diff --git a/core/src/main/java/com/google/bitcoin/protocols/payments/PaymentProtocol.java b/core/src/main/java/com/google/bitcoin/protocols/payments/PaymentProtocol.java index 6ec675d8..5916c9d3 100644 --- a/core/src/main/java/com/google/bitcoin/protocols/payments/PaymentProtocol.java +++ b/core/src/main/java/com/google/bitcoin/protocols/payments/PaymentProtocol.java @@ -17,6 +17,8 @@ package com.google.bitcoin.protocols.payments; +import java.io.Serializable; +import java.math.BigInteger; import java.security.GeneralSecurityException; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; @@ -38,13 +40,19 @@ import java.security.cert.PKIXCertPathValidatorResult; import java.security.cert.PKIXParameters; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; +import java.util.ArrayList; import java.util.List; import javax.annotation.Nullable; import org.bitcoin.protocols.payments.Protos; +import com.google.bitcoin.core.Address; +import com.google.bitcoin.core.NetworkParameters; +import com.google.bitcoin.core.Transaction; import com.google.bitcoin.crypto.X509Utils; +import com.google.bitcoin.script.ScriptBuilder; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import com.google.protobuf.ByteString; import com.google.protobuf.InvalidProtocolBufferException; @@ -56,6 +64,81 @@ public class PaymentProtocol { public static final String MIMETYPE_PAYMENT = "application/bitcoin-payment"; public static final String MIMETYPE_PAYMENTACK = "application/bitcoin-paymentack"; + /** + * Create a payment request with one standard pay to address output. You may want to sign the request using + * {@link #signPaymentRequestPki}. Use {@link Protos.PaymentRequest.Builder#build} to get the actual payment + * request. + * + * @param params + * network parameters + * @param amount + * amount of coins to request, or null + * @param toAddress + * address to request coins to + * @param memo + * arbitrary, user readable memo, or null if none + * @param paymentUrl + * URL to send payment message to, or null if none + * @param merchantData + * arbitrary merchant data, or null if none + * @return created payment request, in its builder form + */ + public static Protos.PaymentRequest.Builder createPaymentRequest(NetworkParameters params, + @Nullable BigInteger amount, Address toAddress, @Nullable String memo, @Nullable String paymentUrl, + @Nullable byte[] merchantData) { + return createPaymentRequest(params, ImmutableList.of(createPayToAddressOutput(amount, toAddress)), memo, + paymentUrl, merchantData); + } + + /** + * Create a payment request. You may want to sign the request using {@link #signPaymentRequestPki}. Use + * {@link Protos.PaymentRequest.Builder#build} to get the actual payment request. + * + * @param params + * network parameters + * @param outputs + * list of outputs to request coins to + * @param memo + * arbitrary, user readable memo, or null if none + * @param paymentUrl + * URL to send payment message to, or null if none + * @param merchantData + * arbitrary merchant data, or null if none + * @return created payment request, in its builder form + */ + public static Protos.PaymentRequest.Builder createPaymentRequest(NetworkParameters params, + List outputs, @Nullable String memo, @Nullable String paymentUrl, + @Nullable byte[] merchantData) { + final Protos.PaymentDetails.Builder paymentDetails = Protos.PaymentDetails.newBuilder(); + paymentDetails.setNetwork(params.getPaymentProtocolId()); + for (Protos.Output output : outputs) + paymentDetails.addOutputs(output); + if (memo != null) + paymentDetails.setMemo(memo); + if (paymentUrl != null) + paymentDetails.setPaymentUrl(paymentUrl); + if (merchantData != null) + paymentDetails.setMerchantData(ByteString.copyFrom(merchantData)); + paymentDetails.setTime(System.currentTimeMillis()); + + final Protos.PaymentRequest.Builder paymentRequest = Protos.PaymentRequest.newBuilder(); + paymentRequest.setSerializedPaymentDetails(paymentDetails.build().toByteString()); + return paymentRequest; + } + + /** + * Parse a payment request. + * + * @param paymentRequest + * payment request to parse + * @return instance of {@link PaymentSession}, used as a value object + * @throws PaymentProtocolException + */ + public static PaymentSession parsePaymentRequest(Protos.PaymentRequest paymentRequest) + throws PaymentProtocolException { + return new PaymentSession(paymentRequest, false, null); + } + /** * Sign the provided payment request. * @@ -216,4 +299,165 @@ public class PaymentProtocol { } } } + + /** + * Create a payment message with one standard pay to address output. + * + * @param transactions + * transactions to include with the payment message + * @param refundAmount + * amount of coins to refund, or null + * @param refundAddress + * address to refund coins to + * @param memo + * arbitrary, user readable memo, or null if none + * @param merchantData + * arbitrary merchant data, or null if none + * @return created payment message + */ + public static Protos.Payment createPaymentMessage(List transactions, + @Nullable BigInteger refundAmount, @Nullable Address refundAddress, @Nullable String memo, + @Nullable byte[] merchantData) { + if (refundAddress != null) { + if (refundAmount == null) + throw new IllegalArgumentException("Specify refund amount if refund address is specified."); + return createPaymentMessage(transactions, + ImmutableList.of(createPayToAddressOutput(refundAmount, refundAddress)), memo, merchantData); + } else { + return createPaymentMessage(transactions, null, memo, merchantData); + } + } + + /** + * Create a payment message. + * + * @param transactions + * transactions to include with the payment message + * @param refundOutputs + * list of outputs to refund coins to, or null + * @param memo + * arbitrary, user readable memo, or null if none + * @param merchantData + * arbitrary merchant data, or null if none + * @return created payment message + */ + public static Protos.Payment createPaymentMessage(List transactions, + @Nullable List refundOutputs, @Nullable String memo, @Nullable byte[] merchantData) { + Protos.Payment.Builder builder = Protos.Payment.newBuilder(); + for (Transaction transaction : transactions) { + transaction.verify(); + builder.addTransactions(ByteString.copyFrom(transaction.unsafeBitcoinSerialize())); + } + if (refundOutputs != null) { + for (Protos.Output output : refundOutputs) + builder.addRefundTo(output); + } + if (memo != null) + builder.setMemo(memo); + if (merchantData != null) + builder.setMerchantData(ByteString.copyFrom(merchantData)); + return builder.build(); + } + + /** + * Parse transactions from payment message. + * + * @param params + * network parameters (needed for transaction deserialization) + * @param paymentMessage + * payment message to parse + * @return list of transactions + */ + public static List parseTransactionsFromPaymentMessage(NetworkParameters params, + Protos.Payment paymentMessage) { + final List transactions = new ArrayList(paymentMessage.getTransactionsCount()); + for (final ByteString transaction : paymentMessage.getTransactionsList()) + transactions.add(new Transaction(params, transaction.toByteArray())); + return transactions; + } + + /** + * Message returned by the merchant in response to a Payment message. + */ + public static class Ack { + @Nullable private final String memo; + + Ack(@Nullable String memo) { + this.memo = memo; + } + + /** + * Returns the memo included by the merchant in the payment ack. This message is typically displayed to the user + * as a notification (e.g. "Your payment was received and is being processed"). If none was provided, returns + * null. + */ + @Nullable public String getMemo() { + return memo; + } + } + + /** + * Create a payment ack. + * + * @param paymentMessage + * payment message to send with the ack + * @param memo + * arbitrary, user readable memo, or null if none + * @return created payment ack + */ + public static Protos.PaymentACK createPaymentAck(Protos.Payment paymentMessage, @Nullable String memo) { + final Protos.PaymentACK.Builder builder = Protos.PaymentACK.newBuilder(); + builder.setPayment(paymentMessage); + if (memo != null) + builder.setMemo(memo); + return builder.build(); + } + + /** + * Parse payment ack. + * + * @param payment + * ack to parse + * @return instance of {@link Ack} + */ + public static Ack parsePaymentAck(Protos.PaymentACK paymentAck) { + final String memo = paymentAck.hasMemo() ? paymentAck.getMemo() : null; + return new Ack(memo); + } + + /** + * Create a standard pay to address output for usage in {@link #createPaymentRequest} and + * {@link #createPaymentMessage}. + * + * @param amount + * amount to pay, or null + * @param address + * address to pay to + * @return output + */ + public static Protos.Output createPayToAddressOutput(@Nullable BigInteger amount, Address address) { + Protos.Output.Builder output = Protos.Output.newBuilder(); + if (amount != null) { + if (amount.compareTo(NetworkParameters.MAX_MONEY) > 0) + throw new IllegalArgumentException("Amount too big: " + amount); + output.setAmount(amount.longValue()); + } else { + output.setAmount(0); + } + output.setScript(ByteString.copyFrom(ScriptBuilder.createOutputScript(address).getProgram())); + return output.build(); + } + + /** + * Value object to hold amount/script pairs. + */ + public static class Output implements Serializable { + public final @Nullable BigInteger amount; + public final byte[] scriptData; + + public Output(@Nullable BigInteger amount, byte[] scriptData) { + this.amount = amount; + this.scriptData = scriptData; + } + } } diff --git a/core/src/main/java/com/google/bitcoin/protocols/payments/PaymentSession.java b/core/src/main/java/com/google/bitcoin/protocols/payments/PaymentSession.java index 7697e8f1..4286a9bb 100644 --- a/core/src/main/java/com/google/bitcoin/protocols/payments/PaymentSession.java +++ b/core/src/main/java/com/google/bitcoin/protocols/payments/PaymentSession.java @@ -18,21 +18,22 @@ import com.google.bitcoin.core.*; import com.google.bitcoin.crypto.TrustStoreLoader; import com.google.bitcoin.params.MainNetParams; import com.google.bitcoin.protocols.payments.PaymentProtocol.PkiVerificationData; -import com.google.bitcoin.script.ScriptBuilder; import com.google.bitcoin.uri.BitcoinURI; import com.google.bitcoin.utils.Threading; import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.protobuf.ByteString; import com.google.protobuf.InvalidProtocolBufferException; + import org.bitcoin.protocols.payments.Protos; import javax.annotation.Nullable; + import java.io.*; import java.math.BigInteger; import java.net.*; import java.security.KeyStoreException; +import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.concurrent.Callable; @@ -217,23 +218,15 @@ public class PaymentSession { } /** - * Message returned by the merchant in response to a Payment message. + * Returns the outputs of the payment request. */ - public class Ack { - @Nullable private String memo; - - Ack(@Nullable String memo) { - this.memo = memo; - } - - /** - * Returns the memo included by the merchant in the payment ack. This message is typically displayed to the user - * as a notification (e.g. "Your payment was received and is being processed"). If none was provided, returns - * null. - */ - @Nullable public String getMemo() { - return memo; + public List getOutputs() { + List outputs = new ArrayList(paymentDetails.getOutputsCount()); + for (Protos.Output output : paymentDetails.getOutputsList()) { + BigInteger amount = output.hasAmount() ? BigInteger.valueOf(output.getAmount()) : null; + outputs.add(new PaymentProtocol.Output(amount, output.getScript().toByteArray())); } + return outputs; } /** @@ -260,6 +253,16 @@ public class PaymentSession { return new Date(paymentDetails.getTime() * 1000); } + /** + * Returns the expires time of the payment request, or null if none. + */ + @Nullable public Date getExpires() { + if (paymentDetails.hasExpires()) + return new Date(paymentDetails.getExpires() * 1000); + else + return null; + } + /** * This should always be called before attempting to call sendPayment. */ @@ -277,6 +280,16 @@ public class PaymentSession { return null; } + /** + * Returns the merchant data included by the merchant in the payment request, or null if none. + */ + @Nullable public byte[] getMerchantData() { + if (paymentDetails.hasMerchantData()) + return paymentDetails.getMerchantData().toByteArray(); + else + return null; + } + /** * Returns a {@link Wallet.SendRequest} suitable for broadcasting to the network. */ @@ -298,7 +311,7 @@ public class PaymentSession { * @param refundAddr will be used by the merchant to send money back if there was a problem. * @param memo is a message to include in the payment message sent to the merchant. */ - public @Nullable ListenableFuture sendPayment(List txns, @Nullable Address refundAddr, @Nullable String memo) + public @Nullable ListenableFuture sendPayment(List txns, @Nullable Address refundAddr, @Nullable String memo) throws PaymentProtocolException, VerificationException, IOException { Protos.Payment payment = getPayment(txns, refundAddr, memo); if (payment == null) @@ -324,36 +337,21 @@ public class PaymentSession { */ public @Nullable Protos.Payment getPayment(List txns, @Nullable Address refundAddr, @Nullable String memo) throws IOException, PaymentProtocolException.InvalidNetwork { - if (!paymentDetails.hasPaymentUrl()) + if (paymentDetails.hasPaymentUrl()) { + for (Transaction tx : txns) + if (!tx.getParams().equals(params)) + throw new PaymentProtocolException.InvalidNetwork(params.getPaymentProtocolId()); + return PaymentProtocol.createPaymentMessage(txns, totalValue, refundAddr, memo, getMerchantData()); + } else { return null; - if (!txns.get(0).getParams().equals(params)) - throw new PaymentProtocolException.InvalidNetwork(params.getPaymentProtocolId()); - Protos.Payment.Builder payment = Protos.Payment.newBuilder(); - if (paymentDetails.hasMerchantData()) - payment.setMerchantData(paymentDetails.getMerchantData()); - if (refundAddr != null) { - Protos.Output.Builder refundOutput = Protos.Output.newBuilder(); - refundOutput.setAmount(totalValue.longValue()); - refundOutput.setScript(ByteString.copyFrom(ScriptBuilder.createOutputScript(refundAddr).getProgram())); - payment.addRefundTo(refundOutput); } - if (memo != null) { - payment.setMemo(memo); - } - for (Transaction txn : txns) { - txn.verify(); - ByteArrayOutputStream o = new ByteArrayOutputStream(); - txn.bitcoinSerialize(o); - payment.addTransactions(ByteString.copyFrom(o.toByteArray())); - } - return payment.build(); } @VisibleForTesting - protected ListenableFuture sendPayment(final URL url, final Protos.Payment payment) { - return executor.submit(new Callable() { + protected ListenableFuture sendPayment(final URL url, final Protos.Payment payment) { + return executor.submit(new Callable() { @Override - public Ack call() throws Exception { + public PaymentProtocol.Ack call() throws Exception { HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); connection.setRequestProperty("Content-Type", PaymentProtocol.MIMETYPE_PAYMENT); @@ -370,13 +368,8 @@ public class PaymentSession { outStream.close(); // Get response. - InputStream inStream = connection.getInputStream(); - Protos.PaymentACK.Builder paymentAckBuilder = Protos.PaymentACK.newBuilder().mergeFrom(inStream); - Protos.PaymentACK paymentAck = paymentAckBuilder.build(); - String memo = null; - if (paymentAck.hasMemo()) - memo = paymentAck.getMemo(); - return new Ack(memo); + Protos.PaymentACK paymentAck = Protos.PaymentACK.parseFrom(connection.getInputStream()); + return PaymentProtocol.parsePaymentAck(paymentAck); } }); } diff --git a/core/src/test/java/com/google/bitcoin/protocols/payments/PaymentProtocolTest.java b/core/src/test/java/com/google/bitcoin/protocols/payments/PaymentProtocolTest.java index 0d1ae713..a0ec597c 100644 --- a/core/src/test/java/com/google/bitcoin/protocols/payments/PaymentProtocolTest.java +++ b/core/src/test/java/com/google/bitcoin/protocols/payments/PaymentProtocolTest.java @@ -16,23 +16,46 @@ package com.google.bitcoin.protocols.payments; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import java.math.BigInteger; import java.security.KeyStore; import java.security.PrivateKey; import java.security.cert.X509Certificate; +import java.util.LinkedList; +import java.util.List; import org.bitcoin.protocols.payments.Protos; +import org.bitcoin.protocols.payments.Protos.Payment; +import org.bitcoin.protocols.payments.Protos.PaymentACK; +import org.bitcoin.protocols.payments.Protos.PaymentRequest; import org.junit.Before; import org.junit.Test; +import com.google.bitcoin.core.Address; +import com.google.bitcoin.core.ECKey; +import com.google.bitcoin.core.NetworkParameters; +import com.google.bitcoin.core.Transaction; import com.google.bitcoin.crypto.X509Utils; +import com.google.bitcoin.params.UnitTestParams; +import com.google.bitcoin.protocols.payments.PaymentProtocol.Output; import com.google.bitcoin.protocols.payments.PaymentProtocol.PkiVerificationData; import com.google.bitcoin.protocols.payments.PaymentProtocolException.PkiVerificationException; +import com.google.bitcoin.script.ScriptBuilder; +import com.google.bitcoin.testing.FakeTxBuilder; public class PaymentProtocolTest { + // static test data + private static final NetworkParameters NETWORK_PARAMS = UnitTestParams.get(); + private static final BigInteger AMOUNT = BigInteger.ONE; + private static final Address TO_ADDRESS = new ECKey().toAddress(NETWORK_PARAMS); + private static final String MEMO = "memo"; + private static final String PAYMENT_URL = "https://example.com"; + private static final byte[] MERCHANT_DATA = new byte[] { 0, 1, 2 }; + private KeyStore caStore; private X509Certificate caCert; @@ -81,4 +104,56 @@ public class PaymentProtocolTest { paymentRequest.setSerializedPaymentDetails(paymentDetails.build().toByteString()); return paymentRequest.build(); } + + public void testPaymentRequest() throws Exception { + // Create + PaymentRequest paymentRequest = PaymentProtocol.createPaymentRequest(NETWORK_PARAMS, AMOUNT, TO_ADDRESS, MEMO, + PAYMENT_URL, MERCHANT_DATA).build(); + byte[] paymentRequestBytes = paymentRequest.toByteArray(); + + // Parse + PaymentSession parsedPaymentRequest = PaymentProtocol.parsePaymentRequest(PaymentRequest + .parseFrom(paymentRequestBytes)); + final List parsedOutputs = parsedPaymentRequest.getOutputs(); + assertEquals(1, parsedOutputs.size()); + assertEquals(AMOUNT, parsedOutputs.get(0).amount); + assertEquals(ScriptBuilder.createOutputScript(TO_ADDRESS).getProgram(), parsedOutputs.get(0).scriptData); + assertEquals(MEMO, parsedPaymentRequest.getMemo()); + assertEquals(PAYMENT_URL, parsedPaymentRequest.getPaymentUrl()); + assertEquals(MERCHANT_DATA, parsedPaymentRequest.getMerchantData()); + } + + @Test + public void testPaymentMessage() throws Exception { + // Create + List transactions = new LinkedList(); + transactions.add(FakeTxBuilder.createFakeTx(NETWORK_PARAMS, AMOUNT, TO_ADDRESS)); + BigInteger refundAmount = BigInteger.ONE; + Address refundAddress = new ECKey().toAddress(NETWORK_PARAMS); + Payment payment = PaymentProtocol.createPaymentMessage(transactions, refundAmount, refundAddress, MEMO, + MERCHANT_DATA); + byte[] paymentBytes = payment.toByteArray(); + + // Parse + Payment parsedPayment = Payment.parseFrom(paymentBytes); + List parsedTransactions = PaymentProtocol.parseTransactionsFromPaymentMessage(NETWORK_PARAMS, + parsedPayment); + assertEquals(transactions, parsedTransactions); + assertEquals(1, parsedPayment.getRefundToCount()); + assertEquals(MEMO, parsedPayment.getMemo()); + assertArrayEquals(MERCHANT_DATA, parsedPayment.getMerchantData().toByteArray()); + } + + @Test + public void testPaymentAck() throws Exception { + // Create + Payment paymentMessage = Protos.Payment.newBuilder().build(); + PaymentACK paymentAck = PaymentProtocol.createPaymentAck(paymentMessage, MEMO); + byte[] paymentAckBytes = paymentAck.toByteArray(); + + // Parse + PaymentACK parsedPaymentAck = PaymentACK.parseFrom(paymentAckBytes); + assertEquals(paymentMessage, parsedPaymentAck.getPayment()); + assertEquals(MEMO, parsedPaymentAck.getMemo()); + } } diff --git a/core/src/test/java/com/google/bitcoin/protocols/payments/PaymentSessionTest.java b/core/src/test/java/com/google/bitcoin/protocols/payments/PaymentSessionTest.java index e1cb9426..adc265ed 100644 --- a/core/src/test/java/com/google/bitcoin/protocols/payments/PaymentSessionTest.java +++ b/core/src/test/java/com/google/bitcoin/protocols/payments/PaymentSessionTest.java @@ -197,7 +197,7 @@ public class PaymentSessionTest { return paymentLog; } - protected ListenableFuture sendPayment(final URL url, final Protos.Payment payment) { + protected ListenableFuture sendPayment(final URL url, final Protos.Payment payment) { paymentLog.add(new PaymentLogItem(url, payment)); return null; } diff --git a/tools/src/main/java/com/google/bitcoin/tools/WalletTool.java b/tools/src/main/java/com/google/bitcoin/tools/WalletTool.java index c03826a4..5223cde9 100644 --- a/tools/src/main/java/com/google/bitcoin/tools/WalletTool.java +++ b/tools/src/main/java/com/google/bitcoin/tools/WalletTool.java @@ -23,6 +23,7 @@ import com.google.bitcoin.net.discovery.DnsDiscovery; import com.google.bitcoin.params.MainNetParams; import com.google.bitcoin.params.RegTestParams; import com.google.bitcoin.params.TestNet3Params; +import com.google.bitcoin.protocols.payments.PaymentProtocol; import com.google.bitcoin.protocols.payments.PaymentProtocolException; import com.google.bitcoin.protocols.payments.PaymentSession; import com.google.bitcoin.store.*; @@ -557,14 +558,14 @@ public class WalletTool { } setup(); // No refund address specified, no user-specified memo field. - ListenableFuture future = session.sendPayment(ImmutableList.of(req.tx), null, null); + ListenableFuture future = session.sendPayment(ImmutableList.of(req.tx), null, null); if (future == null) { // No payment_url for submission so, broadcast and wait. peers.startAsync(); peers.awaitRunning(); peers.broadcastTransaction(req.tx).get(); } else { - PaymentSession.Ack ack = future.get(); + PaymentProtocol.Ack ack = future.get(); wallet.commitTx(req.tx); System.out.println("Memo from server: " + ack.getMemo()); }