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.

This commit is contained in:
Andreas Schildbach
2014-05-03 17:24:16 +02:00
committed by Mike Hearn
parent bd49cd4027
commit cdfec498a4
5 changed files with 365 additions and 52 deletions

View File

@@ -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<Protos.Output> 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<Transaction> 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<Transaction> transactions,
@Nullable List<Protos.Output> 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<Transaction> parseTransactionsFromPaymentMessage(NetworkParameters params,
Protos.Payment paymentMessage) {
final List<Transaction> transactions = new ArrayList<Transaction>(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;
}
}
}

View File

@@ -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<PaymentProtocol.Output> getOutputs() {
List<PaymentProtocol.Output> outputs = new ArrayList<PaymentProtocol.Output>(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<Ack> sendPayment(List<Transaction> txns, @Nullable Address refundAddr, @Nullable String memo)
public @Nullable ListenableFuture<PaymentProtocol.Ack> sendPayment(List<Transaction> 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<Transaction> 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<Ack> sendPayment(final URL url, final Protos.Payment payment) {
return executor.submit(new Callable<Ack>() {
protected ListenableFuture<PaymentProtocol.Ack> sendPayment(final URL url, final Protos.Payment payment) {
return executor.submit(new Callable<PaymentProtocol.Ack>() {
@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);
}
});
}

View File

@@ -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<Output> 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<Transaction> transactions = new LinkedList<Transaction>();
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<Transaction> 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());
}
}

View File

@@ -197,7 +197,7 @@ public class PaymentSessionTest {
return paymentLog;
}
protected ListenableFuture<Ack> sendPayment(final URL url, final Protos.Payment payment) {
protected ListenableFuture<PaymentProtocol.Ack> sendPayment(final URL url, final Protos.Payment payment) {
paymentLog.add(new PaymentLogItem(url, payment));
return null;
}

View File

@@ -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<PaymentSession.Ack> future = session.sendPayment(ImmutableList.of(req.tx), null, null);
ListenableFuture<PaymentProtocol.Ack> 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());
}