Skip to content

Commit f04a1a3

Browse files
committed
Merge petertodd#136: FakeBitcoinProxy: a mock RPC implementation.
4b5c0b6 FakeBitcoinProxy: a mock RPC implementation. (Bryan Bishop) Pull request description: FakeBitcoinProxy provides an interface similar to the bitcoin.rpc.Proxy API. Downstream applications interested in unit testing can use FakeBitcoinProxy as an alternative to elaborate Proxy mocks. This interface returns similar values as Proxy and is intended to have similar behavior. Unit tests are provided and they are passing. We've been using FakeBitcoinProxy since 2015 to test many different bitcoin applications, which have also been tested against bitcoin nodes running on regtest, testnet and mainnet. Specifically, we pass in FakeBitcoinProxy instead of a bitcoin connection and we get back the same behavior in these different environments using the same interface. Ideally, this contribution would also include tests to show parity between Proxy and FakeBitcoinProxy, even against active bitcoind nodes, but given extensive usage in multiple environments it seems like less of a priority right now. An interesting future direction for this would be to load test blockchain data from bitcoind regtest files, and then load that up for testing. I have some tools for handcrafting regtest reorg scenarios using FakeBitcoinProxy but I think it would be better to load from file and do less handcrafting. Tree-SHA512: 3d358abb4278903c068619da2b370a06b93438638974c4fa57a658dc76342d44e68ff1f85dc2065de39968ce6a2689e2eb38fef7616e82eba2451ec86f47793b
2 parents 16e74e6 + 4b5c0b6 commit f04a1a3

File tree

2 files changed

+747
-0
lines changed

2 files changed

+747
-0
lines changed

bitcoin/tests/fakebitcoinproxy.py

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
"""
2+
`FakeBitcoinProxy` allows for unit testing of code that normally uses bitcoin
3+
RPC without requiring a running bitcoin node.
4+
5+
`FakeBitcoinProxy` has an interface similar to `bitcoin.rpc.Proxy`, but does not
6+
connect to a local bitcoin RPC node. Hence, `FakeBitcoinProxy` is similar to a
7+
mock for the RPC tests.
8+
9+
`FakeBitcoinProxy` does _not_ implement a full bitcoin RPC node. Instead, it
10+
currently implements only a subset of the available RPC commands. Test setup is
11+
responsible for populating a `FakeBitcoinProxy` object with reasonable mock
12+
data.
13+
14+
:author: Bryan Bishop <[email protected]>
15+
"""
16+
17+
import random
18+
import hashlib
19+
20+
from bitcoin.core import (
21+
# bytes to hex (see x)
22+
b2x,
23+
24+
# convert hex string to bytes (see b2x)
25+
x,
26+
27+
# convert little-endian hex string to bytes (see b2lx)
28+
lx,
29+
30+
# convert bytes to little-endian hex string (see lx)
31+
b2lx,
32+
33+
# number of satoshis per bitcoin
34+
COIN,
35+
36+
# a type for a transaction that isn't finished building
37+
CMutableTransaction,
38+
CMutableTxIn,
39+
CMutableTxOut,
40+
COutPoint,
41+
CTxIn,
42+
)
43+
44+
from bitcoin.wallet import (
45+
# bitcoin address initialized from base58-encoded string
46+
CBitcoinAddress,
47+
48+
# base58-encoded secret key
49+
CBitcoinSecret,
50+
51+
# has a nifty function from_pubkey
52+
P2PKHBitcoinAddress,
53+
)
54+
55+
def make_address_from_passphrase(passphrase, compressed=True, as_str=True):
56+
"""
57+
Create a Bitcoin address from a passphrase. The passphrase is hashed and
58+
then used as the secret bytes to construct the CBitcoinSecret.
59+
"""
60+
if not isinstance(passphrase, bytes):
61+
passphrase = bytes(passphrase, "utf-8")
62+
passphrasehash = hashlib.sha256(passphrase).digest()
63+
private_key = CBitcoinSecret.from_secret_bytes(passphrasehash, compressed=compressed)
64+
address = P2PKHBitcoinAddress.from_pubkey(private_key.pub)
65+
if as_str:
66+
return str(address)
67+
else:
68+
return address
69+
70+
def make_txout(amount=None, address=None, counter=None):
71+
"""
72+
Make a CTxOut object based on the parameters. Otherwise randomly generate a
73+
CTxOut to represent a transaction output.
74+
75+
:param amount: amount in satoshis
76+
"""
77+
passphrase_template = "correct horse battery staple txout {counter}"
78+
79+
if not counter:
80+
counter = random.randrange(0, 2**50)
81+
82+
if not address:
83+
passphrase = passphrase_template.format(counter=counter)
84+
address = make_address_from_passphrase(bytes(passphrase, "utf-8"))
85+
86+
if not amount:
87+
maxsatoshis = (21 * 1000 * 1000) * (100 * 1000 * 1000) # 21 million BTC * 100 million satoshi per BTC
88+
amount = random.randrange(0, maxsatoshis) # between 0 satoshi and 21 million BTC
89+
90+
txout = CMutableTxOut(amount, CBitcoinAddress(address).to_scriptPubKey())
91+
92+
return txout
93+
94+
def make_blocks_from_blockhashes(blockhashes):
95+
"""
96+
Create some block data suitable for FakeBitcoinProxy to consume during
97+
instantiation.
98+
"""
99+
blocks = []
100+
101+
for (height, blockhash) in enumerate(blockhashes):
102+
block = {"hash": blockhash, "height": height, "tx": []}
103+
if height != 0:
104+
block["previousblockhash"] = previousblockhash
105+
blocks.append(block)
106+
previousblockhash = blockhash
107+
108+
return blocks
109+
110+
def make_rpc_batch_request_entry(rpc_name, params):
111+
"""
112+
Construct an entry for the list of commands that will be passed as a batch
113+
(for `_batch`).
114+
"""
115+
return {
116+
"id": "50",
117+
"version": "1.1",
118+
"method": rpc_name,
119+
"params": params,
120+
}
121+
122+
class FakeBitcoinProxyException(Exception):
123+
"""
124+
Incorrect usage of fake proxy.
125+
"""
126+
pass
127+
128+
class FakeBitcoinProxy(object):
129+
"""
130+
This is an alternative to using `bitcoin.rpc.Proxy` in tests. This class
131+
can store a number of blocks and transactions, which can then be retrieved
132+
by calling various "RPC" methods.
133+
"""
134+
135+
def __init__(self, blocks=None, transactions=None, getnewaddress_offset=None, getnewaddress_passphrase_template="getnewaddress passphrase template {}", num_fundrawtransaction_inputs=5):
136+
"""
137+
:param getnewaddress_offset: a number to start using and incrementing
138+
in template used by getnewaddress.
139+
:type getnewaddress_offset: int
140+
:param int num_fundrawtransaction_inputs: number of inputs to create
141+
during fundrawtransaction.
142+
"""
143+
self.blocks = blocks or {}
144+
self.transactions = transactions or {}
145+
146+
if getnewaddress_offset == None:
147+
self._getnewaddress_offset = 0
148+
else:
149+
self._getnewaddress_offset = getnewaddress_offset
150+
151+
self._getnewaddress_passphrase_template = getnewaddress_passphrase_template
152+
self._num_fundrawtransaction_inputs = num_fundrawtransaction_inputs
153+
self.populate_blocks_with_blockheights()
154+
155+
def _call(self, rpc_method_name, *args, **kwargs):
156+
"""
157+
This represents a "raw" RPC call, which has output that
158+
python-bitcoinlib does not parse.
159+
"""
160+
method = getattr(self, rpc_method_name)
161+
return method(*args, **kwargs)
162+
163+
def populate_blocks_with_blockheights(self):
164+
"""
165+
Helper method to correctly apply "height" on all blocks.
166+
"""
167+
for (height, block) in enumerate(self.blocks):
168+
block["height"] = height
169+
170+
def getblock(self, blockhash, *args, **kwargs):
171+
"""
172+
:param blockhash: hash of the block to retrieve data for
173+
:raise IndexError: invalid blockhash
174+
"""
175+
176+
# Note that the actual "getblock" bitcoind RPC call from
177+
# python-bitcoinlib returns a CBlock object, not a dictionary.
178+
179+
if isinstance(blockhash, bytes):
180+
blockhash = b2lx(blockhash)
181+
182+
for block in self.blocks:
183+
if block["hash"] == blockhash:
184+
return block
185+
186+
raise IndexError("no block found for blockhash {}".format(blockhash))
187+
188+
def getblockhash(self, blockheight):
189+
"""
190+
Get block by blockheight.
191+
192+
:type blockheight: int
193+
:rtype: dict
194+
"""
195+
for block in self.blocks:
196+
if block["height"] == int(blockheight):
197+
return block["hash"]
198+
199+
def getblockcount(self):
200+
"""
201+
Return the total number of blocks. When there is only one block in the
202+
blockchain, this function will return zero.
203+
204+
:rtype: int
205+
"""
206+
return len(self.blocks) - 1
207+
208+
def getrawtransaction(self, txid, *args, **kwargs):
209+
"""
210+
Get parsed transaction.
211+
212+
:type txid: bytes or str
213+
:rtype: dict
214+
"""
215+
if isinstance(txid, bytes):
216+
txid = b2lx(txid)
217+
return self.transactions[txid]
218+
219+
def getnewaddress(self):
220+
"""
221+
Construct a new address based on a passphrase template. As more
222+
addresses are generated, the template value goes up.
223+
"""
224+
passphrase = self._getnewaddress_passphrase_template.format(self._getnewaddress_offset)
225+
address = make_address_from_passphrase(bytes(passphrase, "utf-8"))
226+
self._getnewaddress_offset += 1
227+
return CBitcoinAddress(address)
228+
229+
def importaddress(self, *args, **kwargs):
230+
"""
231+
Completely unrealistic fake version of importaddress.
232+
"""
233+
return True
234+
235+
# This was implemented a long time ago and it's possible that this does not
236+
# match the current behavior of fundrawtransaction.
237+
def fundrawtransaction(self, given_transaction, *args, **kwargs):
238+
"""
239+
Make up some inputs for the given transaction.
240+
"""
241+
# just use any txid here
242+
vintxid = lx("99264749804159db1e342a0c8aa3279f6ef4031872051a1e52fb302e51061bef")
243+
244+
if isinstance(given_transaction, str):
245+
given_bytes = x(given_transaction)
246+
elif isinstance(given_transaction, CMutableTransaction):
247+
given_bytes = given_transaction.serialize()
248+
else:
249+
raise FakeBitcoinProxyException("Wrong type passed to fundrawtransaction.")
250+
251+
# this is also a clever way to not cause a side-effect in this function
252+
transaction = CMutableTransaction.deserialize(given_bytes)
253+
254+
for vout_counter in range(0, self._num_fundrawtransaction_inputs):
255+
txin = CMutableTxIn(COutPoint(vintxid, vout_counter))
256+
transaction.vin.append(txin)
257+
258+
# also allocate a single output (for change)
259+
txout = make_txout()
260+
transaction.vout.append(txout)
261+
262+
transaction_hex = b2x(transaction.serialize())
263+
264+
return {"hex": transaction_hex, "fee": 5000000}
265+
266+
def signrawtransaction(self, given_transaction):
267+
"""
268+
This method does not actually sign the transaction, but it does return
269+
a transaction based on the given transaction.
270+
"""
271+
if isinstance(given_transaction, str):
272+
given_bytes = x(given_transaction)
273+
elif isinstance(given_transaction, CMutableTransaction):
274+
given_bytes = given_transaction.serialize()
275+
else:
276+
raise FakeBitcoinProxyException("Wrong type passed to signrawtransaction.")
277+
278+
transaction = CMutableTransaction.deserialize(given_bytes)
279+
transaction_hex = b2x(transaction.serialize())
280+
return {"hex": transaction_hex}
281+
282+
def sendrawtransaction(self, given_transaction):
283+
"""
284+
Pretend to broadcast and relay the transaction. Return the txid of the
285+
given transaction.
286+
"""
287+
if isinstance(given_transaction, str):
288+
given_bytes = x(given_transaction)
289+
elif isinstance(given_transaction, CMutableTransaction):
290+
given_bytes = given_transaction.serialize()
291+
else:
292+
raise FakeBitcoinProxyException("Wrong type passed to sendrawtransaction.")
293+
transaction = CMutableTransaction.deserialize(given_bytes)
294+
return b2lx(transaction.GetHash())
295+
296+
def _batch(self, batch_request_entries):
297+
"""
298+
Process a bunch of requests all at once. This mimics the _batch RPC
299+
feature found in python-bitcoinlib and bitcoind RPC.
300+
"""
301+
necessary_keys = ["id", "version", "method", "params"]
302+
303+
results = []
304+
305+
for (idx, request) in enumerate(batch_request_entries):
306+
error = None
307+
result = None
308+
309+
# assert presence of important details
310+
for necessary_key in necessary_keys:
311+
if not necessary_key in request.keys():
312+
raise FakeBitcoinProxyException("Missing necessary key {} for _batch request number {}".format(necessary_key, idx))
313+
314+
if isinstance(request["params"], list):
315+
method = getattr(self, request["method"])
316+
result = method(*request["params"])
317+
else:
318+
# matches error message received through python-bitcoinrpc
319+
error = {"message": "Params must be an array", "code": -32600}
320+
321+
results.append({
322+
"error": error,
323+
"id": request["id"],
324+
"result": result,
325+
})
326+
327+
return results

0 commit comments

Comments
 (0)