diff --git a/src/com/google/bitcoin/core/Transaction.java b/src/com/google/bitcoin/core/Transaction.java index cf723658..d501b1f4 100644 --- a/src/com/google/bitcoin/core/Transaction.java +++ b/src/com/google/bitcoin/core/Transaction.java @@ -56,6 +56,13 @@ public class Transaction extends Message implements Serializable { // If this transaction is not stored in the wallet, appearsIn is null. Set appearsIn; + // Stored only in Java serialization. This is either the time the transaction was broadcast as measured from the + // local clock, or the time from the block in which it was included. Note that this can be changed by re-orgs so + // the wallet may update this field. Old serialized transactions don't have this field, thus null is valid. + // It is used for returning an ordered list of transactions from a wallet, which is helpful for presenting to + // users. + Date updatedAt; + // This is an in memory helper only. transient Sha256Hash hash; @@ -119,9 +126,7 @@ public class Transaction extends Message implements Serializable { return v; } - /** - * Calculates the sum of the outputs that are sending coins to a key in the wallet. - */ + /** Calculates the sum of the outputs that are sending coins to a key in the wallet. */ public BigInteger getValueSentToMe(Wallet wallet) { return getValueSentToMe(wallet, true); } @@ -138,8 +143,14 @@ public class Transaction extends Message implements Serializable { * Adds the given block to the internal serializable set of blocks in which this transaction appears. This is * used by the wallet to ensure transactions that appear on side chains are recorded properly even though the * block stores do not save the transaction data at all. + * + * @param block The {@link StoredBlock} in which the transaction has appeared. + * @param bestChain whether to set the updatedAt timestamp from the block header (only if not already set) */ - void addBlockAppearance(StoredBlock block) { + void addBlockAppearance(StoredBlock block, boolean bestChain) { + if (bestChain && updatedAt == null) { + updatedAt = new Date(block.getHeader().getTimeSeconds()); + } if (appearsIn == null) { appearsIn = new HashSet(); } @@ -215,6 +226,32 @@ public class Transaction extends Message implements Serializable { return true; } + /** + * Returns the earliest time at which the transaction was seen (broadcast or included into the chain), + * or null if that information isn't available. + */ + public Date getUpdateTime() { + if (updatedAt == null) { + // Older wallets did not store this field. If we can, fill it out based on the block pointers. We might + // "guess wrong" in the case of transactions appearing on chain forks, but this is unlikely to matter in + // practice. Note, some patched copies of BitCoinJ store dates in this field that do not correspond to any + // block but rather broadcast time. + if (appearsIn == null || appearsIn.size() == 0) { + // Transaction came from somewhere that doesn't provide time info. + return null; + } + long earliestTimeSecs = Long.MAX_VALUE; + // We might return a time that is different to the best chain, as we don't know here which block is part + // of the active chain and which are simply inactive. We just ignore this for now. + // TODO: At some point we'll want to store storing full block headers in the wallet. Remove at that time. + for (StoredBlock b : appearsIn) { + earliestTimeSecs = Math.min(b.getHeader().getTimeSeconds(), earliestTimeSecs); + } + updatedAt = new Date(earliestTimeSecs); + } + return updatedAt; + } + /** * These constants are a part of a scriptSig signature on the inputs. They define the details of how a * transaction can be redeemed, specifically, they control how the hash of the transaction is calculated. diff --git a/src/com/google/bitcoin/core/Wallet.java b/src/com/google/bitcoin/core/Wallet.java index ecc505b0..2c87deb6 100644 --- a/src/com/google/bitcoin/core/Wallet.java +++ b/src/com/google/bitcoin/core/Wallet.java @@ -245,7 +245,7 @@ public class Wallet implements Serializable { // accepted by the network. // // Mark the tx as appearing in this block so we can find it later after a re-org. - wtx.addBlockAppearance(block); + wtx.addBlockAppearance(block, bestChain); if (bestChain) { if (valueSentToMe.equals(BigInteger.ZERO)) { // There were no change transactions so this tx is fully spent. @@ -277,7 +277,7 @@ public class Wallet implements Serializable { } else { if (!reorg) { // Mark the tx as appearing in this block so we can find it later after a re-org. - tx.addBlockAppearance(block); + tx.addBlockAppearance(block, bestChain); } // This TX didn't originate with us. It could be sending us coins and also spending our own coins if keys // are being shared between different wallets. @@ -433,6 +433,44 @@ public class Wallet implements Serializable { pending.put(tx.getHash(), tx); } + /** Returns all transactions in the wallet ordered by recency. See {@link Wallet#getRecentTransactions(int)}. */ + public List getTransactionsByTime() { + return getRecentTransactions(0); + } + + /** + * Returns an list of N transactions, ordered by increasing age. Transactions which exist only on + * inactive side-chains are not included or which are dead (overridden by double spends) are not included.

+ * + * Note: the current implementation is O(num transactions in wallet). Regardless of how many transactions are + * requested, the cost is always the same. In future, requesting smaller numbers of transactions may be faster + * depending on how the wallet is implemented (eg if backed by a database). + */ + public List getRecentTransactions(int numTransactions) { + assert numTransactions >= 0; + // Firstly, put all transactions into an array. + int size = getPoolSize(Pool.UNSPENT) + getPoolSize(Pool.SPENT) + getPoolSize(Pool.PENDING); + if (numTransactions > size || numTransactions == 0) { + numTransactions = size; + } + ArrayList all = new ArrayList(size); + all.addAll(unspent.values()); + all.addAll(spent.values()); + all.addAll(pending.values()); + // Order by date. + Collections.sort(all, Collections.reverseOrder(new Comparator() { + public int compare(Transaction t1, Transaction t2) { + return t1.getUpdateTime().compareTo(t2.getUpdateTime()); + } + })); + if (numTransactions == all.size()) { + return all; + } else { + all.subList(numTransactions, all.size()).clear(); + return all; + } + } + // This is used only for unit testing, it's an internal API. enum Pool { UNSPENT, diff --git a/tests/com/google/bitcoin/core/TestUtils.java b/tests/com/google/bitcoin/core/TestUtils.java index 523cf905..018b4de9 100644 --- a/tests/com/google/bitcoin/core/TestUtils.java +++ b/tests/com/google/bitcoin/core/TestUtils.java @@ -42,10 +42,10 @@ public class TestUtils { } // Emulates receiving a valid block that builds on top of the chain. - public static BlockPair createFakeBlock(NetworkParameters params, BlockStore blockStore, + public static BlockPair createFakeBlock(NetworkParameters params, BlockStore blockStore, long timeSeconds, Transaction... transactions) { try { - Block b = makeTestBlock(params, blockStore); + Block b = blockStore.getChainHead().getHeader().createNextBlock(new ECKey().toAddress(params), timeSeconds); // Coinbase tx was already added. for (Transaction tx : transactions) b.addTransaction(tx); @@ -63,20 +63,20 @@ public class TestUtils { } } - public static Block makeTestBlock(NetworkParameters params, - BlockStore blockStore) throws BlockStoreException { - return blockStore.getChainHead().getHeader().createNextBlock(new ECKey().toAddress(params)); + public static BlockPair createFakeBlock(NetworkParameters params, BlockStore blockStore, + Transaction... transactions) { + return createFakeBlock(params, blockStore, System.currentTimeMillis() / 1000, transactions); } public static Block makeSolvedTestBlock(NetworkParameters params, - BlockStore blockStore) throws BlockStoreException { + BlockStore blockStore) throws BlockStoreException { Block b = blockStore.getChainHead().getHeader().createNextBlock(new ECKey().toAddress(params)); b.solve(); return b; } public static Block makeSolvedTestBlock(NetworkParameters params, - Block prev) throws BlockStoreException { + Block prev) throws BlockStoreException { Block b = prev.createNextBlock(new ECKey().toAddress(params)); b.solve(); return b; diff --git a/tests/com/google/bitcoin/core/WalletTest.java b/tests/com/google/bitcoin/core/WalletTest.java index 4101f595..06d578e0 100644 --- a/tests/com/google/bitcoin/core/WalletTest.java +++ b/tests/com/google/bitcoin/core/WalletTest.java @@ -17,12 +17,12 @@ package com.google.bitcoin.core; import com.google.bitcoin.store.BlockStore; -import com.google.bitcoin.store.BlockStoreException; import com.google.bitcoin.store.MemoryBlockStore; import org.junit.Before; import org.junit.Test; import java.math.BigInteger; +import java.util.List; import static com.google.bitcoin.core.TestUtils.createFakeBlock; import static com.google.bitcoin.core.TestUtils.createFakeTx; @@ -272,4 +272,38 @@ public class WalletTest { assertEquals(send1, eventDead[0]); assertEquals(send2, eventReplacement[0]); } + + @Test + public void transactionsList() throws Exception { + // Check the wallet can give us an ordered list of all received transactions. + long time = System.currentTimeMillis() / 1000; + // Receive a coin. + Transaction tx1 = createFakeTx(params, Utils.toNanoCoins(1, 0), myAddress); + StoredBlock b1 = createFakeBlock(params, blockStore, time, tx1).storedBlock; + wallet.receive(tx1, b1, BlockChain.NewBlockType.BEST_CHAIN); + // Receive half a coin 10 minutes later. + time += 60 * 10; + Transaction tx2 = createFakeTx(params, Utils.toNanoCoins(0, 5), myAddress); + StoredBlock b2 = createFakeBlock(params, blockStore, time, tx1).storedBlock; + wallet.receive(tx2, b2, BlockChain.NewBlockType.BEST_CHAIN); + // Check we got them back in order. + List transactions = wallet.getTransactionsByTime(); + assertEquals(tx2, transactions.get(0)); + assertEquals(tx1, transactions.get(1)); + assertEquals(2, transactions.size()); + // Check we get only the last transaction if we request a subrage. + transactions = wallet.getRecentTransactions(1); + assertEquals(1, transactions.size()); + assertEquals(tx2, transactions.get(0)); + + // Verify we can handle the case of older wallets in which the timestamp is null (guessed from the + // block appearances list). + tx1.updatedAt = null; + tx2.updatedAt = null; + // Check we got them back in order. + transactions = wallet.getTransactionsByTime(); + assertEquals(tx2, transactions.get(0)); + assertEquals(tx1, transactions.get(1)); + assertEquals(2, transactions.size()); + } }