Skip to content

Commit 6ef3c7e

Browse files
committed
Merge #9622: [rpc] listsinceblock should include lost transactions when parameter is a reorg'd block
876e92b Testing: listsinceblock should display all transactions that were affected since the given block, including transactions that were removed due to a reorg. (Karl-Johan Alm) f999c46 listsinceblock: optionally find and list any transactions that were undone due to reorg when requesting a non-main chain block in a new 'removed' array. (Karl-Johan Alm) Pull request description: The following scenario will not notify the caller of the fact `tx0` has been dropped: 1. User 1 receives BTC in tx0 from utxo1 in block aa1. 2. User 2 receives BTC in tx1 from utxo1 (same) in block bb1 3. User 1 sees 2 confirmations at block aa3. 4. Reorg into bb chain. 5. User 1 asks `listsinceblock aa3` and does not see that tx0 is now invalidated. See `listsinceblock.py` commit for related test. The proposed fix is to iterate from the given block down to the fork point, and to check each transaction in the blocks against the wallet, in addition to including all transactions from the fork point to the active chain tip (the current behavior). Any transactions that were present will now also be listed in the `listsinceblock` output in a new `replaced` array. This operation may be a bit heavy but the circumstances (and perceived frequency of occurrence) warrant it, I believe. Example output: ```Python { 'transactions': [], 'replaced': [ { 'walletconflicts': [], 'vout': 1, 'account': '', 'timereceived': 1485234857, 'time': 1485234857, 'amount': '1.00000000', 'bip125-replaceable': 'unknown', 'trusted': False, 'category': 'receive', 'txid': 'ce673859a30dee1d2ebdb3c05f2eea7b1da54baf68f93bb8bfe37c5f09ed22ff', 'address': 'miqEt4kWp9zSizwGGuUWLAmxEcTW9bFUnQ', 'label': '', 'confirmations': -7 } ], 'lastblock': '7a388f27d09e3699102a4ebf81597d974fc4c72093eeaa02adffbbf7527f6715' } ``` I believe this addresses the comment by @luke-jr in bitcoin/bitcoin#9516 (comment) but I could be wrong.. Tree-SHA512: 607b5dcaeccb9dc0d963d3de138c40490f3e923050b29821e6bd513d26beb587bddc748fbb194503fe618cfe34a6ed65d95e8d9c5764a882b6c5f976520cff35
2 parents 0c173a1 + 876e92b commit 6ef3c7e

File tree

3 files changed

+240
-31
lines changed

3 files changed

+240
-31
lines changed

src/rpc/client.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
6868
{ "getblocktemplate", 0, "template_request" },
6969
{ "listsinceblock", 1, "target_confirmations" },
7070
{ "listsinceblock", 2, "include_watchonly" },
71+
{ "listsinceblock", 3, "include_removed" },
7172
{ "sendmany", 1, "amounts" },
7273
{ "sendmany", 2, "minconf" },
7374
{ "sendmany", 4, "subtractfeefrom" },

src/wallet/rpcwallet.cpp

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1426,6 +1426,17 @@ static void MaybePushAddress(UniValue & entry, const CTxDestination &dest)
14261426
entry.push_back(Pair("address", addr.ToString()));
14271427
}
14281428

1429+
/**
1430+
* List transactions based on the given criteria.
1431+
*
1432+
* @param pwallet The wallet.
1433+
* @param wtx The wallet transaction.
1434+
* @param strAccount The account, if any, or "*" for all.
1435+
* @param nMinDepth The minimum confirmation depth.
1436+
* @param fLong Whether to include the JSON version of the transaction.
1437+
* @param ret The UniValue into which the result is stored.
1438+
* @param filter The "is mine" filter bool.
1439+
*/
14291440
void ListTransactions(CWallet* const pwallet, const CWalletTx& wtx, const std::string& strAccount, int nMinDepth, bool fLong, UniValue& ret, const isminefilter& filter)
14301441
{
14311442
CAmount nFee;
@@ -1742,14 +1753,18 @@ UniValue listsinceblock(const JSONRPCRequest& request)
17421753
return NullUniValue;
17431754
}
17441755

1745-
if (request.fHelp || request.params.size() > 3)
1756+
if (request.fHelp || request.params.size() > 4)
17461757
throw std::runtime_error(
1747-
"listsinceblock ( \"blockhash\" target_confirmations include_watchonly)\n"
1748-
"\nGet all transactions in blocks since block [blockhash], or all transactions if omitted\n"
1758+
"listsinceblock ( \"blockhash\" target_confirmations include_watchonly include_removed )\n"
1759+
"\nGet all transactions in blocks since block [blockhash], or all transactions if omitted.\n"
1760+
"If \"blockhash\" is no longer a part of the main chain, transactions from the fork point onward are included.\n"
1761+
"Additionally, if include_removed is set, transactions affecting the wallet which were removed are returned in the \"removed\" array.\n"
17491762
"\nArguments:\n"
17501763
"1. \"blockhash\" (string, optional) The block hash to list transactions since\n"
1751-
"2. target_confirmations: (numeric, optional) The confirmations required, must be 1 or more\n"
1752-
"3. include_watchonly: (bool, optional, default=false) Include transactions to watch-only addresses (see 'importaddress')"
1764+
"2. target_confirmations: (numeric, optional, default=1) The confirmations required, must be 1 or more\n"
1765+
"3. include_watchonly: (bool, optional, default=false) Include transactions to watch-only addresses (see 'importaddress')\n"
1766+
"4. include_removed: (bool, optional, default=true) Show transactions that were removed due to a reorg in the \"removed\" array\n"
1767+
" (not guaranteed to work on pruned nodes)\n"
17531768
"\nResult:\n"
17541769
"{\n"
17551770
" \"transactions\": [\n"
@@ -1774,7 +1789,11 @@ UniValue listsinceblock(const JSONRPCRequest& request)
17741789
" \"comment\": \"...\", (string) If a comment is associated with the transaction.\n"
17751790
" \"label\" : \"label\" (string) A comment for the address/transaction, if any\n"
17761791
" \"to\": \"...\", (string) If a comment to is associated with the transaction.\n"
1777-
" ],\n"
1792+
" ],\n"
1793+
" \"removed\": [\n"
1794+
" <structure is the same as \"transactions\" above, only present if include_removed=true>\n"
1795+
" Note: transactions that were readded in the active chain will appear as-is in this array, and may thus have a positive confirmation count.\n"
1796+
" ],\n"
17781797
" \"lastblock\": \"lastblockhash\" (string) The hash of the last block\n"
17791798
"}\n"
17801799
"\nExamples:\n"
@@ -1785,21 +1804,19 @@ UniValue listsinceblock(const JSONRPCRequest& request)
17851804

17861805
LOCK2(cs_main, pwallet->cs_wallet);
17871806

1788-
const CBlockIndex *pindex = NULL;
1807+
const CBlockIndex* pindex = NULL; // Block index of the specified block or the common ancestor, if the block provided was in a deactivated chain.
1808+
const CBlockIndex* paltindex = NULL; // Block index of the specified block, even if it's in a deactivated chain.
17891809
int target_confirms = 1;
17901810
isminefilter filter = ISMINE_SPENDABLE;
17911811

1792-
if (!request.params[0].isNull())
1793-
{
1812+
if (!request.params[0].isNull()) {
17941813
uint256 blockId;
17951814

17961815
blockId.SetHex(request.params[0].get_str());
17971816
BlockMap::iterator it = mapBlockIndex.find(blockId);
1798-
if (it != mapBlockIndex.end())
1799-
{
1800-
pindex = it->second;
1801-
if (chainActive[pindex->nHeight] != pindex)
1802-
{
1817+
if (it != mapBlockIndex.end()) {
1818+
paltindex = pindex = it->second;
1819+
if (chainActive[pindex->nHeight] != pindex) {
18031820
// the block being asked for is a part of a deactivated chain;
18041821
// we don't want to depend on its perceived height in the block
18051822
// chain, we want to instead use the last common ancestor
@@ -1808,35 +1825,56 @@ UniValue listsinceblock(const JSONRPCRequest& request)
18081825
}
18091826
}
18101827

1811-
if (!request.params[1].isNull())
1812-
{
1828+
if (!request.params[1].isNull()) {
18131829
target_confirms = request.params[1].get_int();
18141830

1815-
if (target_confirms < 1)
1831+
if (target_confirms < 1) {
18161832
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter");
1833+
}
18171834
}
18181835

1819-
if (request.params.size() > 2 && request.params[2].get_bool())
1820-
{
1836+
if (!request.params[2].isNull() && request.params[2].get_bool()) {
18211837
filter = filter | ISMINE_WATCH_ONLY;
18221838
}
18231839

1840+
bool include_removed = (request.params[3].isNull() || request.params[3].get_bool());
1841+
18241842
int depth = pindex ? (1 + chainActive.Height() - pindex->nHeight) : -1;
18251843

18261844
UniValue transactions(UniValue::VARR);
18271845

18281846
for (const std::pair<uint256, CWalletTx>& pairWtx : pwallet->mapWallet) {
18291847
CWalletTx tx = pairWtx.second;
18301848

1831-
if (depth == -1 || tx.GetDepthInMainChain() < depth)
1849+
if (depth == -1 || tx.GetDepthInMainChain() < depth) {
18321850
ListTransactions(pwallet, tx, "*", 0, true, transactions, filter);
1851+
}
1852+
}
1853+
1854+
// when a reorg'd block is requested, we also list any relevant transactions
1855+
// in the blocks of the chain that was detached
1856+
UniValue removed(UniValue::VARR);
1857+
while (include_removed && paltindex && paltindex != pindex) {
1858+
CBlock block;
1859+
if (!ReadBlockFromDisk(block, paltindex, Params().GetConsensus())) {
1860+
throw JSONRPCError(RPC_INTERNAL_ERROR, "Can't read block from disk");
1861+
}
1862+
for (const CTransactionRef& tx : block.vtx) {
1863+
if (pwallet->mapWallet.count(tx->GetHash()) > 0) {
1864+
// We want all transactions regardless of confirmation count to appear here,
1865+
// even negative confirmation ones, hence the big negative.
1866+
ListTransactions(pwallet, pwallet->mapWallet[tx->GetHash()], "*", -100000000, true, removed, filter);
1867+
}
1868+
}
1869+
paltindex = paltindex->pprev;
18331870
}
18341871

18351872
CBlockIndex *pblockLast = chainActive[chainActive.Height() + 1 - target_confirms];
18361873
uint256 lastblock = pblockLast ? pblockLast->GetBlockHash() : uint256();
18371874

18381875
UniValue ret(UniValue::VOBJ);
18391876
ret.push_back(Pair("transactions", transactions));
1877+
if (include_removed) ret.push_back(Pair("removed", removed));
18401878
ret.push_back(Pair("lastblock", lastblock.GetHex()));
18411879

18421880
return ret;
@@ -3117,7 +3155,7 @@ static const CRPCCommand commands[] =
31173155
{ "wallet", "listlockunspent", &listlockunspent, false, {} },
31183156
{ "wallet", "listreceivedbyaccount", &listreceivedbyaccount, false, {"minconf","include_empty","include_watchonly"} },
31193157
{ "wallet", "listreceivedbyaddress", &listreceivedbyaddress, false, {"minconf","include_empty","include_watchonly"} },
3120-
{ "wallet", "listsinceblock", &listsinceblock, false, {"blockhash","target_confirmations","include_watchonly"} },
3158+
{ "wallet", "listsinceblock", &listsinceblock, false, {"blockhash","target_confirmations","include_watchonly","include_removed"} },
31213159
{ "wallet", "listtransactions", &listtransactions, false, {"account","count","skip","include_watchonly"} },
31223160
{ "wallet", "listunspent", &listunspent, false, {"minconf","maxconf","addresses","include_unsafe","query_options"} },
31233161
{ "wallet", "listwallets", &listwallets, true, {} },

test/functional/listsinceblock.py

Lines changed: 180 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@ def __init__(self):
1414
self.setup_clean_chain = True
1515
self.num_nodes = 4
1616

17-
def run_test (self):
17+
def run_test(self):
18+
self.nodes[2].generate(101)
19+
self.sync_all()
20+
21+
self.test_reorg()
22+
self.test_double_spend()
23+
self.test_double_send()
24+
25+
def test_reorg(self):
1826
'''
1927
`listsinceblock` did not behave correctly when handed a block that was
2028
no longer in the main chain:
@@ -43,14 +51,6 @@ def run_test (self):
4351
This test only checks that [tx0] is present.
4452
'''
4553

46-
self.nodes[2].generate(101)
47-
self.sync_all()
48-
49-
assert_equal(self.nodes[0].getbalance(), 0)
50-
assert_equal(self.nodes[1].getbalance(), 0)
51-
assert_equal(self.nodes[2].getbalance(), 50)
52-
assert_equal(self.nodes[3].getbalance(), 0)
53-
5454
# Split network into two
5555
self.split_network()
5656

@@ -73,7 +73,177 @@ def run_test (self):
7373
if tx['txid'] == senttx:
7474
found = True
7575
break
76-
assert_equal(found, True)
76+
assert found
77+
78+
def test_double_spend(self):
79+
'''
80+
This tests the case where the same UTXO is spent twice on two separate
81+
blocks as part of a reorg.
82+
83+
ab0
84+
/ \
85+
aa1 [tx1] bb1 [tx2]
86+
| |
87+
aa2 bb2
88+
| |
89+
aa3 bb3
90+
|
91+
bb4
92+
93+
Problematic case:
94+
95+
1. User 1 receives BTC in tx1 from utxo1 in block aa1.
96+
2. User 2 receives BTC in tx2 from utxo1 (same) in block bb1
97+
3. User 1 sees 2 confirmations at block aa3.
98+
4. Reorg into bb chain.
99+
5. User 1 asks `listsinceblock aa3` and does not see that tx1 is now
100+
invalidated.
101+
102+
Currently the solution to this is to detect that a reorg'd block is
103+
asked for in listsinceblock, and to iterate back over existing blocks up
104+
until the fork point, and to include all transactions that relate to the
105+
node wallet.
106+
'''
107+
108+
self.sync_all()
109+
110+
# Split network into two
111+
self.split_network()
112+
113+
# share utxo between nodes[1] and nodes[2]
114+
utxos = self.nodes[2].listunspent()
115+
utxo = utxos[0]
116+
privkey = self.nodes[2].dumpprivkey(utxo['address'])
117+
self.nodes[1].importprivkey(privkey)
118+
119+
# send from nodes[1] using utxo to nodes[0]
120+
change = '%.8f' % (float(utxo['amount']) - 1.0003)
121+
recipientDict = {
122+
self.nodes[0].getnewaddress(): 1,
123+
self.nodes[1].getnewaddress(): change,
124+
}
125+
utxoDicts = [{
126+
'txid': utxo['txid'],
127+
'vout': utxo['vout'],
128+
}]
129+
txid1 = self.nodes[1].sendrawtransaction(
130+
self.nodes[1].signrawtransaction(
131+
self.nodes[1].createrawtransaction(utxoDicts, recipientDict))['hex'])
132+
133+
# send from nodes[2] using utxo to nodes[3]
134+
recipientDict2 = {
135+
self.nodes[3].getnewaddress(): 1,
136+
self.nodes[2].getnewaddress(): change,
137+
}
138+
self.nodes[2].sendrawtransaction(
139+
self.nodes[2].signrawtransaction(
140+
self.nodes[2].createrawtransaction(utxoDicts, recipientDict2))['hex'])
141+
142+
# generate on both sides
143+
lastblockhash = self.nodes[1].generate(3)[2]
144+
self.nodes[2].generate(4)
145+
146+
self.join_network()
147+
148+
self.sync_all()
149+
150+
# gettransaction should work for txid1
151+
assert self.nodes[0].gettransaction(txid1)['txid'] == txid1, "gettransaction failed to find txid1"
152+
153+
# listsinceblock(lastblockhash) should now include txid1, as seen from nodes[0]
154+
lsbres = self.nodes[0].listsinceblock(lastblockhash)
155+
assert any(tx['txid'] == txid1 for tx in lsbres['removed'])
156+
157+
# but it should not include 'removed' if include_removed=false
158+
lsbres2 = self.nodes[0].listsinceblock(blockhash=lastblockhash, include_removed=False)
159+
assert 'removed' not in lsbres2
160+
161+
def test_double_send(self):
162+
'''
163+
This tests the case where the same transaction is submitted twice on two
164+
separate blocks as part of a reorg. The former will vanish and the
165+
latter will appear as the true transaction (with confirmations dropping
166+
as a result).
167+
168+
ab0
169+
/ \
170+
aa1 [tx1] bb1
171+
| |
172+
aa2 bb2
173+
| |
174+
aa3 bb3 [tx1]
175+
|
176+
bb4
177+
178+
Asserted:
179+
180+
1. tx1 is listed in listsinceblock.
181+
2. It is included in 'removed' as it was removed, even though it is now
182+
present in a different block.
183+
3. It is listed with a confirmations count of 2 (bb3, bb4), not
184+
3 (aa1, aa2, aa3).
185+
'''
186+
187+
self.sync_all()
188+
189+
# Split network into two
190+
self.split_network()
191+
192+
# create and sign a transaction
193+
utxos = self.nodes[2].listunspent()
194+
utxo = utxos[0]
195+
change = '%.8f' % (float(utxo['amount']) - 1.0003)
196+
recipientDict = {
197+
self.nodes[0].getnewaddress(): 1,
198+
self.nodes[2].getnewaddress(): change,
199+
}
200+
utxoDicts = [{
201+
'txid': utxo['txid'],
202+
'vout': utxo['vout'],
203+
}]
204+
signedtxres = self.nodes[2].signrawtransaction(
205+
self.nodes[2].createrawtransaction(utxoDicts, recipientDict))
206+
assert signedtxres['complete']
207+
208+
signedtx = signedtxres['hex']
209+
210+
# send from nodes[1]; this will end up in aa1
211+
txid1 = self.nodes[1].sendrawtransaction(signedtx)
212+
213+
# generate bb1-bb2 on right side
214+
self.nodes[2].generate(2)
215+
216+
# send from nodes[2]; this will end up in bb3
217+
txid2 = self.nodes[2].sendrawtransaction(signedtx)
218+
219+
assert_equal(txid1, txid2)
220+
221+
# generate on both sides
222+
lastblockhash = self.nodes[1].generate(3)[2]
223+
self.nodes[2].generate(2)
224+
225+
self.join_network()
226+
227+
self.sync_all()
228+
229+
# gettransaction should work for txid1
230+
self.nodes[0].gettransaction(txid1)
231+
232+
# listsinceblock(lastblockhash) should now include txid1 in transactions
233+
# as well as in removed
234+
lsbres = self.nodes[0].listsinceblock(lastblockhash)
235+
assert any(tx['txid'] == txid1 for tx in lsbres['transactions'])
236+
assert any(tx['txid'] == txid1 for tx in lsbres['removed'])
237+
238+
# find transaction and ensure confirmations is valid
239+
for tx in lsbres['transactions']:
240+
if tx['txid'] == txid1:
241+
assert_equal(tx['confirmations'], 2)
242+
243+
# the same check for the removed array; confirmations should STILL be 2
244+
for tx in lsbres['removed']:
245+
if tx['txid'] == txid1:
246+
assert_equal(tx['confirmations'], 2)
77247

78248
if __name__ == '__main__':
79249
ListSinceBlockTest().main()

0 commit comments

Comments
 (0)