# Copyright (c) 2026 Tiago Pinto # SPDX-License-Identifier: MIT # # Independent producer-side implementation of # draft-fassbender-scitt-time-anchor-03, written from the published # Internet-Draft alone. See README.md and CONSTRUCTION.md. """node_rpc.py: header-only access to a self-operated Bitcoin node. Implements FETCH-BLOCK-HEADER-BY-HEIGHT and CONFIRMATIONS-ON (profile Section 1.4) against a full node operated by the verifier/producer itself. The note on header sources in Section 3.1 requires the header to be placed on the validated most-work chain; "a producing implementation that operates its own Bitcoin node satisfies that requirement directly" (Appendix D.2). That is the case here: the node is the verifier's own, and the response of getblockhash/getblockheader refers to the active chain that node has validated. Only headers (80 bytes) and the tip height are requested. No transaction is retrieved (Section 3.1, "note on why no transaction is retrieved"). Credentials are read from ~/.bitcoin/bitcoin.conf (rpcconnect, rpcport, rpcuser, rpcpassword), the same file bitcoin-cli uses. Inline comments below are in Portuguese, the implementer's working language. """ import base64 import json import os import urllib.request CONF_PATH = os.path.expanduser('~/.bitcoin/bitcoin.conf') def _read_conf(path=CONF_PATH): conf = {} with open(path) as f: for line in f: line = line.strip() if not line or line.startswith('#') or '=' not in line: continue k, v = line.split('=', 1) conf[k.strip()] = v.strip() return conf class NodeUnavailable(Exception): """O nó não respondeu; FETCH-BLOCK-HEADER-BY-HEIGHT devolve NULL e o §3.1 trata isso como ramo não avaliado, nunca como inválido.""" class BitcoinRPC: def __init__(self, conf_path=CONF_PATH, timeout=15): conf = _read_conf(conf_path) host = conf.get('rpcconnect', '127.0.0.1') port = conf.get('rpcport', '8332') self.url = f'http://{host}:{port}/' creds = f"{conf['rpcuser']}:{conf['rpcpassword']}".encode() self.auth = 'Basic ' + base64.b64encode(creds).decode() self.timeout = timeout def call(self, method, *params): body = json.dumps({'jsonrpc': '1.0', 'id': 'interop', 'method': method, 'params': list(params)}).encode() req = urllib.request.Request( self.url, data=body, headers={'Authorization': self.auth, 'Content-Type': 'text/plain'}) try: with urllib.request.urlopen(req, timeout=self.timeout) as resp: r = json.loads(resp.read()) except Exception as e: raise NodeUnavailable(str(e)) if r.get('error'): raise NodeUnavailable(str(r['error'])) return r['result'] # ---- funções do perfil (§1.4) ---- def fetch_block_header_by_height(self, height: int) -> bytes | None: """FETCH-BLOCK-HEADER-BY-HEIGHT(H) -> 80 bytes ou None. getblockhash(H) devolve o hash do bloco à altura H na cadeia ativa validada por este nó; getblockheader(hash, false) devolve o header serializado (80 bytes).""" try: block_hash = self.call('getblockhash', height) header_hex = self.call('getblockheader', block_hash, False) except NodeUnavailable: return None header = bytes.fromhex(header_hex) if len(header) != 80: return None return header def confirmations_on(self, height: int) -> int | None: """CONFIRMATIONS-ON(H): blocos à altura H e acima na cadeia validada, contando o próprio bloco em H (§1.3).""" try: tip = self.call('getblockcount') except NodeUnavailable: return None if tip < height: return None return tip - height + 1 # ---- leitura de campos do header (formato em [NAKAMOTO]; layout # fixo de 80 bytes: versão 4B | prev 32B | merkle 32B | nTime 4B | # bits 4B | nonce 4B) ---- def header_merkle_root(header: bytes) -> bytes: """Raiz Merkle em ordem de bytes interna — a ordem em que OTS-REPLAY produz o digest (§3.1, passo 8: 'internal byte order').""" return header[36:68] def header_ntime(header: bytes) -> int: """Campo nTime (F8, informativo — Reference Wall-Clock Projection).""" return int.from_bytes(header[68:72], 'little')