Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wallet: track mempool conflicts with wallet transactions #27307

Merged
merged 5 commits into from
Mar 27, 2024
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
282 changes: 282 additions & 0 deletions test/functional/wallet_conflicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from decimal import Decimal

from test_framework.blocktools import COINBASE_MATURITY
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_equal,
Expand All @@ -28,6 +29,20 @@ def get_utxo_of_value(self, from_tx_id, search_value):
return next(tx_out["vout"] for tx_out in self.nodes[0].gettransaction(from_tx_id)["details"] if tx_out["amount"] == Decimal(f"{search_value}"))

def run_test(self):
"""
The following tests check the behavior of the wallet when
ishaanam marked this conversation as resolved.
Show resolved Hide resolved
transaction conflicts are created. These conflicts are created
using raw transaction RPCs that double-spend UTXOs and have more
fees, replacing the original transaction.
"""

self.test_block_conflicts()
self.generatetoaddress(self.nodes[0], COINBASE_MATURITY + 7, self.nodes[2].getnewaddress())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:
Would be good to mention why this generatetoaddress is needed. I removed it locally and the test still passes.

self.test_mempool_conflict()
self.test_mempool_and_block_conflicts()
self.test_descendants_with_mempool_conflicts()

def test_block_conflicts(self):
self.log.info("Send tx from which to conflict outputs later")
txid_conflict_from_1 = self.nodes[0].sendtoaddress(self.nodes[0].getnewaddress(), Decimal("10"))
txid_conflict_from_2 = self.nodes[0].sendtoaddress(self.nodes[0].getnewaddress(), Decimal("10"))
Expand Down Expand Up @@ -123,5 +138,272 @@ def run_test(self):
assert_equal(former_conflicted["confirmations"], 1)
assert_equal(former_conflicted["blockheight"], 217)

def test_mempool_conflict(self):
ishaanam marked this conversation as resolved.
Show resolved Hide resolved
ryanofsky marked this conversation as resolved.
Show resolved Hide resolved
self.nodes[0].createwallet("alice")
alice = self.nodes[0].get_wallet_rpc("alice")

bob = self.nodes[1]

self.nodes[2].send(outputs=[{alice.getnewaddress() : 25} for _ in range(3)])
self.generate(self.nodes[2], 1)

self.log.info("Test a scenario where a transaction has a mempool conflict")

unspents = alice.listunspent()
assert_equal(len(unspents), 3)
ishaanam marked this conversation as resolved.
Show resolved Hide resolved
assert all([tx["amount"] == 25 for tx in unspents])

# tx1 spends unspent[0] and unspent[1]
raw_tx = alice.createrawtransaction(inputs=[unspents[0], unspents[1]], outputs=[{bob.getnewaddress() : 49.9999}])
tx1 = alice.signrawtransactionwithwallet(raw_tx)['hex']

# tx2 spends unspent[1] and unspent[2], conflicts with tx1
raw_tx = alice.createrawtransaction(inputs=[unspents[1], unspents[2]], outputs=[{bob.getnewaddress() : 49.99}])
tx2 = alice.signrawtransactionwithwallet(raw_tx)['hex']

# tx3 spends unspent[2], conflicts with tx2
raw_tx = alice.createrawtransaction(inputs=[unspents[2]], outputs=[{bob.getnewaddress() : 24.9899}])
tx3 = alice.signrawtransactionwithwallet(raw_tx)['hex']

# broadcast tx1
tx1_txid = alice.sendrawtransaction(tx1)

assert_equal(alice.listunspent(), [unspents[2]])
assert_equal(alice.getbalance(), 25)

# broadcast tx2, replaces tx1 in mempool
tx2_txid = alice.sendrawtransaction(tx2)

# Check that unspent[0] is still not available because the wallet does not know that the tx spending it has a mempool conflicted
assert_equal(alice.listunspent(), [])
assert_equal(alice.getbalance(), 0)

self.log.info("Test scenario where a mempool conflict is removed")

# broadcast tx3, replaces tx2 in mempool
# Now that tx1's conflict has been removed, tx1 is now
# not conflicted, and instead is inactive until it is
# rebroadcasted. Now unspent[0] is not available, because
# tx1 is no longer conflicted.
alice.sendrawtransaction(tx3)

assert tx1_txid not in self.nodes[0].getrawmempool()

# now all of alice's outputs should be considered spent
ryanofsky marked this conversation as resolved.
Show resolved Hide resolved
# unspent[0]: spent by inactive tx1
# unspent[1]: spent by inactive tx1
# unspent[2]: spent by active tx3
assert_equal(alice.listunspent(), [])
assert_equal(alice.getbalance(), 0)

# Clean up for next test
bob.sendall([self.nodes[2].getnewaddress()])
self.generate(self.nodes[2], 1)
ishaanam marked this conversation as resolved.
Show resolved Hide resolved

alice.unloadwallet()

def test_mempool_and_block_conflicts(self):
self.nodes[0].createwallet("alice_2")
alice = self.nodes[0].get_wallet_rpc("alice_2")
bob = self.nodes[1]

self.nodes[2].send(outputs=[{alice.getnewaddress() : 25} for _ in range(3)])
self.generate(self.nodes[2], 1)

self.log.info("Test a scenario where a transaction has both a block conflict and a mempool conflict")
unspents = [{"txid" : element["txid"], "vout" : element["vout"]} for element in alice.listunspent()]

assert_equal(bob.getbalances()["mine"]["untrusted_pending"], 0)

# alice and bob nodes are disconnected so that transactions can be
# created by alice, but broadcasted from bob so that alice's wallet
# doesn't know about them
self.disconnect_nodes(0, 1)

# Sends funds to bob
raw_tx = alice.createrawtransaction(inputs=[unspents[0]], outputs=[{bob.getnewaddress() : 24.99999}])
raw_tx1 = alice.signrawtransactionwithwallet(raw_tx)['hex']
Comment on lines +227 to +228
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 003efbb "test: Add tests for wallet mempool conflicts"

This can be compressed into one line using the send RPC.

Suggested change
raw_tx = alice.createrawtransaction(inputs=[unspents[0]], outputs=[{bob.getnewaddress() : 24.99999}])
raw_tx1 = alice.signrawtransactionwithwallet(raw_tx)['hex']
raw_tx1 = alice.send(outputs=[{bob.getnewaddress(): 24.9999}], inputs=[unspents[0]], add_to_wallet=False)["hex"]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two things are not technically equivalent, because send creates a change output here, which results in intermittent failures for the rest of the test. Is there any way to tell send not to create a change output?

tx1_txid = bob.sendrawtransaction(raw_tx1) # broadcast original tx spending unspents[0] only to bob

# create a conflict to previous tx (also spends unspents[0]), but don't broadcast, sends funds back to alice
raw_tx = alice.createrawtransaction(inputs=[unspents[0], unspents[2]], outputs=[{alice.getnewaddress() : 49.999}])
tx1_conflict = alice.signrawtransactionwithwallet(raw_tx)['hex']

# Sends funds to bob
raw_tx = alice.createrawtransaction(inputs=[unspents[1]], outputs=[{bob.getnewaddress() : 24.9999}])
raw_tx2 = alice.signrawtransactionwithwallet(raw_tx)['hex']
tx2_txid = bob.sendrawtransaction(raw_tx2) # broadcast another original tx spending unspents[1] only to bob

# create a conflict to previous tx (also spends unspents[1]), but don't broadcast, sends funds to alice
raw_tx = alice.createrawtransaction(inputs=[unspents[1]], outputs=[{alice.getnewaddress() : 24.9999}])
tx2_conflict = alice.signrawtransactionwithwallet(raw_tx)['hex']

bob_unspents = [{"txid" : element, "vout" : 0} for element in [tx1_txid, tx2_txid]]
Copy link
Member

@furszy furszy Mar 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In d3f7764:

Tiny note for reviewers:
vout is always 0 because "alice only contain 3 utxo of 25 btc each. So, tx1 and tx2 are changeless transactions. (could be good to mention this in the code too)


# tx1 and tx2 are now in bob's mempool, and they are unconflicted, so bob has these funds
assert_equal(bob.getbalances()["mine"]["untrusted_pending"], Decimal("49.99989000"))

# spend both of bob's unspents, child tx of tx1 and tx2
raw_tx = bob.createrawtransaction(inputs=[bob_unspents[0], bob_unspents[1]], outputs=[{bob.getnewaddress() : 49.999}])
raw_tx3 = bob.signrawtransactionwithwallet(raw_tx)['hex']
tx3_txid = bob.sendrawtransaction(raw_tx3) # broadcast tx only to bob

# alice knows about 0 txs, bob knows about 3
assert_equal(len(alice.getrawmempool()), 0)
ishaanam marked this conversation as resolved.
Show resolved Hide resolved
assert_equal(len(bob.getrawmempool()), 3)

assert_equal(bob.getbalances()["mine"]["untrusted_pending"], Decimal("49.99900000"))

# bob broadcasts tx_1 conflict
tx1_conflict_txid = bob.sendrawtransaction(tx1_conflict)
assert_equal(len(alice.getrawmempool()), 0)
assert_equal(len(bob.getrawmempool()), 2) # tx1_conflict kicks out both tx1, and its child tx3

assert tx2_txid in bob.getrawmempool()
assert tx1_conflict_txid in bob.getrawmempool()

# check that the tx2 unspent is still not available because the wallet does not know that the tx spending it has a mempool conflict
assert_equal(bob.getbalances()["mine"]["untrusted_pending"], 0)

# we will be disconnecting this block in the future
alice.sendrawtransaction(tx2_conflict)
assert_equal(len(alice.getrawmempool()), 1) # currently alice's mempool is only aware of tx2_conflict
# 11 blocks are mined so that when they are invalidated, tx_2
# does not get put back into the mempool
blk = self.generate(self.nodes[0], 11, sync_fun=self.no_op)[0]
ishaanam marked this conversation as resolved.
Show resolved Hide resolved
assert_equal(len(alice.getrawmempool()), 0) # tx2_conflict is now mined

self.connect_nodes(0, 1)
self.sync_blocks()
assert_equal(alice.getbestblockhash(), bob.getbestblockhash())

# now that tx2 has a block conflict, tx1_conflict should be the only tx in bob's mempool
assert tx1_conflict_txid in bob.getrawmempool()
assert_equal(len(bob.getrawmempool()), 1)

# tx3 should now also be block-conflicted by tx2_conflict
assert_equal(bob.gettransaction(tx3_txid)["confirmations"], -11)
# bob has no pending funds, since tx1, tx2, and tx3 are all conflicted
assert_equal(bob.getbalances()["mine"]["untrusted_pending"], 0)
bob.invalidateblock(blk) # remove tx2_conflict
# bob should still have no pending funds because tx1 and tx3 are still conflicted, and tx2 has not been re-broadcast
assert_equal(bob.getbalances()["mine"]["untrusted_pending"], 0)
assert_equal(len(bob.getrawmempool()), 1)
# check that tx3 is no longer block-conflicted
assert_equal(bob.gettransaction(tx3_txid)["confirmations"], 0)

bob.sendrawtransaction(raw_tx2)
assert_equal(bob.getbalances()["mine"]["untrusted_pending"], 0)

# create a conflict to previous tx (also spends unspents[2]), but don't broadcast, sends funds back to alice
raw_tx = alice.createrawtransaction(inputs=[unspents[2]], outputs=[{alice.getnewaddress() : 24.99}])
tx1_conflict_conflict = alice.signrawtransactionwithwallet(raw_tx)['hex']

bob.sendrawtransaction(tx1_conflict_conflict) # kick tx1_conflict out of the mempool
bob.sendrawtransaction(raw_tx1) #re-broadcast tx1 because it is no longer conflicted
ryanofsky marked this conversation as resolved.
Show resolved Hide resolved

# Now bob has no pending funds because tx1 and tx2 are spent by tx3, which hasn't been re-broadcast yet
assert_equal(bob.getbalances()["mine"]["untrusted_pending"], 0)

bob.sendrawtransaction(raw_tx3)
assert_equal(len(bob.getrawmempool()), 4) # The mempool contains: tx1, tx2, tx1_conflict_conflict, tx3
assert_equal(bob.getbalances()["mine"]["untrusted_pending"], Decimal("49.99900000"))

# Clean up for next test
bob.reconsiderblock(blk)
assert_equal(alice.getbestblockhash(), bob.getbestblockhash())
self.sync_mempools()
self.generate(self.nodes[2], 1)

alice.unloadwallet()

def test_descendants_with_mempool_conflicts(self):
ryanofsky marked this conversation as resolved.
Show resolved Hide resolved
self.nodes[0].createwallet("alice_3")
alice = self.nodes[0].get_wallet_rpc("alice_3")

self.nodes[2].send(outputs=[{alice.getnewaddress() : 25} for _ in range(2)])
self.generate(self.nodes[2], 1)

self.nodes[1].createwallet("bob_1")
bob = self.nodes[1].get_wallet_rpc("bob_1")

self.nodes[2].createwallet("carol")
carol = self.nodes[2].get_wallet_rpc("carol")

self.log.info("Test a scenario where a transaction's parent has a mempool conflict")

unspents = alice.listunspent()
assert_equal(len(unspents), 2)
assert all([tx["amount"] == 25 for tx in unspents])

assert_equal(alice.getrawmempool(), [])

# Alice spends first utxo to bob in tx1
raw_tx = alice.createrawtransaction(inputs=[unspents[0]], outputs=[{bob.getnewaddress() : 24.9999}])
tx1 = alice.signrawtransactionwithwallet(raw_tx)['hex']
tx1_txid = alice.sendrawtransaction(tx1)

self.sync_mempools()

assert_equal(alice.getbalance(), 25)
assert_equal(bob.getbalances()["mine"]["untrusted_pending"], Decimal("24.99990000"))

raw_tx = bob.createrawtransaction(inputs=[bob.listunspent(minconf=0)[0]], outputs=[{carol.getnewaddress() : 24.999}])
# Bob creates a child to tx1
tx1_child = bob.signrawtransactionwithwallet(raw_tx)['hex']
tx1_child_txid = bob.sendrawtransaction(tx1_child)

self.sync_mempools()

# Currently neither tx1 nor tx1_child should have any conflicts
assert tx1_txid in bob.getrawmempool()
assert tx1_child_txid in bob.getrawmempool()
assert_equal(len(bob.getrawmempool()), 2)

assert_equal(bob.getbalances()["mine"]["untrusted_pending"], 0)
assert_equal(carol.getbalances()["mine"]["untrusted_pending"], Decimal("24.99900000"))

# Alice spends first unspent again, conflicting with tx1
raw_tx = alice.createrawtransaction(inputs=[unspents[0], unspents[1]], outputs=[{carol.getnewaddress() : 49.99}])
tx1_conflict = alice.signrawtransactionwithwallet(raw_tx)['hex']
tx1_conflict_txid = alice.sendrawtransaction(tx1_conflict)

self.sync_mempools()

assert_equal(bob.getbalances()["mine"]["untrusted_pending"], 0)
assert_equal(carol.getbalances()["mine"]["untrusted_pending"], Decimal("49.99000000"))

assert tx1_txid not in bob.getrawmempool()
assert tx1_child_txid not in bob.getrawmempool()
assert tx1_conflict_txid in bob.getrawmempool()
assert_equal(len(bob.getrawmempool()), 1)

# Now create a conflict to tx1_conflict, so that it gets kicked out of the mempool
raw_tx = alice.createrawtransaction(inputs=[unspents[1]], outputs=[{carol.getnewaddress() : 24.9895}])
tx1_conflict_conflict = alice.signrawtransactionwithwallet(raw_tx)['hex']
tx1_conflict_conflict_txid = alice.sendrawtransaction(tx1_conflict_conflict)

self.sync_mempools()

# Both tx1 and tx1_child are still not in the mempool because they have not be re-broadcasted
assert tx1_txid not in bob.getrawmempool()
assert tx1_child_txid not in bob.getrawmempool()
assert tx1_conflict_txid not in bob.getrawmempool()
assert tx1_conflict_conflict_txid in bob.getrawmempool()
assert_equal(len(bob.getrawmempool()), 1)

assert_equal(alice.getbalance(), 0)
assert_equal(bob.getbalances()["mine"]["untrusted_pending"], 0)
assert_equal(carol.getbalances()["mine"]["untrusted_pending"], Decimal("24.98950000"))

# Both tx1 and tx1_child can now be re-broadcasted
bob.sendrawtransaction(tx1)
bob.sendrawtransaction(tx1_child)
assert_equal(len(bob.getrawmempool()), 3)

alice.unloadwallet()
bob.unloadwallet()
carol.unloadwallet()

if __name__ == '__main__':
TxConflicts().main()