# Author: Simon-Pierre Boucher # Mail: contact@spboucher.ai """Bitcoin adapter — whale-only native BTC transfers via the esplora REST API (mempool.space and blockstream.info expose the same schema, giving free keyless failover). UTXO model: a transaction spends inputs and creates outputs — there is no single from/to. We record each OUTPUT >= the configured minimum as one canonical event: `to` = the output's address, `from` = the first input's previous-output address when the tx has exactly one distinct input address (else null — genuinely ambiguous, e.g. exchange sweep consolidations). Coinbase (miner reward) txs have no input address. Amounts are satoshis (8 decimals). Pace: ~10-minute blocks, txs fetched 25 per page — even a full block is ~150 paged calls in a 600s budget, well inside the 2 rps bucket. Reorg safety: 1-confirmation trail + stored block-hash check with 2-block rewind. """ import logging import time from .. import db from ..rpc import AllEndpointsDown, RestError, RestPool log = logging.getLogger("bitcoin") MAX_TX_PAGES = 400 # hard cap per block (~10k txs) — logged if ever hit class BitcoinIndexer: def __init__(self, chain, cfg, tokens, db_path): self.chain = chain self.cfg = cfg self.db_path = db_path self.conn = None self.pool = RestPool(cfg["rpcs"], timeout=30) self.native = cfg["native"] # {symbol: BTC, min, decimals: 8} # -- esplora helpers --------------------------------------------------- def head(self): return int(self.pool.get("/blocks/tip/height")) def hash_at(self, height): h = self.pool.get(f"/block-height/{height}") # text/plain hash return h.strip() if isinstance(h, str) else None def block_txs(self, block_hash): """All txs of a block, paged 25 at a time.""" txs, start = [], 0 for _ in range(MAX_TX_PAGES): page = self.pool.get(f"/block/{block_hash}/txs/{start}") if not page: break txs.extend(page) if len(page) < 25: break start += 25 else: log.warning("%s: block %s hit the %d-page cap — tail not scanned", self.chain, block_hash[:12], MAX_TX_PAGES) return txs # -- pipeline ---------------------------------------------------------- def process_block(self, height, block_hash): blk = self.pool.get(f"/block/{block_hash}") ts = blk.get("timestamp") min_sats = int(self.native["min"] * 10 ** self.native["decimals"]) rows = [] for tx in self.block_txs(block_hash): in_addrs = {v.get("prevout", {}).get("scriptpubkey_address") for v in tx.get("vin") or [] if v.get("prevout")} in_addrs.discard(None) frm = next(iter(in_addrs)) if len(in_addrs) == 1 else None for oi, out in enumerate(tx.get("vout") or []): val = out.get("value", 0) to = out.get("scriptpubkey_address") if val < min_sats or not to: continue # below threshold or OP_RETURN/nonstandard if to == frm: continue # change back to the sender — not a transfer rows.append({ "chain": self.chain, "block": height, "block_hash": block_hash, "tx_hash": tx["txid"], "log_index": oi, "timestamp": ts, "token": "native", "symbol": self.native["symbol"], "from": frm, "to": to, "amount": str(val), "decimals": self.native["decimals"], }) db.insert_transfers(self.conn, rows) return len(rows) def reorged(self, height, stored_hash): if not stored_hash: return False h = self.hash_at(height) return h is not None and h != stored_hash # -- main loop ----------------------------------------------------- def run(self, stop): self.conn = db.connect(self.db_path) cur = db.get_cursor(self.conn, self.chain) confirmations = self.cfg.get("confirmations", 1) while not stop.is_set(): try: head = self.head() safe = head - confirmations if cur is None: start = max(0, safe - self.cfg.get("start_offset", 3)) cur = (start, None, start) db.set_cursor(self.conn, self.chain, start, None) db.set_backfill(self.conn, self.chain, start) log.info("%s: fresh start at height %d (tip %d)", self.chain, start, head) height, stored_hash, _ = cur if self.reorged(height, stored_hash): to_block = height - 2 log.warning("%s: reorg at %d — rolling back to %d", self.chain, height, to_block) db.rollback(self.conn, self.chain, to_block) height, stored_hash = to_block, None while height < safe and not stop.is_set(): nxt = height + 1 bh = self.hash_at(nxt) if not bh: break n = self.process_block(nxt, bh) height, stored_hash = nxt, bh db.set_cursor(self.conn, self.chain, height, bh, head=head) log.info("%s: %d whale outputs in block %d (tip %d)", self.chain, n, height, head) cur = (height, stored_hash, None) db.save_rpc_health(self.conn, self.chain, self.pool.stats(), int(time.time())) stop.wait(60) # ~10-min blocks — checking every minute is plenty except (RestError, AllEndpointsDown, Exception) as e: log.error("%s: %s — retrying in 30s", self.chain, e) stop.wait(30)