diff --git a/core/src/main/java/com/google/bitcoin/protocols/channels/PaymentChannelCloseException.java b/core/src/main/java/com/google/bitcoin/protocols/channels/PaymentChannelCloseException.java index 8a4a526b..9982516b 100644 --- a/core/src/main/java/com/google/bitcoin/protocols/channels/PaymentChannelCloseException.java +++ b/core/src/main/java/com/google/bitcoin/protocols/channels/PaymentChannelCloseException.java @@ -12,6 +12,8 @@ public class PaymentChannelCloseException extends Exception { TIME_WINDOW_TOO_LARGE, /** Generated by a client when the server requested we lock up an unacceptably high value */ SERVER_REQUESTED_TOO_MUCH_VALUE, + /** Generated by the server when the client has used up all the value in the channel. */ + CHANNEL_EXHAUSTED, // Values after here indicate its probably possible to try reopening channel again @@ -45,16 +47,17 @@ public class PaymentChannelCloseException extends Exception { CONNECTION_CLOSED, } - CloseReason error; - public CloseReason getCloseReason() { - return error; - } + private final CloseReason error; public PaymentChannelCloseException(String message, CloseReason error) { super(message); this.error = error; } + public CloseReason getCloseReason() { + return error; + } + public String toString() { return "PaymentChannelCloseException for reason " + getCloseReason().toString(); } diff --git a/core/src/main/java/com/google/bitcoin/protocols/channels/PaymentChannelServer.java b/core/src/main/java/com/google/bitcoin/protocols/channels/PaymentChannelServer.java index fecfe2c7..3bbe8ab6 100644 --- a/core/src/main/java/com/google/bitcoin/protocols/channels/PaymentChannelServer.java +++ b/core/src/main/java/com/google/bitcoin/protocols/channels/PaymentChannelServer.java @@ -87,7 +87,7 @@ public class PaymentChannelServer { */ public void paymentIncrease(BigInteger by, BigInteger to); } - @GuardedBy("lock") private final ServerConnection conn; + private final ServerConnection conn; // Used to keep track of whether or not the "socket" ie connection is open and we can generate messages @GuardedBy("lock") private boolean connectionOpen = false; @@ -262,11 +262,17 @@ public class PaymentChannelServer { Protos.UpdatePayment updatePayment = msg.getUpdatePayment(); BigInteger lastBestPayment = state.getBestValueToMe(); - state.incrementPayment(BigInteger.valueOf(updatePayment.getClientChangeValue()), updatePayment.getSignature().toByteArray()); + final BigInteger refundSize = BigInteger.valueOf(updatePayment.getClientChangeValue()); + boolean stillUsable = state.incrementPayment(refundSize, updatePayment.getSignature().toByteArray()); BigInteger bestPaymentChange = state.getBestValueToMe().subtract(lastBestPayment); if (bestPaymentChange.compareTo(BigInteger.ZERO) > 0) conn.paymentIncrease(bestPaymentChange, state.getBestValueToMe()); + + if (!stillUsable) { + log.info("Channel is now fully exhausted, closing/initiating settlement"); + settlePayment(CloseReason.CHANNEL_EXHAUSTED); + } } /** @@ -351,36 +357,44 @@ public class PaymentChannelServer { @GuardedBy("lock") private void receiveCloseMessage() throws ValueOutOfRangeException { log.info("Got CLOSE message, closing channel"); - connectionClosing = true; if (state != null) { - Futures.addCallback(state.close(), new FutureCallback() { - @Override - public void onSuccess(Transaction result) { - // Send the successfully accepted transaction back to the client. - final Protos.TwoWayChannelMessage.Builder msg = Protos.TwoWayChannelMessage.newBuilder(); - msg.setType(Protos.TwoWayChannelMessage.MessageType.CLOSE); - if (result != null) { - // Result can be null on various error paths, like if we never actually opened - // properly and so on. - msg.getCloseBuilder().setTx(ByteString.copyFrom(result.bitcoinSerialize())); - } - log.info("Sending CLOSE back with finalized broadcast contract."); - conn.sendToClient(msg.build()); - // The client is expected to hang up the TCP connection after we send back the - // CLOSE message. - } - - @Override - public void onFailure(Throwable t) { - log.error("Failed to broadcast close TX", t); - conn.destroyConnection(CloseReason.CLIENT_REQUESTED_CLOSE); - } - }); + settlePayment(CloseReason.CLIENT_REQUESTED_CLOSE); } else { conn.destroyConnection(CloseReason.CLIENT_REQUESTED_CLOSE); } } + @GuardedBy("lock") + private void settlePayment(final CloseReason clientRequestedClose) throws ValueOutOfRangeException { + // Setting connectionClosing here prevents us from sending another CLOSE when state.close() calls + // close() on us here below via the stored channel state. + // TODO: Strongly separate the lifecycle of the payment channel from the TCP connection in these classes. + connectionClosing = true; + Futures.addCallback(state.close(), new FutureCallback() { + @Override + public void onSuccess(Transaction result) { + // Send the successfully accepted transaction back to the client. + final Protos.TwoWayChannelMessage.Builder msg = Protos.TwoWayChannelMessage.newBuilder(); + msg.setType(Protos.TwoWayChannelMessage.MessageType.CLOSE); + if (result != null) { + // Result can be null on various error paths, like if we never actually opened + // properly and so on. + msg.getCloseBuilder().setTx(ByteString.copyFrom(result.bitcoinSerialize())); + log.info("Sending CLOSE back with finalized broadcast contract."); + } else { + log.info("Sending CLOSE back without finalized broadcast contract."); + } + conn.sendToClient(msg.build()); + } + + @Override + public void onFailure(Throwable t) { + log.error("Failed to broadcast close TX", t); + conn.destroyConnection(clientRequestedClose); + } + }); + } + /** *

Called when the connection terminates. Notifies the {@link StoredServerChannel} object that we can attempt to * resume this channel in the future and stops generating messages for the client.

diff --git a/core/src/main/java/com/google/bitcoin/protocols/channels/PaymentChannelServerState.java b/core/src/main/java/com/google/bitcoin/protocols/channels/PaymentChannelServerState.java index c908011b..9b64b38d 100644 --- a/core/src/main/java/com/google/bitcoin/protocols/channels/PaymentChannelServerState.java +++ b/core/src/main/java/com/google/bitcoin/protocols/channels/PaymentChannelServerState.java @@ -281,8 +281,9 @@ public class PaymentChannelServerState { * @param refundSize How many satoshis of the original contract are refunded to the client (the rest are ours) * @param signatureBytes The new signature spending the multi-sig contract to a new payment transaction * @throws VerificationException If the signature does not verify or size is out of range (incl being rejected by the network as dust). + * @return true if there is more value left on the channel, false if it is now fully used up. */ - public synchronized void incrementPayment(BigInteger refundSize, byte[] signatureBytes) throws VerificationException, ValueOutOfRangeException { + public synchronized boolean incrementPayment(BigInteger refundSize, byte[] signatureBytes) throws VerificationException, ValueOutOfRangeException { checkState(state == State.READY); checkNotNull(refundSize); checkNotNull(signatureBytes); @@ -296,7 +297,7 @@ public class PaymentChannelServerState { if (newValueToMe.compareTo(BigInteger.ZERO) < 0) throw new ValueOutOfRangeException("Attempt to refund more than the contract allows."); if (newValueToMe.compareTo(bestValueToMe) < 0) - return; + throw new ValueOutOfRangeException("Attempt to roll back payment on the channel."); // Get the wallet's copy of the multisigContract (ie with confidence information), if this is null, the wallet // was not connected to the peergroup when the contract was broadcast (which may cause issues down the road, and @@ -334,6 +335,7 @@ public class PaymentChannelServerState { bestValueToMe = newValueToMe; bestValueSignature = signatureBytes; updateChannelInWallet(); + return !fullyUsedUp; } // Signs the first input of the transaction which must spend the multisig contract. diff --git a/core/src/test/java/com/google/bitcoin/protocols/channels/ChannelConnectionTest.java b/core/src/test/java/com/google/bitcoin/protocols/channels/ChannelConnectionTest.java index e941b96d..e4e21d89 100644 --- a/core/src/test/java/com/google/bitcoin/protocols/channels/ChannelConnectionTest.java +++ b/core/src/test/java/com/google/bitcoin/protocols/channels/ChannelConnectionTest.java @@ -601,11 +601,16 @@ public class ChannelConnectionTest extends TestWithWallet { pair.clientRecorder.checkOpened(); assertNull(pair.serverRecorder.q.poll()); assertNull(pair.clientRecorder.q.poll()); - // Send the whole channel at once. + // Send the whole channel at once. The server will broadcast the final contract and close the channel for us. client.incrementPayment(Utils.COIN); + broadcastTxPause.release(); server.receiveMessage(pair.clientRecorder.checkNextMsg(MessageType.UPDATE_PAYMENT)); + broadcasts.take(); // The channel is now empty. assertEquals(BigInteger.ZERO, client.state().getValueRefunded()); + pair.serverRecorder.q.take(); // Take the BigInteger. + client.receiveMessage(pair.serverRecorder.checkNextMsg(MessageType.CLOSE)); + assertEquals(CloseReason.SERVER_REQUESTED_CLOSE, pair.clientRecorder.q.take()); client.connectionClosed(); // Now try opening a new channel with the same server ID and verify the client asks for a new channel. diff --git a/core/src/test/java/com/google/bitcoin/protocols/channels/PaymentChannelStateTest.java b/core/src/test/java/com/google/bitcoin/protocols/channels/PaymentChannelStateTest.java index e97b9451..e9a17a15 100644 --- a/core/src/test/java/com/google/bitcoin/protocols/channels/PaymentChannelStateTest.java +++ b/core/src/test/java/com/google/bitcoin/protocols/channels/PaymentChannelStateTest.java @@ -513,7 +513,11 @@ public class PaymentChannelStateTest extends TestWithWallet { serverState.incrementPayment(halfCoin.subtract(totalPayment), signature2); - serverState.incrementPayment(halfCoin.subtract(totalPayment.subtract(size)), signature); + // Trying to take reduce the refund size fails. + try { + serverState.incrementPayment(halfCoin.subtract(totalPayment.subtract(size)), signature); + fail(); + } catch (ValueOutOfRangeException e) {} assertEquals(serverState.getBestValueToMe(), totalPayment); try {