From cdfec498a403acb1aa38847160045b94ede79eff Mon Sep 17 00:00:00 2001 From: Andreas Schildbach Date: Sat, 3 May 2014 17:24:16 +0200 Subject: [PATCH] Extract methods for creating and parsing payment requests, payment messages and payment acks, as well as Ack data class. Expose more payment request data from PaymentSession. Add unit tests for roundtripping all messages. --- .../protocols/payments/PaymentProtocol.java | 244 ++++++++++++++++++ .../protocols/payments/PaymentSession.java | 91 +++---- .../payments/PaymentProtocolTest.java | 75 ++++++ .../payments/PaymentSessionTest.java | 2 +- .../com/google/bitcoin/tools/WalletTool.java | 5 +- 5 files changed, 365 insertions(+), 52 deletions(-) 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()); }