3
0
mirror of https://github.com/Qortal/altcoinj.git synced 2025-02-07 14:54:15 +00:00

Payment channels: add payment acks.

Add a new PAYMENT_ACK message to the protocol. Make incrementPayment return a future that completes when the server has acknowledge the balance increase.

Also, prevent users from overlapping multiple increase payment requests.

This resolves race conditions that can occur when the billed-for activity is asynchronous to the protocol in which the micropayment protocol is embedded. In this case, it was previously impossible to know when the async activity could be resumed as it would otherwise race with the process of the server checking the payment signature and updating the balance. Most applications of micropayments will use a single protocol that has been extended with an embedding, and thus this is not an issue. However in some rare applications the payment process may run alongside the existing protocol rather than inside it. In this case, payment acks should be used for synchronization.
This commit is contained in:
Mike Hearn 2013-11-01 15:31:57 +01:00
parent 06ac0105f3
commit aff5f140fb
6 changed files with 108 additions and 38 deletions

View File

@ -4,6 +4,7 @@ import com.google.bitcoin.core.*;
import com.google.bitcoin.protocols.channels.PaymentChannelCloseException.CloseReason;
import com.google.bitcoin.utils.Threading;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.util.concurrent.*;
import com.google.protobuf.ByteString;
import net.jcip.annotations.GuardedBy;
import org.bitcoin.paymentchannel.Protos;
@ -109,6 +110,9 @@ public class PaymentChannelClient {
private final ECKey myKey;
private final BigInteger maxValue;
@GuardedBy("lock") SettableFuture<BigInteger> increasePaymentFuture;
@GuardedBy("lock") BigInteger lastPaymentActualAmount;
/**
* <p>The maximum amount of time for which we will accept the server locking up our funds for the multisig
* contract.</p>
@ -255,6 +259,9 @@ public class PaymentChannelClient {
case CHANNEL_OPEN:
receiveChannelOpen();
return;
case PAYMENT_ACK:
receivePaymentAck();
return;
case CLOSE:
receiveClose(msg);
return;
@ -418,33 +425,66 @@ public class PaymentChannelClient {
* Increments the total value which we pay the server. Note that the amount of money sent may not be the same as the
* amount of money actually requested. It can be larger if the amount left over in the channel would be too small to
* be accepted by the Bitcoin network. ValueOutOfRangeException will be thrown, however, if there's not enough money
* left in the channel to make the payment at all.
* left in the channel to make the payment at all. Only one payment can be in-flight at once. You have to ensure
* you wait for the previous increase payment future to complete before incrementing the payment again.
*
* @param size How many satoshis to increment the payment by (note: not the new total).
* @throws ValueOutOfRangeException If the size is negative or would pay more than this channel's total value
* ({@link PaymentChannelClientConnection#state()}.getTotalValue())
* @throws IllegalStateException If the channel has been closed or is not yet open
* (see {@link PaymentChannelClientConnection#getChannelOpenFuture()} for the second)
* @return amount of money actually sent.
* @return a future that completes when the server acknowledges receipt and acceptance of the payment.
*/
public BigInteger incrementPayment(BigInteger size) throws ValueOutOfRangeException, IllegalStateException {
public ListenableFuture<BigInteger> incrementPayment(BigInteger size) throws ValueOutOfRangeException, IllegalStateException {
lock.lock();
try {
if (state() == null || !connectionOpen || step != InitStep.CHANNEL_OPEN)
throw new IllegalStateException("Channel is not fully initialized/has already been closed");
if (increasePaymentFuture != null)
throw new IllegalStateException("Already incrementing paying, wait for previous payment to complete.");
PaymentChannelClientState.IncrementedPayment payment = state().incrementPaymentBy(size);
Protos.UpdatePayment.Builder updatePaymentBuilder = Protos.UpdatePayment.newBuilder()
.setSignature(ByteString.copyFrom(payment.signature.encodeToBitcoin()))
.setClientChangeValue(state.getValueRefunded().longValue());
increasePaymentFuture = SettableFuture.create();
increasePaymentFuture.addListener(new Runnable() {
@Override
public void run() {
lock.lock();
increasePaymentFuture = null;
lock.unlock();
}
}, MoreExecutors.sameThreadExecutor());
conn.sendToServer(Protos.TwoWayChannelMessage.newBuilder()
.setUpdatePayment(updatePaymentBuilder)
.setType(Protos.TwoWayChannelMessage.MessageType.UPDATE_PAYMENT)
.build());
return payment.amount;
lastPaymentActualAmount = payment.amount;
return increasePaymentFuture;
} finally {
lock.unlock();
}
}
private void receivePaymentAck() {
SettableFuture<BigInteger> future;
BigInteger value;
lock.lock();
try {
if (increasePaymentFuture == null) return;
checkNotNull(increasePaymentFuture, "Server sent a PAYMENT_ACK with no outstanding payment");
log.info("Received a PAYMENT_ACK from the server");
future = increasePaymentFuture;
value = lastPaymentActualAmount;
} finally {
lock.unlock();
}
// Ensure the future runs without the client lock held.
future.set(value);
}
}

View File

@ -30,8 +30,7 @@ import java.math.BigInteger;
import java.net.InetSocketAddress;
/**
* Manages a {@link PaymentChannelClientState} by connecting to a server over TLS and exchanging the necessary data over
* protobufs.
* A simple utility class that runs the micropayment protocol over a raw TCP socket using NIO, standalone.
*/
public class PaymentChannelClientConnection {
// Various futures which will be completed later
@ -134,8 +133,8 @@ public class PaymentChannelClientConnection {
* @throws IllegalStateException If the channel has been closed or is not yet open
* (see {@link PaymentChannelClientConnection#getChannelOpenFuture()} for the second)
*/
public void incrementPayment(BigInteger size) throws ValueOutOfRangeException, IllegalStateException {
channelClient.incrementPayment(size);
public ListenableFuture<BigInteger> incrementPayment(BigInteger size) throws ValueOutOfRangeException, IllegalStateException {
return channelClient.incrementPayment(size);
}
/**

View File

@ -269,6 +269,10 @@ public class PaymentChannelServer {
if (bestPaymentChange.compareTo(BigInteger.ZERO) > 0)
conn.paymentIncrease(bestPaymentChange, state.getBestValueToMe());
Protos.TwoWayChannelMessage.Builder ack = Protos.TwoWayChannelMessage.newBuilder();
ack.setType(Protos.TwoWayChannelMessage.MessageType.PAYMENT_ACK);
conn.sendToClient(ack.build());
if (!stillUsable) {
log.info("Channel is now fully exhausted, closing/initiating settlement");
settlePayment(CloseReason.CHANNEL_EXHAUSTED);

View File

@ -442,6 +442,14 @@ public final class Protos {
* <code>UPDATE_PAYMENT = 8;</code>
*/
UPDATE_PAYMENT(7, 8),
/**
* <code>PAYMENT_ACK = 11;</code>
*
* <pre>
* Sent by the server to the client after an UPDATE_PAYMENT message is successfully processed.
* </pre>
*/
PAYMENT_ACK(8, 11),
/**
* <code>CLOSE = 9;</code>
*
@ -457,7 +465,7 @@ public final class Protos {
* with another CLOSE and then disconnects.
* </pre>
*/
CLOSE(8, 9),
CLOSE(9, 9),
/**
* <code>ERROR = 10;</code>
*
@ -468,7 +476,7 @@ public final class Protos {
* because the protocol may not run over TCP.
* </pre>
*/
ERROR(9, 10),
ERROR(10, 10),
;
/**
@ -513,6 +521,14 @@ public final class Protos {
* <code>UPDATE_PAYMENT = 8;</code>
*/
public static final int UPDATE_PAYMENT_VALUE = 8;
/**
* <code>PAYMENT_ACK = 11;</code>
*
* <pre>
* Sent by the server to the client after an UPDATE_PAYMENT message is successfully processed.
* </pre>
*/
public static final int PAYMENT_ACK_VALUE = 11;
/**
* <code>CLOSE = 9;</code>
*
@ -554,6 +570,7 @@ public final class Protos {
case 6: return PROVIDE_CONTRACT;
case 7: return CHANNEL_OPEN;
case 8: return UPDATE_PAYMENT;
case 11: return PAYMENT_ACK;
case 9: return CLOSE;
case 10: return ERROR;
default: return null;
@ -7968,7 +7985,7 @@ public final class Protos {
static {
java.lang.String[] descriptorData = {
"\n\024paymentchannel.proto\022\017paymentchannels\"" +
"\343\005\n\024TwoWayChannelMessage\022?\n\004type\030\001 \002(\01621" +
"\364\005\n\024TwoWayChannelMessage\022?\n\004type\030\001 \002(\01621" +
".paymentchannels.TwoWayChannelMessage.Me" +
"ssageType\0226\n\016client_version\030\002 \001(\0132\036.paym" +
"entchannels.ClientVersion\0226\n\016server_vers" +
@ -7981,30 +7998,30 @@ public final class Protos {
".ProvideContract\0226\n\016update_payment\030\010 \001(\013" +
"2\036.paymentchannels.UpdatePayment\022%\n\005clos" +
"e\030\t \001(\0132\026.paymentchannels.Close\022%\n\005error" +
"\030\n \001(\0132\026.paymentchannels.Error\"\274\001\n\013Messa" +
"\030\n \001(\0132\026.paymentchannels.Error\"\315\001\n\013Messa" +
"geType\022\022\n\016CLIENT_VERSION\020\001\022\022\n\016SERVER_VER" +
"SION\020\002\022\014\n\010INITIATE\020\003\022\022\n\016PROVIDE_REFUND\020\004" +
"\022\021\n\rRETURN_REFUND\020\005\022\024\n\020PROVIDE_CONTRACT\020" +
"\006\022\020\n\014CHANNEL_OPEN\020\007\022\022\n\016UPDATE_PAYMENT\020\010\022" +
"\t\n\005CLOSE\020\t\022\t\n\005ERROR\020\n\"X\n\rClientVersion\022\r",
"\n\005major\030\001 \002(\005\022\020\n\005minor\030\002 \001(\005:\0010\022&\n\036previ" +
"ous_channel_contract_hash\030\003 \001(\014\"0\n\rServe" +
"rVersion\022\r\n\005major\030\001 \002(\005\022\020\n\005minor\030\002 \001(\005:\001" +
"0\"]\n\010Initiate\022\024\n\014multisig_key\030\001 \002(\014\022!\n\031m" +
"in_accepted_channel_size\030\002 \002(\004\022\030\n\020expire" +
"_time_secs\030\003 \002(\004\"1\n\rProvideRefund\022\024\n\014mul" +
"tisig_key\030\001 \002(\014\022\n\n\002tx\030\002 \002(\014\"!\n\014ReturnRef" +
"und\022\021\n\tsignature\030\001 \002(\014\"\035\n\017ProvideContrac" +
"t\022\n\n\002tx\030\001 \002(\014\"?\n\rUpdatePayment\022\033\n\023client" +
"_change_value\030\001 \002(\004\022\021\n\tsignature\030\002 \002(\014\"\023",
"\n\005Close\022\n\n\002tx\030\003 \002(\014\"\363\001\n\005Error\0225\n\004code\030\001 " +
"\001(\0162 .paymentchannels.Error.ErrorCode:\005O" +
"THER\022\023\n\013explanation\030\002 \001(\t\"\235\001\n\tErrorCode\022" +
"\013\n\007TIMEOUT\020\001\022\020\n\014SYNTAX_ERROR\020\002\022\031\n\025NO_ACC" +
"EPTABLE_VERSION\020\003\022\023\n\017BAD_TRANSACTION\020\004\022\031" +
"\n\025TIME_WINDOW_TOO_LARGE\020\005\022\033\n\027CHANNEL_VAL" +
"UE_TOO_LARGE\020\006\022\t\n\005OTHER\020\007B$\n\032org.bitcoin" +
".paymentchannelB\006Protos"
"\017\n\013PAYMENT_ACK\020\013\022\t\n\005CLOSE\020\t\022\t\n\005ERROR\020\n\"X",
"\n\rClientVersion\022\r\n\005major\030\001 \002(\005\022\020\n\005minor\030" +
"\002 \001(\005:\0010\022&\n\036previous_channel_contract_ha" +
"sh\030\003 \001(\014\"0\n\rServerVersion\022\r\n\005major\030\001 \002(\005" +
"\022\020\n\005minor\030\002 \001(\005:\0010\"]\n\010Initiate\022\024\n\014multis" +
"ig_key\030\001 \002(\014\022!\n\031min_accepted_channel_siz" +
"e\030\002 \002(\004\022\030\n\020expire_time_secs\030\003 \002(\004\"1\n\rPro" +
"videRefund\022\024\n\014multisig_key\030\001 \002(\014\022\n\n\002tx\030\002" +
" \002(\014\"!\n\014ReturnRefund\022\021\n\tsignature\030\001 \002(\014\"" +
"\035\n\017ProvideContract\022\n\n\002tx\030\001 \002(\014\"?\n\rUpdate" +
"Payment\022\033\n\023client_change_value\030\001 \002(\004\022\021\n\t",
"signature\030\002 \002(\014\"\023\n\005Close\022\n\n\002tx\030\003 \002(\014\"\363\001\n" +
"\005Error\0225\n\004code\030\001 \001(\0162 .paymentchannels.E" +
"rror.ErrorCode:\005OTHER\022\023\n\013explanation\030\002 \001" +
"(\t\"\235\001\n\tErrorCode\022\013\n\007TIMEOUT\020\001\022\020\n\014SYNTAX_" +
"ERROR\020\002\022\031\n\025NO_ACCEPTABLE_VERSION\020\003\022\023\n\017BA" +
"D_TRANSACTION\020\004\022\031\n\025TIME_WINDOW_TOO_LARGE" +
"\020\005\022\033\n\027CHANNEL_VALUE_TOO_LARGE\020\006\022\t\n\005OTHER" +
"\020\007B$\n\032org.bitcoin.paymentchannelB\006Protos"
};
com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner assigner =
new com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner() {

View File

@ -47,6 +47,8 @@ message TwoWayChannelMessage {
// message.
CHANNEL_OPEN = 7;
UPDATE_PAYMENT = 8;
// Sent by the server to the client after an UPDATE_PAYMENT message is successfully processed.
PAYMENT_ACK = 11;
// Either side can send this message. If the client sends it to the server, then the server
// takes the most recent signature it received in an UPDATE_PAYMENT and uses it to create a
// valid transaction, which it then broadcasts on the network.

View File

@ -165,11 +165,11 @@ public class ChannelConnectionTest extends TestWithWallet {
});
Thread.sleep(1250); // No timeouts once the channel is open
client.incrementPayment(Utils.CENT);
client.incrementPayment(Utils.CENT).get();
assertEquals(Utils.CENT, q.take());
client.incrementPayment(Utils.CENT);
client.incrementPayment(Utils.CENT).get();
assertEquals(Utils.CENT.multiply(BigInteger.valueOf(2)), q.take());
client.incrementPayment(Utils.CENT);
client.incrementPayment(Utils.CENT).get();
assertEquals(Utils.CENT.multiply(BigInteger.valueOf(3)), q.take());
latch.await();
@ -291,6 +291,7 @@ public class ChannelConnectionTest extends TestWithWallet {
assertEquals(Utils.CENT, pair.serverRecorder.q.take());
server.close();
server.connectionClosed();
client.receiveMessage(pair.serverRecorder.checkNextMsg(MessageType.PAYMENT_ACK));
client.receiveMessage(pair.serverRecorder.checkNextMsg(MessageType.CLOSE));
client.connectionClosed();
assertFalse(client.connectionOpen);
@ -340,6 +341,7 @@ public class ChannelConnectionTest extends TestWithWallet {
client.incrementPayment(Utils.CENT);
server.receiveMessage(pair.clientRecorder.checkNextMsg(MessageType.UPDATE_PAYMENT));
pair.serverRecorder.checkTotalPayment(Utils.CENT.multiply(BigInteger.valueOf(2)));
client.receiveMessage(pair.serverRecorder.checkNextMsg(MessageType.PAYMENT_ACK));
PaymentChannelClient openClient = client;
ChannelTestUtils.RecordingPair openPair = pair;
@ -610,6 +612,7 @@ public class ChannelConnectionTest extends TestWithWallet {
// The channel is now empty.
assertEquals(BigInteger.ZERO, client.state().getValueRefunded());
pair.serverRecorder.q.take(); // Take the BigInteger.
client.receiveMessage(pair.serverRecorder.checkNextMsg(MessageType.PAYMENT_ACK));
client.receiveMessage(pair.serverRecorder.checkNextMsg(MessageType.CLOSE));
assertEquals(CloseReason.SERVER_REQUESTED_CLOSE, pair.clientRecorder.q.take());
client.connectionClosed();
@ -646,15 +649,20 @@ public class ChannelConnectionTest extends TestWithWallet {
pair.clientRecorder.checkOpened();
assertNull(pair.serverRecorder.q.poll());
assertNull(pair.clientRecorder.q.poll());
client.incrementPayment(Utils.CENT);
client.incrementPayment(Utils.CENT);
ListenableFuture<BigInteger> future = client.incrementPayment(Utils.CENT);
server.receiveMessage(pair.clientRecorder.checkNextMsg(MessageType.UPDATE_PAYMENT));
pair.serverRecorder.q.take();
client.receiveMessage(pair.serverRecorder.checkNextMsg(MessageType.PAYMENT_ACK));
assertTrue(future.isDone());
client.incrementPayment(Utils.CENT);
server.receiveMessage(pair.clientRecorder.checkNextMsg(MessageType.UPDATE_PAYMENT));
pair.serverRecorder.q.take();
client.receiveMessage(pair.serverRecorder.checkNextMsg(MessageType.PAYMENT_ACK));
client.incrementPayment(Utils.CENT);
server.receiveMessage(pair.clientRecorder.checkNextMsg(MessageType.UPDATE_PAYMENT));
pair.serverRecorder.q.take();
server.receiveMessage(pair.clientRecorder.checkNextMsg(MessageType.UPDATE_PAYMENT));
pair.serverRecorder.q.take();
client.receiveMessage(pair.serverRecorder.checkNextMsg(MessageType.PAYMENT_ACK));
// Close it and verify it's considered to be closed.
broadcastTxPause.release();