/ test / functional / interface_ipc.py
interface_ipc.py
  1  #!/usr/bin/env python3
  2  # Copyright (c) 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  """Test the IPC (multiprocess) interface."""
  6  import asyncio
  7  from io import BytesIO
  8  from pathlib import Path
  9  import shutil
 10  from test_framework.messages import (CBlock, CTransaction, ser_uint256, COIN)
 11  from test_framework.test_framework import BitcoinTestFramework
 12  from test_framework.util import (
 13      assert_equal,
 14      assert_not_equal
 15  )
 16  from test_framework.wallet import MiniWallet
 17  
 18  # Test may be skipped and not have capnp installed
 19  try:
 20      import capnp  # type: ignore[import] # noqa: F401
 21  except ImportError:
 22      pass
 23  
 24  
 25  class IPCInterfaceTest(BitcoinTestFramework):
 26  
 27      def skip_test_if_missing_module(self):
 28          self.skip_if_no_ipc()
 29          self.skip_if_no_py_capnp()
 30  
 31      def load_capnp_modules(self):
 32          if capnp_bin := shutil.which("capnp"):
 33              # Add the system cap'nproto path so include/capnp/c++.capnp can be found.
 34              capnp_dir = Path(capnp_bin).resolve().parent.parent / "include"
 35          else:
 36              # If there is no system cap'nproto, the pycapnp module should have its own "bundled"
 37              # includes at this location. If pycapnp was installed with bundled capnp,
 38              # capnp/c++.capnp can be found here.
 39              capnp_dir = Path(capnp.__path__[0]).parent
 40          src_dir = Path(self.config['environment']['SRCDIR']) / "src"
 41          mp_dir = src_dir / "ipc" / "libmultiprocess" / "include"
 42          # List of import directories. Note: it is important for mp_dir to be
 43          # listed first, in case there are other libmultiprocess installations on
 44          # the system, to ensure that `import "/mp/proxy.capnp"` lines load the
 45          # same file as capnp.load() loads directly below, and there are not
 46          # "failed: Duplicate ID @0xcc316e3f71a040fb" errors.
 47          imports = [str(mp_dir), str(capnp_dir), str(src_dir)]
 48          return {
 49              "proxy": capnp.load(str(mp_dir / "mp" / "proxy.capnp"), imports=imports),
 50              "init": capnp.load(str(src_dir / "ipc" / "capnp" / "init.capnp"), imports=imports),
 51              "echo": capnp.load(str(src_dir / "ipc" / "capnp" / "echo.capnp"), imports=imports),
 52              "mining": capnp.load(str(src_dir / "ipc" / "capnp" / "mining.capnp"), imports=imports),
 53          }
 54  
 55      def set_test_params(self):
 56          self.num_nodes = 2
 57  
 58      def setup_nodes(self):
 59          self.extra_init = [{"ipcbind": True}, {}]
 60          super().setup_nodes()
 61          # Use this function to also load the capnp modules (we cannot use set_test_params for this,
 62          # as it is being called before knowing whether capnp is available).
 63          self.capnp_modules = self.load_capnp_modules()
 64  
 65      async def make_capnp_init_ctx(self):
 66          node = self.nodes[0]
 67          # Establish a connection, and create Init proxy object.
 68          connection = await capnp.AsyncIoStream.create_unix_connection(node.ipc_socket_path)
 69          client = capnp.TwoPartyClient(connection)
 70          init = client.bootstrap().cast_as(self.capnp_modules['init'].Init)
 71          # Create a remote thread on the server for the IPC calls to be executed in.
 72          threadmap = init.construct().threadMap
 73          thread = threadmap.makeThread("pythread").result
 74          ctx = self.capnp_modules['proxy'].Context()
 75          ctx.thread = thread
 76          # Return both.
 77          return ctx, init
 78  
 79      async def parse_and_deserialize_block(self, block_template, ctx):
 80          block_data = BytesIO((await block_template.result.getBlock(ctx)).result)
 81          block = CBlock()
 82          block.deserialize(block_data)
 83          return block
 84  
 85      async def parse_and_deserialize_coinbase_tx(self, block_template, ctx):
 86          coinbase_data = BytesIO((await block_template.result.getCoinbaseTx(ctx)).result)
 87          tx = CTransaction()
 88          tx.deserialize(coinbase_data)
 89          return tx
 90  
 91      def run_echo_test(self):
 92          self.log.info("Running echo test")
 93          async def async_routine():
 94              ctx, init = await self.make_capnp_init_ctx()
 95              self.log.debug("Create Echo proxy object")
 96              echo = init.makeEcho(ctx).result
 97              self.log.debug("Test a few invocations of echo")
 98              for s in ["hallo", "", "haha"]:
 99                  result_eval = (await echo.echo(ctx, s)).result
100                  assert_equal(s, result_eval)
101              self.log.debug("Destroy the Echo object")
102              echo.destroy(ctx)
103          asyncio.run(capnp.run(async_routine()))
104  
105      def run_mining_test(self):
106          self.log.info("Running mining test")
107          block_hash_size = 32
108          block_header_size = 80
109          timeout = 1000.0 # 1000 milliseconds
110          miniwallet = MiniWallet(self.nodes[0])
111  
112          async def async_routine():
113              ctx, init = await self.make_capnp_init_ctx()
114              self.log.debug("Create Mining proxy object")
115              mining = init.makeMining(ctx)
116              self.log.debug("Test simple inspectors")
117              assert (await mining.result.isTestChain(ctx))
118              assert (await mining.result.isInitialBlockDownload(ctx))
119              blockref = await mining.result.getTip(ctx)
120              assert blockref.hasResult
121              assert_equal(len(blockref.result.hash), block_hash_size)
122              current_block_height = self.nodes[0].getchaintips()[0]["height"]
123              assert blockref.result.height == current_block_height
124              self.log.debug("Mine a block")
125              wait = mining.result.waitTipChanged(ctx, blockref.result.hash, )
126              self.generate(self.nodes[0], 1)
127              newblockref = await wait
128              assert_equal(len(newblockref.result.hash), block_hash_size)
129              assert_equal(newblockref.result.height, current_block_height + 1)
130              self.log.debug("Wait for timeout")
131              wait = mining.result.waitTipChanged(ctx, newblockref.result.hash, timeout)
132              oldblockref = await wait
133              assert_equal(len(newblockref.result.hash), block_hash_size)
134              assert_equal(oldblockref.result.hash, newblockref.result.hash)
135              assert_equal(oldblockref.result.height, newblockref.result.height)
136  
137              self.log.debug("Create a template")
138              opts = self.capnp_modules['mining'].BlockCreateOptions()
139              opts.useMempool = True
140              opts.blockReservedWeight = 4000
141              opts.coinbaseOutputMaxAdditionalSigops = 0
142              template = mining.result.createNewBlock(opts)
143              self.log.debug("Test some inspectors of Template")
144              header = await template.result.getBlockHeader(ctx)
145              assert_equal(len(header.result), block_header_size)
146              block = await self.parse_and_deserialize_block(template, ctx)
147              assert_equal(ser_uint256(block.hashPrevBlock), newblockref.result.hash)
148              assert len(block.vtx) >= 1
149              txfees = await template.result.getTxFees(ctx)
150              assert_equal(len(txfees.result), 0)
151              txsigops = await template.result.getTxSigops(ctx)
152              assert_equal(len(txsigops.result), 0)
153              coinbase_data = BytesIO((await template.result.getCoinbaseTx(ctx)).result)
154              coinbase = CTransaction()
155              coinbase.deserialize(coinbase_data)
156              assert_equal(coinbase.vin[0].prevout.hash, 0)
157              self.log.debug("Wait for a new template")
158              waitoptions = self.capnp_modules['mining'].BlockWaitOptions()
159              waitoptions.timeout = timeout
160              waitoptions.feeThreshold = 1
161              waitnext = template.result.waitNext(ctx, waitoptions)
162              self.generate(self.nodes[0], 1)
163              template2 = await waitnext
164              block2 = await self.parse_and_deserialize_block(template2, ctx)
165              assert_equal(len(block2.vtx), 1)
166              self.log.debug("Wait for another, but time out")
167              template3 = await template2.result.waitNext(ctx, waitoptions)
168              assert_equal(template3.to_dict(), {})
169              self.log.debug("Wait for another, get one after increase in fees in the mempool")
170              waitnext = template2.result.waitNext(ctx, waitoptions)
171              miniwallet.send_self_transfer(fee_rate=10, from_node=self.nodes[0])
172              template4 = await waitnext
173              block3 = await self.parse_and_deserialize_block(template4, ctx)
174              assert_equal(len(block3.vtx), 2)
175              self.log.debug("Wait again, this should return the same template, since the fee threshold is zero")
176              waitoptions.feeThreshold = 0
177              template5 = await template4.result.waitNext(ctx, waitoptions)
178              block4 = await self.parse_and_deserialize_block(template5, ctx)
179              assert_equal(len(block4.vtx), 2)
180              waitoptions.feeThreshold = 1
181              self.log.debug("Wait for another, get one after increase in fees in the mempool")
182              waitnext = template5.result.waitNext(ctx, waitoptions)
183              miniwallet.send_self_transfer(fee_rate=10, from_node=self.nodes[0])
184              template6 = await waitnext
185              block4 = await self.parse_and_deserialize_block(template6, ctx)
186              assert_equal(len(block4.vtx), 3)
187              self.log.debug("Wait for another, but time out, since the fee threshold is set now")
188              template7 = await template6.result.waitNext(ctx, waitoptions)
189              assert_equal(template7.to_dict(), {})
190  
191              self.log.debug("interruptWait should abort the current wait")
192              wait_started = asyncio.Event()
193              async def wait_for_block():
194                  new_waitoptions = self.capnp_modules['mining'].BlockWaitOptions()
195                  new_waitoptions.timeout = waitoptions.timeout * 60 # 1 minute wait
196                  new_waitoptions.feeThreshold = 1
197                  wait_started.set()
198                  return await template6.result.waitNext(ctx, new_waitoptions)
199  
200              async def interrupt_wait():
201                  await wait_started.wait() # Wait for confirmation wait started
202                  await asyncio.sleep(0.1)  # Minimal buffer
203                  template6.result.interruptWait()
204                  miniwallet.send_self_transfer(fee_rate=10, from_node=self.nodes[0])
205  
206              wait_task = asyncio.create_task(wait_for_block())
207              interrupt_task = asyncio.create_task(interrupt_wait())
208  
209              result = await wait_task
210              await interrupt_task
211              assert_equal(result.to_dict(), {})
212  
213              current_block_height = self.nodes[0].getchaintips()[0]["height"]
214              check_opts = self.capnp_modules['mining'].BlockCheckOptions()
215              template = await mining.result.createNewBlock(opts)
216              block = await self.parse_and_deserialize_block(template, ctx)
217              coinbase = await self.parse_and_deserialize_coinbase_tx(template, ctx)
218              balance = miniwallet.get_balance()
219              coinbase.vout[0].scriptPubKey = miniwallet.get_output_script()
220              coinbase.vout[0].nValue = COIN
221              block.vtx[0] = coinbase
222              block.hashMerkleRoot = block.calc_merkle_root()
223              original_version = block.nVersion
224              self.log.debug("Submit a block with a bad version")
225              block.nVersion = 0
226              block.solve()
227              res = await mining.result.checkBlock(block.serialize(), check_opts)
228              assert_equal(res.result, False)
229              assert_equal(res.reason, "bad-version(0x00000000)")
230              res = await template.result.submitSolution(ctx, block.nVersion, block.nTime, block.nNonce, coinbase.serialize())
231              assert_equal(res.result, False)
232              self.log.debug("Submit a valid block")
233              block.nVersion = original_version
234              block.solve()
235  
236              self.log.debug("First call checkBlock()")
237              res = await mining.result.checkBlock(block.serialize(), check_opts)
238              assert_equal(res.result, True)
239  
240              # The remote template block will be mutated, capture the original:
241              remote_block_before = await self.parse_and_deserialize_block(template, ctx)
242  
243              self.log.debug("Submitted coinbase must include witness")
244              assert_not_equal(coinbase.serialize_without_witness().hex(), coinbase.serialize().hex())
245              res = await template.result.submitSolution(ctx, block.nVersion, block.nTime, block.nNonce, coinbase.serialize_without_witness())
246              assert_equal(res.result, False)
247  
248              self.log.debug("Even a rejected submitBlock() mutates the template's block")
249              # Can be used by clients to download and inspect the (rejected)
250              # reconstructed block.
251              remote_block_after = await self.parse_and_deserialize_block(template, ctx)
252              assert_not_equal(remote_block_before.serialize().hex(), remote_block_after.serialize().hex())
253  
254              self.log.debug("Submit again, with the witness")
255              res = await template.result.submitSolution(ctx, block.nVersion, block.nTime, block.nNonce, coinbase.serialize())
256              assert_equal(res.result, True)
257  
258              self.log.debug("Block should propagate")
259              # Check that the IPC node actually updates its own chain
260              assert_equal(self.nodes[0].getchaintips()[0]["height"], current_block_height + 1)
261              # Stalls if a regression causes submitBlock() to accept an invalid block:
262              self.sync_all()
263              # Check that the other node accepts the block
264              assert_equal(self.nodes[0].getchaintips()[0], self.nodes[1].getchaintips()[0])
265  
266              miniwallet.rescan_utxos()
267              assert_equal(miniwallet.get_balance(), balance + 1)
268              self.log.debug("Check block should fail now, since it is a duplicate")
269              res = await mining.result.checkBlock(block.serialize(), check_opts)
270              assert_equal(res.result, False)
271              assert_equal(res.reason, "inconclusive-not-best-prevblk")
272  
273              self.log.debug("Destroy template objects")
274              template.result.destroy(ctx)
275              template2.result.destroy(ctx)
276              template3.result.destroy(ctx)
277              template4.result.destroy(ctx)
278              template5.result.destroy(ctx)
279              template6.result.destroy(ctx)
280              template7.result.destroy(ctx)
281          asyncio.run(capnp.run(async_routine()))
282  
283      def run_test(self):
284          self.run_echo_test()
285          self.run_mining_test()
286  
287  if __name__ == '__main__':
288      IPCInterfaceTest(__file__).main()