Skip to content

Commit 0137e6f

Browse files
committed
Add tests for transaction replacement
1 parent 5891f87 commit 0137e6f

File tree

3 files changed

+294
-0
lines changed

3 files changed

+294
-0
lines changed

qa/replace-by-fee/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
python-bitcoinlib

qa/replace-by-fee/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Replace-by-fee regression tests
2+
===============================
3+
4+
First get version v0.5.0 of the python-bitcoinlib library. In this directory
5+
run:
6+
7+
git clone -n https://github.com/petertodd/python-bitcoinlib
8+
(cd python-bitcoinlib && git checkout 8270bfd9c6ac37907d75db3d8b9152d61c7255cd)
9+
10+
Then run the tests themselves with a bitcoind available running in regtest
11+
mode:
12+
13+
./rbf-tests.py

qa/replace-by-fee/rbf-tests.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2015 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+
#
7+
# Test replace-by-fee
8+
#
9+
10+
import os
11+
import sys
12+
13+
# Add python-bitcoinlib to module search path:
14+
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "python-bitcoinlib"))
15+
16+
import unittest
17+
18+
import bitcoin
19+
bitcoin.SelectParams('regtest')
20+
21+
import bitcoin.rpc
22+
23+
from bitcoin.core import *
24+
from bitcoin.core.script import *
25+
from bitcoin.wallet import *
26+
27+
MAX_REPLACEMENT_LIMIT = 100
28+
29+
class Test_ReplaceByFee(unittest.TestCase):
30+
proxy = None
31+
32+
@classmethod
33+
def setUpClass(cls):
34+
if cls.proxy is None:
35+
cls.proxy = bitcoin.rpc.Proxy()
36+
37+
@classmethod
38+
def tearDownClass(cls):
39+
# Make sure mining works
40+
mempool_size = 1
41+
while mempool_size:
42+
cls.proxy.call('generate',1)
43+
new_mempool_size = len(cls.proxy.getrawmempool())
44+
45+
# It's possible to get stuck in a loop here if the mempool has
46+
# transactions that can't be mined.
47+
assert(new_mempool_size != mempool_size)
48+
mempool_size = new_mempool_size
49+
50+
def make_txout(self, amount, scriptPubKey=CScript([1])):
51+
"""Create a txout with a given amount and scriptPubKey
52+
53+
Mines coins as needed.
54+
"""
55+
fee = 1*COIN
56+
while self.proxy.getbalance() < amount + fee:
57+
self.proxy.call('generate', 100)
58+
59+
addr = P2SHBitcoinAddress.from_redeemScript(CScript([]))
60+
txid = self.proxy.sendtoaddress(addr, amount + fee)
61+
62+
tx1 = self.proxy.getrawtransaction(txid)
63+
64+
i = None
65+
for i, txout in enumerate(tx1.vout):
66+
if txout.scriptPubKey == addr.to_scriptPubKey():
67+
break
68+
assert i is not None
69+
70+
tx2 = CTransaction([CTxIn(COutPoint(txid, i), CScript([1, CScript([])]), nSequence=0)],
71+
[CTxOut(amount, scriptPubKey)])
72+
73+
tx2_txid = self.proxy.sendrawtransaction(tx2, True)
74+
75+
return COutPoint(tx2_txid, 0)
76+
77+
def test_simple_doublespend(self):
78+
"""Simple doublespend"""
79+
tx0_outpoint = self.make_txout(1.1*COIN)
80+
81+
tx1a = CTransaction([CTxIn(tx0_outpoint, nSequence=0)],
82+
[CTxOut(1*COIN, CScript([b'a']))])
83+
tx1a_txid = self.proxy.sendrawtransaction(tx1a, True)
84+
85+
# Should fail because we haven't changed the fee
86+
tx1b = CTransaction([CTxIn(tx0_outpoint, nSequence=0)],
87+
[CTxOut(1*COIN, CScript([b'b']))])
88+
89+
try:
90+
tx1b_txid = self.proxy.sendrawtransaction(tx1b, True)
91+
except bitcoin.rpc.JSONRPCException as exp:
92+
self.assertEqual(exp.error['code'], -26) # insufficient fee
93+
else:
94+
self.fail()
95+
96+
# Extra 0.1 BTC fee
97+
tx1b = CTransaction([CTxIn(tx0_outpoint, nSequence=0)],
98+
[CTxOut(0.9*COIN, CScript([b'b']))])
99+
tx1b_txid = self.proxy.sendrawtransaction(tx1b, True)
100+
101+
# tx1a is in fact replaced
102+
with self.assertRaises(IndexError):
103+
self.proxy.getrawtransaction(tx1a_txid)
104+
105+
self.assertEqual(tx1b, self.proxy.getrawtransaction(tx1b_txid))
106+
107+
def test_doublespend_chain(self):
108+
"""Doublespend of a long chain"""
109+
110+
initial_nValue = 50*COIN
111+
tx0_outpoint = self.make_txout(initial_nValue)
112+
113+
prevout = tx0_outpoint
114+
remaining_value = initial_nValue
115+
chain_txids = []
116+
while remaining_value > 10*COIN:
117+
remaining_value -= 1*COIN
118+
tx = CTransaction([CTxIn(prevout, nSequence=0)],
119+
[CTxOut(remaining_value, CScript([1]))])
120+
txid = self.proxy.sendrawtransaction(tx, True)
121+
chain_txids.append(txid)
122+
prevout = COutPoint(txid, 0)
123+
124+
# Whether the double-spend is allowed is evaluated by including all
125+
# child fees - 40 BTC - so this attempt is rejected.
126+
dbl_tx = CTransaction([CTxIn(tx0_outpoint, nSequence=0)],
127+
[CTxOut(initial_nValue - 30*COIN, CScript([1]))])
128+
129+
try:
130+
self.proxy.sendrawtransaction(dbl_tx, True)
131+
except bitcoin.rpc.JSONRPCException as exp:
132+
self.assertEqual(exp.error['code'], -26) # insufficient fee
133+
else:
134+
self.fail()
135+
136+
# Accepted with sufficient fee
137+
dbl_tx = CTransaction([CTxIn(tx0_outpoint, nSequence=0)],
138+
[CTxOut(1*COIN, CScript([1]))])
139+
self.proxy.sendrawtransaction(dbl_tx, True)
140+
141+
for doublespent_txid in chain_txids:
142+
with self.assertRaises(IndexError):
143+
self.proxy.getrawtransaction(doublespent_txid)
144+
145+
def test_doublespend_tree(self):
146+
"""Doublespend of a big tree of transactions"""
147+
148+
initial_nValue = 50*COIN
149+
tx0_outpoint = self.make_txout(initial_nValue)
150+
151+
def branch(prevout, initial_value, max_txs, *, tree_width=5, fee=0.0001*COIN, _total_txs=None):
152+
if _total_txs is None:
153+
_total_txs = [0]
154+
if _total_txs[0] >= max_txs:
155+
return
156+
157+
txout_value = (initial_value - fee) // tree_width
158+
if txout_value < fee:
159+
return
160+
161+
vout = [CTxOut(txout_value, CScript([i+1]))
162+
for i in range(tree_width)]
163+
tx = CTransaction([CTxIn(prevout, nSequence=0)],
164+
vout)
165+
166+
self.assertTrue(len(tx.serialize()) < 100000)
167+
txid = self.proxy.sendrawtransaction(tx, True)
168+
yield tx
169+
_total_txs[0] += 1
170+
171+
for i, txout in enumerate(tx.vout):
172+
yield from branch(COutPoint(txid, i), txout_value,
173+
max_txs,
174+
tree_width=tree_width, fee=fee,
175+
_total_txs=_total_txs)
176+
177+
fee = 0.0001*COIN
178+
n = MAX_REPLACEMENT_LIMIT
179+
tree_txs = list(branch(tx0_outpoint, initial_nValue, n, fee=fee))
180+
self.assertEqual(len(tree_txs), n)
181+
182+
# Attempt double-spend, will fail because too little fee paid
183+
dbl_tx = CTransaction([CTxIn(tx0_outpoint, nSequence=0)],
184+
[CTxOut(initial_nValue - fee*n, CScript([1]))])
185+
try:
186+
self.proxy.sendrawtransaction(dbl_tx, True)
187+
except bitcoin.rpc.JSONRPCException as exp:
188+
self.assertEqual(exp.error['code'], -26) # insufficient fee
189+
else:
190+
self.fail()
191+
192+
# 1 BTC fee is enough
193+
dbl_tx = CTransaction([CTxIn(tx0_outpoint, nSequence=0)],
194+
[CTxOut(initial_nValue - fee*n - 1*COIN, CScript([1]))])
195+
self.proxy.sendrawtransaction(dbl_tx, True)
196+
197+
for tx in tree_txs:
198+
with self.assertRaises(IndexError):
199+
self.proxy.getrawtransaction(tx.GetHash())
200+
201+
# Try again, but with more total transactions than the "max txs
202+
# double-spent at once" anti-DoS limit.
203+
for n in (MAX_REPLACEMENT_LIMIT, MAX_REPLACEMENT_LIMIT*2):
204+
fee = 0.0001*COIN
205+
tx0_outpoint = self.make_txout(initial_nValue)
206+
tree_txs = list(branch(tx0_outpoint, initial_nValue, n, fee=fee))
207+
self.assertEqual(len(tree_txs), n)
208+
209+
dbl_tx = CTransaction([CTxIn(tx0_outpoint, nSequence=0)],
210+
[CTxOut(initial_nValue - fee*n, CScript([1]))])
211+
try:
212+
self.proxy.sendrawtransaction(dbl_tx, True)
213+
except bitcoin.rpc.JSONRPCException as exp:
214+
self.assertEqual(exp.error['code'], -26)
215+
else:
216+
self.fail()
217+
218+
for tx in tree_txs:
219+
self.proxy.getrawtransaction(tx.GetHash())
220+
221+
def test_replacement_feeperkb(self):
222+
"""Replacement requires overall fee-per-KB to be higher"""
223+
tx0_outpoint = self.make_txout(1.1*COIN)
224+
225+
tx1a = CTransaction([CTxIn(tx0_outpoint, nSequence=0)],
226+
[CTxOut(1*COIN, CScript([b'a']))])
227+
tx1a_txid = self.proxy.sendrawtransaction(tx1a, True)
228+
229+
# Higher fee, but the fee per KB is much lower, so the replacement is
230+
# rejected.
231+
tx1b = CTransaction([CTxIn(tx0_outpoint, nSequence=0)],
232+
[CTxOut(0.001*COIN,
233+
CScript([b'a'*999000]))])
234+
235+
try:
236+
tx1b_txid = self.proxy.sendrawtransaction(tx1b, True)
237+
except bitcoin.rpc.JSONRPCException as exp:
238+
self.assertEqual(exp.error['code'], -26) # insufficient fee
239+
else:
240+
self.fail()
241+
242+
def test_spends_of_conflicting_outputs(self):
243+
"""Replacements that spend conflicting tx outputs are rejected"""
244+
utxo1 = self.make_txout(1.2*COIN)
245+
utxo2 = self.make_txout(3.0*COIN)
246+
247+
tx1a = CTransaction([CTxIn(utxo1, nSequence=0)],
248+
[CTxOut(1.1*COIN, CScript([b'a']))])
249+
tx1a_txid = self.proxy.sendrawtransaction(tx1a, True)
250+
251+
# Direct spend an output of the transaction we're replacing.
252+
tx2 = CTransaction([CTxIn(utxo1, nSequence=0), CTxIn(utxo2, nSequence=0),
253+
CTxIn(COutPoint(tx1a_txid, 0), nSequence=0)],
254+
tx1a.vout)
255+
256+
try:
257+
tx2_txid = self.proxy.sendrawtransaction(tx2, True)
258+
except bitcoin.rpc.JSONRPCException as exp:
259+
self.assertEqual(exp.error['code'], -26)
260+
else:
261+
self.fail()
262+
263+
# Spend tx1a's output to test the indirect case.
264+
tx1b = CTransaction([CTxIn(COutPoint(tx1a_txid, 0), nSequence=0)],
265+
[CTxOut(1.0*COIN, CScript([b'a']))])
266+
tx1b_txid = self.proxy.sendrawtransaction(tx1b, True)
267+
268+
tx2 = CTransaction([CTxIn(utxo1, nSequence=0), CTxIn(utxo2, nSequence=0),
269+
CTxIn(COutPoint(tx1b_txid, 0))],
270+
tx1a.vout)
271+
272+
try:
273+
tx2_txid = self.proxy.sendrawtransaction(tx2, True)
274+
except bitcoin.rpc.JSONRPCException as exp:
275+
self.assertEqual(exp.error['code'], -26)
276+
else:
277+
self.fail()
278+
279+
if __name__ == '__main__':
280+
unittest.main()

0 commit comments

Comments
 (0)