diff --git a/core/src/main/java/com/google/bitcoin/core/Transaction.java b/core/src/main/java/com/google/bitcoin/core/Transaction.java index 25c4ab6d..9dfcab9f 100644 --- a/core/src/main/java/com/google/bitcoin/core/Transaction.java +++ b/core/src/main/java/com/google/bitcoin/core/Transaction.java @@ -395,6 +395,18 @@ public class Transaction extends ChildMessage implements Serializable { return true; } + /** + * Returns true if any of the outputs is marked as spent. + */ + public boolean isAnyOutputSpent() { + maybeParse(); + for (TransactionOutput output : outputs) { + if (!output.isAvailableForSpending()) + return true; + } + return false; + } + /** * Returns false if this transaction has at least one output that is owned by the given wallet and unspent, true * otherwise. diff --git a/core/src/main/java/com/google/bitcoin/core/Wallet.java b/core/src/main/java/com/google/bitcoin/core/Wallet.java index 4b3c18c4..7dae5af4 100644 --- a/core/src/main/java/com/google/bitcoin/core/Wallet.java +++ b/core/src/main/java/com/google/bitcoin/core/Wallet.java @@ -1425,6 +1425,40 @@ public class Wallet implements Serializable, BlockChainListener, PeerFilterProvi } } + /** + * Clean up the wallet. Currently, it only removes risky pending transaction from the wallet and only if their + * outputs have not been spent. + */ + public void cleanup() { + lock.lock(); + try { + boolean dirty = false; + for (Iterator i = pending.values().iterator(); i.hasNext();) { + Transaction tx = i.next(); + if (isTransactionRisky(tx, null) && !acceptRiskyTransactions) { + log.debug("Found risky transaction {} in wallet during cleanup.", tx.getHashAsString()); + if (!tx.isAnyOutputSpent()) { + tx.disconnectInputs(); + i.remove(); + transactions.remove(tx.getHash()); + dirty = true; + log.info("Removed transaction {} from pending pool during cleanup.", tx.getHashAsString()); + } else { + log.info( + "Cannot remove transaction {} from pending pool during cleanup, as it's already spent partially.", + tx.getHashAsString()); + } + } + } + if (dirty) { + checkState(isConsistent()); + saveLater(); + } + } finally { + lock.unlock(); + } + } + EnumSet getContainingPools(Transaction tx) { lock.lock(); try { diff --git a/core/src/test/java/com/google/bitcoin/core/WalletTest.java b/core/src/test/java/com/google/bitcoin/core/WalletTest.java index 54337db7..dd1b13fe 100644 --- a/core/src/test/java/com/google/bitcoin/core/WalletTest.java +++ b/core/src/test/java/com/google/bitcoin/core/WalletTest.java @@ -18,6 +18,8 @@ package com.google.bitcoin.core; import com.google.bitcoin.core.Transaction.SigHash; import com.google.bitcoin.core.Wallet.SendRequest; +import com.google.bitcoin.wallet.DefaultCoinSelector; +import com.google.bitcoin.wallet.RiskAnalysis; import com.google.bitcoin.wallet.WalletTransaction; import com.google.bitcoin.wallet.WalletTransaction.Pool; import com.google.bitcoin.crypto.KeyCrypter; @@ -111,7 +113,7 @@ public class WalletTest extends TestWithWallet { public void basicSpending() throws Exception { basicSpendingCommon(wallet, myAddress, new ECKey().toAddress(params), false); } - + @Test public void basicSpendingToP2SH() throws Exception { Address destination = new Address(params, params.getP2SHHeader(), Hex.decode("4a22c3c4cbb31e4d03b15550636762bda0baf85a")); @@ -128,6 +130,116 @@ public class WalletTest extends TestWithWallet { basicSpendingCommon(encryptedMixedWallet, myEncryptedAddress2, new ECKey().toAddress(params), true); } + static class TestRiskAnalysis implements RiskAnalysis { + private final boolean risky; + + public TestRiskAnalysis(boolean risky) { + this.risky = risky; + } + + @Override + public Result analyze() { + return risky ? Result.NON_FINAL : Result.OK; + } + + public static class Analyzer implements RiskAnalysis.Analyzer { + private final Transaction riskyTx; + + Analyzer(Transaction riskyTx) { + this.riskyTx = riskyTx; + } + + @Override + public RiskAnalysis create(Wallet wallet, Transaction tx, List dependencies) { + return new TestRiskAnalysis(tx == riskyTx); + } + } + } + + static class TestCoinSelector extends DefaultCoinSelector { + @Override + protected boolean shouldSelect(Transaction tx) { + return true; + } + } + + private Transaction cleanupCommon(Address destination) throws Exception { + receiveATransaction(wallet, myAddress); + + BigInteger v2 = toNanoCoins(0, 50); + SendRequest req = SendRequest.to(destination, v2); + req.fee = toNanoCoins(0, 1); + wallet.completeTx(req); + + Transaction t2 = req.tx; + + // Broadcast the transaction and commit. + broadcastAndCommit(wallet, t2); + + // At this point we have one pending and one spent + + BigInteger v1 = toNanoCoins(0, 10); + Transaction t = sendMoneyToWallet(wallet, v1, myAddress, null); + Threading.waitForUserCode(); + sendMoneyToWallet(wallet, t, null); + assertEquals("Wrong number of PENDING.4", 2, wallet.getPoolSize(Pool.PENDING)); + assertEquals("Wrong number of UNSPENT.4", 0, wallet.getPoolSize(Pool.UNSPENT)); + assertEquals("Wrong number of ALL.4", 3, wallet.getTransactions(true).size()); + assertEquals(toNanoCoins(0, 59), wallet.getBalance(Wallet.BalanceType.ESTIMATED)); + + // Now we have another incoming pending + return t; + } + + @Test + public void cleanup() throws Exception { + Address destination = new ECKey().toAddress(params); + Transaction t = cleanupCommon(destination); + + // Consider the new pending as risky and remove it from the wallet + wallet.setRiskAnalyzer(new TestRiskAnalysis.Analyzer(t)); + + wallet.cleanup(); + assertTrue(wallet.isConsistent()); + assertEquals("Wrong number of PENDING.5", 1, wallet.getPoolSize(WalletTransaction.Pool.PENDING)); + assertEquals("Wrong number of UNSPENT.5", 0, wallet.getPoolSize(WalletTransaction.Pool.UNSPENT)); + assertEquals("Wrong number of ALL.5", 2, wallet.getTransactions(true).size()); + assertEquals(toNanoCoins(0, 49), wallet.getBalance(Wallet.BalanceType.ESTIMATED)); + } + + @Test + public void cleanupFailsDueToSpend() throws Exception { + Address destination = new ECKey().toAddress(params); + Transaction t = cleanupCommon(destination); + + // Now we have another incoming pending. Spend everything. + BigInteger v3 = toNanoCoins(0, 58); + SendRequest req = SendRequest.to(destination, v3); + + // Force selection of the incoming coin so that we can spend it + req.coinSelector = new TestCoinSelector(); + + req.fee = toNanoCoins(0, 1); + wallet.completeTx(req); + wallet.commitTx(req.tx); + + assertEquals("Wrong number of PENDING.5", 3, wallet.getPoolSize(WalletTransaction.Pool.PENDING)); + assertEquals("Wrong number of UNSPENT.5", 0, wallet.getPoolSize(WalletTransaction.Pool.UNSPENT)); + assertEquals("Wrong number of ALL.5", 4, wallet.getTransactions(true).size()); + + // Consider the new pending as risky and try to remove it from the wallet + wallet.setRiskAnalyzer(new TestRiskAnalysis.Analyzer(t)); + + wallet.cleanup(); + assertTrue(wallet.isConsistent()); + + // The removal should have failed + assertEquals("Wrong number of PENDING.5", 3, wallet.getPoolSize(WalletTransaction.Pool.PENDING)); + assertEquals("Wrong number of UNSPENT.5", 0, wallet.getPoolSize(WalletTransaction.Pool.UNSPENT)); + assertEquals("Wrong number of ALL.5", 4, wallet.getTransactions(true).size()); + assertEquals(toNanoCoins(0, 0), wallet.getBalance(Wallet.BalanceType.ESTIMATED)); + } + private void basicSpendingCommon(Wallet wallet, Address toAddress, Address destination, boolean testEncryption) throws Exception { // We'll set up a wallet that receives a coin, then sends a coin of lesser value and keeps the change. We // will attach a small fee. Because the Bitcoin protocol makes it difficult to determine the fee of an