Skip to content

Commit 56482d9

Browse files
instagibbsajtowns
authored andcommitted
Functional tests for ephemeral anchors
Does not have test coverage for making sure EA are spent, and package RBF cases, neither of which exist currently.
1 parent 79a70ea commit 56482d9

File tree

2 files changed

+212
-0
lines changed

2 files changed

+212
-0
lines changed
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2022 The Bitcoin Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
6+
import copy
7+
from decimal import Decimal
8+
9+
from test_framework.messages import (
10+
COIN,
11+
CTxInWitness,
12+
CTxOut,
13+
MAX_BIP125_RBF_SEQUENCE,
14+
)
15+
from test_framework.script import (
16+
CScript,
17+
OP_TRUE,
18+
)
19+
from test_framework.test_framework import BitcoinTestFramework
20+
from test_framework.util import (
21+
assert_equal,
22+
assert_raises_rpc_error,
23+
)
24+
from test_framework.wallet import (
25+
DEFAULT_FEE,
26+
MiniWallet,
27+
)
28+
29+
class EphemeralAnchorTest(BitcoinTestFramework):
30+
def set_test_params(self):
31+
self.num_nodes = 1
32+
self.setup_clean_chain = True
33+
34+
def assert_mempool_contents(self, expected=None, unexpected=None):
35+
"""Assert that all transactions in expected are in the mempool,
36+
and all transactions in unexpected are not in the mempool.
37+
"""
38+
if not expected:
39+
expected = []
40+
if not unexpected:
41+
unexpected = []
42+
assert set(unexpected).isdisjoint(expected)
43+
mempool = self.nodes[0].getrawmempool(verbose=False)
44+
for tx in expected:
45+
assert tx.rehash() in mempool
46+
for tx in unexpected:
47+
assert tx.rehash() not in mempool
48+
49+
def insert_additional_outputs(self, parent_result, additional_outputs):
50+
# Modify transaction as needed to add ephemeral anchor
51+
parent_tx = parent_result["tx"]
52+
additional_sum = 0
53+
for additional_output in additional_outputs:
54+
parent_tx.vout.append(additional_output)
55+
additional_sum += additional_output.nValue
56+
57+
# Steal value from destination and recompute fields
58+
parent_tx.vout[0].nValue -= additional_sum
59+
parent_result["txid"] = parent_tx.rehash()
60+
parent_result["wtxid"] = parent_tx.getwtxid()
61+
parent_result["hex"] = parent_tx.serialize().hex()
62+
parent_result["new_utxo"] = {**parent_result["new_utxo"], "txid": parent_result["txid"], "value": Decimal(parent_tx.vout[0].nValue)/COIN}
63+
64+
65+
def spend_ephemeral_anchor_witness(self, child_result, child_inputs):
66+
child_tx = child_result["tx"]
67+
child_tx.wit.vtxinwit = [copy.deepcopy(child_tx.wit.vtxinwit[0]) if "anchor" not in x else CTxInWitness() for x in child_inputs]
68+
child_result["hex"] = child_tx.serialize().hex()
69+
70+
71+
def create_simple_package(self, parent_coin, parent_fee=0, child_fee=DEFAULT_FEE, spend_anchor=1, additional_outputs=None):
72+
"""Create a 1 parent 1 child package using the coin passed in as the parent's input. The
73+
parent has 1 output, used to fund 1 child transaction.
74+
All transactions signal BIP125 replaceability, but nSequence changes based on self.ctr. This
75+
prevents identical txids between packages when the parents spend the same coin and have the
76+
same fee (i.e. 0sat).
77+
78+
returns tuple (hex serialized txns, CTransaction objects)
79+
"""
80+
81+
if additional_outputs is None:
82+
additional_outputs=[CTxOut(0, CScript([OP_TRUE]))]
83+
84+
child_inputs = []
85+
self.ctr += 1
86+
# Use fee_rate=0 because create_self_transfer will use the default fee_rate value otherwise.
87+
# Passing in fee>0 overrides fee_rate, so this still works for non-zero parent_fee.
88+
parent_result = self.wallet.create_self_transfer(
89+
fee_rate=0,
90+
fee=parent_fee,
91+
utxo_to_spend=parent_coin,
92+
sequence=MAX_BIP125_RBF_SEQUENCE - self.ctr,
93+
)
94+
95+
self.insert_additional_outputs(parent_result, additional_outputs)
96+
97+
# Add inputs to child, depending on spend arg
98+
child_inputs.append(parent_result["new_utxo"])
99+
if spend_anchor:
100+
for vout, output in enumerate(additional_outputs):
101+
child_inputs.append({**parent_result["new_utxo"], 'vout': 1+vout, 'value': Decimal(output.nValue)/COIN, 'anchor': True})
102+
103+
104+
child_result = self.wallet.create_self_transfer_multi(
105+
utxos_to_spend=child_inputs,
106+
num_outputs=1,
107+
fee_per_output=int(child_fee * COIN),
108+
sequence=MAX_BIP125_RBF_SEQUENCE - self.ctr,
109+
)
110+
111+
if spend_anchor:
112+
self.spend_ephemeral_anchor_witness(child_result, child_inputs)
113+
114+
package_hex = [parent_result["hex"], child_result["hex"]]
115+
package_txns = [parent_result["tx"], child_result["tx"]]
116+
return package_hex, package_txns
117+
118+
def run_test(self):
119+
# Counter used to count the number of times we constructed packages. Since we're constructing parent transactions with the same
120+
# coins (to create conflicts), and giving them the same fee (i.e. 0, since their respective children are paying), we might
121+
# accidentally just create the exact same transaction again. To prevent this, set nSequences to MAX_BIP125_RBF_SEQUENCE - self.ctr.
122+
self.ctr = 0
123+
124+
self.log.info("Generate blocks to create UTXOs")
125+
node = self.nodes[0]
126+
self.wallet = MiniWallet(node)
127+
self.generate(self.wallet, 160)
128+
self.coins = self.wallet.get_utxos(mark_as_spent=False)
129+
# Mature coinbase transactions
130+
self.generate(self.wallet, 100)
131+
self.address = self.wallet.get_address()
132+
133+
self.test_fee_having_parent()
134+
self.test_multianchor()
135+
self.test_nonzero_anchor()
136+
self.test_prioritise_parent()
137+
138+
def test_fee_having_parent(self):
139+
self.log.info("Test that a transaction with ephemeral anchor may not have base fee")
140+
node = self.nodes[0]
141+
# Reuse the same coins so that the transactions conflict with one another.
142+
parent_coin = self.coins[-1]
143+
del self.coins[-1]
144+
145+
package_hex0, package_txns0 = self.create_simple_package(parent_coin=parent_coin, parent_fee=1, child_fee=DEFAULT_FEE)
146+
assert_raises_rpc_error(-26, "invalid-ephemeral-fee", node.submitpackage, package_hex0)
147+
assert_equal(node.getrawmempool(), [])
148+
149+
# But works with no parent fee
150+
package_hex1, package_txns1 = self.create_simple_package(parent_coin=parent_coin, parent_fee=0, child_fee=DEFAULT_FEE)
151+
node.submitpackage(package_hex1)
152+
self.assert_mempool_contents(expected=package_txns1, unexpected=[])
153+
154+
self.generate(node, 1)
155+
156+
def test_multianchor(self):
157+
self.log.info("Test that a transaction with multiple ephemeral anchors is nonstandard")
158+
node = self.nodes[0]
159+
# Reuse the same coins so that the transactions conflict with one another.
160+
parent_coin = self.coins[-1]
161+
del self.coins[-1]
162+
163+
package_hex0, package_txns0 = self.create_simple_package(parent_coin=parent_coin, parent_fee=0, child_fee=DEFAULT_FEE, additional_outputs=[CTxOut(0, CScript([OP_TRUE]))] * 2)
164+
assert_raises_rpc_error(-26, "too-many-ephemeral-anchors", node.submitpackage, package_hex0)
165+
assert_equal(node.getrawmempool(), [])
166+
167+
self.generate(node, 1)
168+
169+
def test_nonzero_anchor(self):
170+
def inner_test_anchor_value(output_value):
171+
node = self.nodes[0]
172+
# Reuse the same coins so that the transactions conflict with one another.
173+
parent_coin = self.coins[-1]
174+
del self.coins[-1]
175+
176+
package_hex0, package_txns0 = self.create_simple_package(parent_coin=parent_coin, parent_fee=0, child_fee=DEFAULT_FEE, additional_outputs=[CTxOut(output_value, CScript([OP_TRUE]))])
177+
node.submitpackage(package_hex0)
178+
self.assert_mempool_contents(expected=package_txns0, unexpected=[])
179+
180+
self.generate(node, 1)
181+
182+
self.log.info("Test that a transaction with ephemeral anchor may have any otherwise legal satoshi value")
183+
for i in range(5):
184+
inner_test_anchor_value(int(i*COIN/4))
185+
186+
def test_prioritise_parent(self):
187+
self.log.info("Test that prioritizing a parent transaction with ephemeral anchor doesn't cause mempool rejection due to non-0 parent fee")
188+
node = self.nodes[0]
189+
# Reuse the same coins so that the transactions conflict with one another.
190+
parent_coin = self.coins[-1]
191+
del self.coins[-1]
192+
193+
# De-prioritising to 0-fee doesn't matter; it's just the base fee that matters
194+
package_hex0, package_txns0 = self.create_simple_package(parent_coin=parent_coin, parent_fee=1, child_fee=DEFAULT_FEE)
195+
parent_txid = node.decoderawtransaction(package_hex0[0])['txid']
196+
node.prioritisetransaction(txid=parent_txid, dummy=0, fee_delta=COIN)
197+
assert_raises_rpc_error(-26, "invalid-ephemeral-fee", node.submitpackage, package_hex0)
198+
assert_equal(node.getrawmempool(), [])
199+
200+
# Also doesn't make it invalid if applied to the parent
201+
package_hex1, package_txns1 = self.create_simple_package(parent_coin=parent_coin, parent_fee=0, child_fee=DEFAULT_FEE)
202+
parent_txid = node.decoderawtransaction(package_hex1[0])['txid']
203+
node.prioritisetransaction(txid=parent_txid, dummy=0, fee_delta=COIN)
204+
node.submitpackage(package_hex1)
205+
self.assert_mempool_contents(expected=package_txns1, unexpected=[])
206+
207+
self.generate(node, 1)
208+
209+
210+
if __name__ == "__main__":
211+
EphemeralAnchorTest().main()

test/functional/test_runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@
356356
'feature_help.py',
357357
'feature_shutdown.py',
358358
'wallet_migration.py',
359+
'mempool_ephemeral_anchor.py',
359360
'p2p_ibd_txrelay.py',
360361
# Don't append tests at the end to avoid merge conflicts
361362
# Put them in a random line within the section that fits their approximate run-time

0 commit comments

Comments
 (0)