From 4b4405b7bc67c84533e6e2f473b1db6b000949a9 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 2 Jul 2013 22:23:57 +0200 Subject: [PATCH] Check for double-spend of contract by force-adding it to wallet --- .../channels/PaymentChannelServerState.java | 25 +++++- .../channels/PaymentChannelStateTest.java | 85 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) 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 f672cf76..465ec804 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 @@ -18,6 +18,7 @@ package com.google.bitcoin.protocols.channels; import java.math.BigInteger; import java.util.Arrays; +import java.util.Collections; import javax.annotation.Nullable; import com.google.bitcoin.core.*; @@ -213,7 +214,7 @@ public class PaymentChannelServerState { * Note that if the network simply rejects the transaction, this future will never complete, a timeout should be used. * @throws VerificationException If the provided multisig contract is not well-formed or does not meet previously-specified parameters */ - public synchronized ListenableFuture provideMultiSigContract(Transaction multisigContract) throws VerificationException { + public synchronized ListenableFuture provideMultiSigContract(final Transaction multisigContract) throws VerificationException { checkNotNull(multisigContract); checkState(state == State.WAITING_FOR_MULTISIG_CONTRACT); try { @@ -240,6 +241,13 @@ public class PaymentChannelServerState { Futures.addCallback(broadcaster.broadcastTransaction(multisigContract), new FutureCallback() { @Override public void onSuccess(Transaction transaction) { log.info("Successfully broadcast multisig contract {}. Channel now open.", transaction.getHashAsString()); + try { + // Manually add the multisigContract to the wallet, overriding the isRelevant checks so we can track + // it and check for double-spends later + wallet.receivePending(multisigContract, Collections.EMPTY_LIST, true); + } catch (VerificationException e) { + throw new RuntimeException(e); // Cannot happen, we already called multisigContract.verify() + } state = State.READY; future.set(PaymentChannelServerState.this); } @@ -291,6 +299,21 @@ public class PaymentChannelServerState { if (newValueToMe.compareTo(bestValueToMe) < 0) return; + // 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 + // disables our double-spend check next) + Transaction walletContract = wallet.getTransaction(multisigContract.getHash()); + checkState(walletContract != null, "Wallet did not contain multisig contract after state was marked READY"); + + // Note that we check for DEAD state here, but this test is essentially useless in production because we will + // miss most double-spends due to bloom filtering right now anyway. This will eventually fixed by network-wide + // double-spend notifications, so we just wait instead of attempting to add all dependant outpoints to our bloom + // filters (and probably missing lots of edge-cases). + if (walletContract.getConfidence().getConfidenceType() == TransactionConfidence.ConfidenceType.DEAD) { + close(); + throw new VerificationException("Multisig contract was double-spent"); + } + Transaction.SigHash mode; // If the client doesn't want anything back, they shouldn't sign any outputs at all. if (fullyUsedUp) 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 ec4d6ee5..f98679da 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 @@ -27,6 +27,7 @@ import org.junit.Test; import java.math.BigInteger; import java.util.Arrays; +import java.util.Collections; import java.util.Iterator; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutionException; @@ -703,4 +704,88 @@ public class PaymentChannelStateTest extends TestWithWallet { pair.future.set(pair.tx); assertEquals(PaymentChannelServerState.State.CLOSED, serverState.getState()); } + + @Test + public void doubleSpendContractTest() throws Exception { + // Tests that if the client double-spends the multisig contract after it is sent, no more payments are accepted + + // Start with a copy of basic().... + Utils.rollMockClock(0); // Use mock clock + final long EXPIRE_TIME = Utils.now().getTime()/1000 + 60*60*24; + + serverState = new PaymentChannelServerState(mockBroadcaster, serverWallet, serverKey, EXPIRE_TIME); + assertEquals(PaymentChannelServerState.State.WAITING_FOR_REFUND_TRANSACTION, serverState.getState()); + + clientState = new PaymentChannelClientState(wallet, myKey, new ECKey(null, serverKey.getPubKey()), halfCoin, EXPIRE_TIME); + assertEquals(PaymentChannelClientState.State.NEW, clientState.getState()); + clientState.initiate(); + assertEquals(PaymentChannelClientState.State.INITIATED, clientState.getState()); + + // Send the refund tx from client to server and get back the signature. + Transaction refund = new Transaction(params, clientState.getIncompleteRefundTransaction().bitcoinSerialize()); + byte[] refundSig = serverState.provideRefundTransaction(refund, myKey.getPubKey()); + assertEquals(PaymentChannelServerState.State.WAITING_FOR_MULTISIG_CONTRACT, serverState.getState()); + // This verifies that the refund can spend the multi-sig output when run. + clientState.provideRefundSignature(refundSig); + assertEquals(PaymentChannelClientState.State.PROVIDE_MULTISIG_CONTRACT_TO_SERVER, clientState.getState()); + + // Validate the multisig contract looks right. + Transaction multisigContract = new Transaction(params, clientState.getMultisigContract().bitcoinSerialize()); + assertEquals(PaymentChannelClientState.State.READY, clientState.getState()); + assertEquals(2, multisigContract.getOutputs().size()); // One multi-sig, one change. + Script script = multisigContract.getOutput(0).getScriptPubKey(); + assertTrue(script.isSentToMultiSig()); + script = multisigContract.getOutput(1).getScriptPubKey(); + assertTrue(script.isSentToAddress()); + assertTrue(wallet.getPendingTransactions().contains(multisigContract)); + + // Provide the server with the multisig contract and simulate successful propagation/acceptance. + serverState.provideMultiSigContract(multisigContract); + assertEquals(PaymentChannelServerState.State.WAITING_FOR_MULTISIG_ACCEPTANCE, serverState.getState()); + final TxFuturePair pair = broadcasts.take(); + pair.future.set(pair.tx); + assertEquals(PaymentChannelServerState.State.READY, serverState.getState()); + + // Make sure the refund transaction is not in the wallet and multisig contract's output is not connected to it + assertEquals(2, wallet.getTransactions(false).size()); + Iterator walletTransactionIterator = wallet.getTransactions(false).iterator(); + Transaction clientWalletMultisigContract = walletTransactionIterator.next(); + assertFalse(clientWalletMultisigContract.getHash().equals(clientState.getCompletedRefundTransaction().getHash())); + if (!clientWalletMultisigContract.getHash().equals(multisigContract.getHash())) { + clientWalletMultisigContract = walletTransactionIterator.next(); + assertFalse(clientWalletMultisigContract.getHash().equals(clientState.getCompletedRefundTransaction().getHash())); + } else + assertFalse(walletTransactionIterator.next().getHash().equals(clientState.getCompletedRefundTransaction().getHash())); + assertEquals(multisigContract.getHash(), clientWalletMultisigContract.getHash()); + assertFalse(clientWalletMultisigContract.getInput(0).getConnectedOutput().getSpentBy().getParentTransaction().getHash().equals(refund.getHash())); + + // Both client and server are now in the ready state. Simulate a few micropayments of 0.005 bitcoins. + BigInteger size = halfCoin.divide(BigInteger.TEN).divide(BigInteger.TEN); + BigInteger totalPayment = BigInteger.ZERO; + for (int i = 0; i < 5; i++) { + byte[] signature = clientState.incrementPaymentBy(size); + totalPayment = totalPayment.add(size); + serverState.incrementPayment(halfCoin.subtract(totalPayment), signature); + } + + // Now create a double-spend and send it to the server + Transaction doubleSpendContract = new Transaction(params); + doubleSpendContract.addInput(new TransactionInput(params, doubleSpendContract, new byte[0], + multisigContract.getInput(0).getOutpoint())); + doubleSpendContract.addOutput(halfCoin, myKey); + doubleSpendContract = new Transaction(params, doubleSpendContract.bitcoinSerialize()); + + StoredBlock block = new StoredBlock(params.getGenesisBlock().createNextBlock(myKey.toAddress(params)), BigInteger.TEN, 1); + serverWallet.receiveFromBlock(doubleSpendContract, block, AbstractBlockChain.NewBlockType.BEST_CHAIN); + + // Now if we try to spend again the server will reject it since it saw a double-spend + try { + byte[] signature = clientState.incrementPaymentBy(size); + totalPayment = totalPayment.add(size); + serverState.incrementPayment(halfCoin.subtract(totalPayment), signature); + fail(); + } catch (VerificationException e) { + assertTrue(e.getMessage().contains("double-spent")); + } + } }