From a5af5718160ba38d7c371a0c6c2f7e57c7e589a8 Mon Sep 17 00:00:00 2001 From: Yak Date: Wed, 23 Sep 2026 18:13:11 +0300 Subject: [PATCH 01/19] initial fixes --- cli.py | 2 + commands/config.py | 11 +- commands/control.py | 113 ++++ commands/control_channel.py | 221 +++++++ commands/ffs.py | 15 +- commands/followers/eth_follower.py | 152 ++++- commands/followers/sig.py | 39 +- commands/followers/xch_follower.py | 188 +++++- commands/listen.py | 11 +- commands/policy_runtime.py | 892 ++++++++++++++++++++++++++ commands/spend_policy.py | 997 +++++++++++++++++++++++++++++ rpc_server.py | 2 + tests/test_control_channel.py | 147 +++++ tests/test_spend_policy.py | 637 ++++++++++++++++++ 14 files changed, 3398 insertions(+), 29 deletions(-) create mode 100644 commands/control.py create mode 100644 commands/control_channel.py create mode 100644 commands/policy_runtime.py create mode 100644 commands/spend_policy.py create mode 100644 tests/test_control_channel.py create mode 100644 tests/test_spend_policy.py diff --git a/cli.py b/cli.py index c30b777..f44478a 100644 --- a/cli.py +++ b/cli.py @@ -4,6 +4,7 @@ from commands.listen import listen from commands.rekey import rekey from commands.ffs import ffs +from commands.control import control @click.group() def cli(): @@ -15,6 +16,7 @@ def cli(): cli.add_command(listen) cli.add_command(rekey) cli.add_command(ffs) +cli.add_command(control) if __name__ == '__main__': cli() diff --git a/commands/config.py b/commands/config.py index 54f0fac..479e3d4 100644 --- a/commands/config.py +++ b/commands/config.py @@ -1,5 +1,5 @@ import json -from typing import List +from typing import List, Tuple import click def load_config(): @@ -16,3 +16,12 @@ def get_config_item(path: List[str]) -> any: for p in path: current = current[p] return current + + +def load_listener_config(cfg: dict = None): + from commands.control_channel import load_control_config + from commands.spend_policy import BridgeRoutes + cfg = config if cfg is None else cfg + control = load_control_config(cfg) + routes = BridgeRoutes.from_config(cfg) + return routes, control diff --git a/commands/control.py b/commands/control.py new file mode 100644 index 0000000..4862cbf --- /dev/null +++ b/commands/control.py @@ -0,0 +1,113 @@ +import sys +import time + +import click +from nostr_sdk import Client, EventBuilder, EventId, Keys, Tag + +from commands.cli_wrappers import async_func +from commands.control_channel import ( + CLOCK_SKEW_SECONDS, + ControlEvent, + fetch_control_events, + load_control_config, + normalize_hex, +) + + +@click.group() +def control(): + pass + + +def _prompt_keys() -> Keys: + mnemonic = click.prompt("Nostr mnemonic", hide_input=True) + return Keys.from_mnemonic(mnemonic, None) + + +def _latest_valid_stop( + events: list[ControlEvent], + stop_authors: set[str], + now_unix: int, +) -> ControlEvent | None: + stop_set = {normalize_hex(a) for a in stop_authors} + stops = [ + ev + for ev in events + if ev.created_at <= now_unix + CLOCK_SKEW_SECONDS + and normalize_hex(ev.author) in stop_set + and ev.content == "stop" + ] + if not stops: + return None + return max(stops, key=lambda e: (e.created_at, normalize_hex(e.id))) + + +def _print_event(event) -> None: + click.echo(f"content: {event.content()}") + click.echo(f"author: {event.author().to_hex()}") + click.echo(f"tags: {[t.as_vec() for t in event.tags()]}") + click.echo(f"created_at: {int(event.created_at().as_secs())}") + + +async def _publish_to_relays(relays: list[str], event) -> None: + """Publish the same signed event to each relay independently.""" + for relay in relays: + client = Client(None) + try: + await client.add_relay(relay) + await client.connect() + event_id = await client.send_event(event) + click.echo(f"ACCEPTED {relay} {event_id.to_hex()}") + except Exception as e: + click.echo(f"REJECTED {relay} {e}") + finally: + try: + await client.disconnect() + except Exception: + pass + + +@control.command() +@async_func +async def stop(): + cfg = load_control_config() + keys = _prompt_keys() + pubkey = normalize_hex(keys.public_key().to_hex()) + if pubkey not in cfg.stop_authors: + click.echo( + f"Refused: pubkey {pubkey} is not in control_stop_authors" + ) + sys.exit(1) + + event = EventBuilder.text_note("stop", []).to_event(keys) + _print_event(event) + await _publish_to_relays(cfg.relays, event) + + +@control.command("start") +@async_func +async def start_cmd(): + cfg = load_control_config() + keys = _prompt_keys() + pubkey = normalize_hex(keys.public_key().to_hex()) + if pubkey not in cfg.start_authors: + click.echo( + f"Refused: pubkey {pubkey} is not in control_start_authors" + ) + sys.exit(1) + + events = await fetch_control_events( + cfg.relays, + cfg.stop_authors, + cfg.start_authors, + ) + latest_stop = _latest_valid_stop( + events, cfg.stop_authors, int(time.time()) + ) + tags = [] + if latest_stop is not None: + tags = [Tag.event(EventId.from_hex(normalize_hex(latest_stop.id)))] + + event = EventBuilder.text_note("start", tags).to_event(keys) + _print_event(event) + await _publish_to_relays(cfg.relays, event) diff --git a/commands/control_channel.py b/commands/control_channel.py new file mode 100644 index 0000000..9c6cb03 --- /dev/null +++ b/commands/control_channel.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from typing import Iterable, List, Literal, Optional, Sequence, Set + +from nostr_sdk import Client, Filter, Kind, PublicKey + +from commands.config import config as _loaded_config + +ControlCommand = Literal["start", "stop"] + +# Starts may be up to this many seconds behind a stop's created_at. +# Events more than this far ahead of `now` are ignored. +CLOCK_SKEW_SECONDS = 120 + + +@dataclass(frozen=True) +class ControlEvent: + id: str + author: str + content: str + created_at: int + e_tag_ids: tuple[str, ...] + + +@dataclass(frozen=True) +class ControlConfig: + stop_authors: Set[str] + start_authors: Set[str] + relays: List[str] + + +def normalize_hex(value: str) -> str: + """Lowercase hex without a 0x prefix.""" + return value.lower().removeprefix("0x") + + +def _parse_pubkey_list(raw: object, field_name: str) -> Set[str]: + if not isinstance(raw, list): + raise ValueError(f"{field_name} must be a list of hex pubkeys") + out: Set[str] = set() + for item in raw: + if not isinstance(item, str): + raise ValueError(f"{field_name} entries must be hex strings") + pk = normalize_hex(item) + if len(pk) != 64 or any(c not in "0123456789abcdef" for c in pk): + raise ValueError(f"invalid pubkey in {field_name}: {item!r}") + out.add(pk) + return out + + +def load_control_config(config: Optional[dict] = None) -> ControlConfig: + """ + Load nostr control-gate settings. + + Requires non-empty, non-overlapping control_stop_authors and + control_start_authors. control_relays is optional and defaults to nostr.relays. + """ + cfg = _loaded_config if config is None else config + try: + nostr = cfg["nostr"] + except (KeyError, TypeError) as e: + raise ValueError("config missing nostr section") from e + + try: + stop_authors = _parse_pubkey_list( + nostr["control_stop_authors"], "nostr.control_stop_authors" + ) + start_authors = _parse_pubkey_list( + nostr["control_start_authors"], "nostr.control_start_authors" + ) + except KeyError as e: + raise ValueError(f"config missing {e.args[0]}") from e + + if not stop_authors: + raise ValueError("nostr.control_stop_authors must not be empty") + if not start_authors: + raise ValueError("nostr.control_start_authors must not be empty") + + overlap = stop_authors & start_authors + if overlap: + raise ValueError( + "nostr.control_stop_authors and nostr.control_start_authors overlap: " + + ", ".join(sorted(overlap)) + ) + + relays = nostr.get("control_relays") or nostr.get("relays") + if not relays: + raise ValueError( + "nostr.control_relays is unset and nostr.relays is missing or empty" + ) + if not isinstance(relays, list) or not all(isinstance(r, str) for r in relays): + raise ValueError("nostr control relays must be a list of strings") + + return ControlConfig( + stop_authors=stop_authors, + start_authors=start_authors, + relays=list(relays), + ) + + +def decide_control( + events: Sequence[ControlEvent], + now_unix: int, + stop_authors: Set[str], + start_authors: Set[str], +) -> ControlCommand: + """ + Pure fail-closed control decision. + + No I/O. Callers inject `now_unix`. Returns \"stop\" when nothing valid has + been posted, when the latest stop is uncleared, or when inputs are empty. + """ + stop_set = {normalize_hex(a) for a in stop_authors} + start_set = {normalize_hex(a) for a in start_authors} + + stops: List[ControlEvent] = [] + starts: List[ControlEvent] = [] + for ev in events: + if ev.created_at > now_unix + CLOCK_SKEW_SECONDS: + continue + author = normalize_hex(ev.author) + if author in stop_set and ev.content == "stop": + stops.append(ev) + elif author in start_set and ev.content == "start": + starts.append(ev) + + if not stops: + return "start" if starts else "stop" + + latest_stop = max(stops, key=lambda e: (e.created_at, normalize_hex(e.id))) + latest_stop_id = normalize_hex(latest_stop.id) + + for start in starts: + tagged = {normalize_hex(eid) for eid in start.e_tag_ids} + if latest_stop_id not in tagged: + continue + if start.created_at >= latest_stop.created_at - CLOCK_SKEW_SECONDS: + return "start" + + return "stop" + + +def control_event_from_nostr(event) -> ControlEvent: + """Map a nostr_sdk Event into the pure ControlEvent shape.""" + e_tag_ids = tuple( + normalize_hex(eid.to_hex()) for eid in event.event_ids() + ) + return ControlEvent( + id=normalize_hex(event.id().to_hex()), + author=normalize_hex(event.author().to_hex()), + content=event.content(), + created_at=int(event.created_at().as_secs()), + e_tag_ids=e_tag_ids, + ) + + +def _author_public_keys(authors: Iterable[str]) -> List[PublicKey]: + return [PublicKey.from_hex(normalize_hex(a)) for a in authors] + + +async def _fetch_events_from_relay( + relay: str, + filt: Filter, + timeout: timedelta, +) -> List: + """Query a single relay; raises on failure.""" + client = Client(None) + await client.add_relay(relay) + await client.connect() + try: + return await client.get_events_of([filt], timeout) + finally: + try: + await client.disconnect() + except Exception: + pass + + +async def fetch_control_events( + relays: Sequence[str], + stop_authors: Set[str], + start_authors: Set[str], + *, + timeout_seconds: float = 10.0, +) -> List[ControlEvent]: + """ + Fetch kind-1 notes from every control relay and union by event id. + + Each relay is queried independently so one withholding relay cannot hide a + stop seen on another. If every relay errors, raises RuntimeError (caller + must treat that as stop). Partial success returns the union of what came + back. No caching. + """ + if not relays: + raise RuntimeError("no control relays configured") + + authors = _author_public_keys(set(stop_authors) | set(start_authors)) + filt = Filter().kinds([Kind(1)]).authors(authors) + timeout = timedelta(seconds=timeout_seconds) + + by_id: dict[str, ControlEvent] = {} + errors: List[str] = [] + successes = 0 + + for relay in relays: + try: + raw_events = await _fetch_events_from_relay(relay, filt, timeout) + successes += 1 + for raw in raw_events: + ev = control_event_from_nostr(raw) + by_id[ev.id] = ev + except Exception as e: + errors.append(f"{relay}: {e}") + + if successes == 0: + detail = "; ".join(errors) if errors else "unknown errors" + raise RuntimeError(f"all control relays failed: {detail}") + + return list(by_id.values()) diff --git a/commands/ffs.py b/commands/ffs.py index ac5c755..86b1a68 100644 --- a/commands/ffs.py +++ b/commands/ffs.py @@ -17,6 +17,7 @@ from drivers.portal import * from commands.deployment import print_spend_instructions from commands.rekey import get_latest_portal_coin_data +from commands.spend_policy import evm_address_from_bytes32 from datetime import timedelta @@ -190,8 +191,18 @@ async def partial_relay_message( portal_puzzle_hash = portal_puzzle.get_tree_hash() source = bytes.fromhex(msg['source']) - while source.startswith(b'\x00'): - source = source[1:] + if source_chain in ("eth", "bse"): + if len(source) > 32: + dropped = source[:-32] + if dropped != b"\x00" * len(dropped): + print("Source longer than 32 bytes with non-zero prefix; skipping relay") + return + source = source[-32:] + try: + source = evm_address_from_bytes32(source) + except ValueError: + print("Invalid EVM source encoding; skipping relay") + return portal_msg = PortalMessage( nonce=bytes.fromhex(nonce), validator_sig_switches=validator_sig_switches, diff --git a/commands/followers/eth_follower.py b/commands/followers/eth_follower.py index e0ba85f..7d0c613 100644 --- a/commands/followers/eth_follower.py +++ b/commands/followers/eth_follower.py @@ -1,15 +1,25 @@ -from typing import Any, Callable +from typing import Any, Callable, Optional from commands.models import * from commands.config import get_config_item +from commands.cli_wrappers import get_node_client from sqlalchemy import and_ from eth_account.messages import encode_typed_data from commands.followers.sig import encode_signature +from commands.control_channel import ControlConfig +from commands.spend_policy import REJECTED_SIG, BridgeRoutes, PolicyResult +from commands.policy_runtime import ( + apply_policy_result, + check_portal_impl, + control_allows_start, + evaluate_chia_message_policy, + evaluate_evm_message_policy, + load_block_spends, +) from web3 import AsyncWeb3 from web3.providers.async_rpc import AsyncHTTPProvider import aiohttp import asyncio import logging -import asyncio import json import sys @@ -37,8 +47,10 @@ class EthereumFollower: last_safe_height: int = 0 l1_block_contract_address: str l1_block_contract: any = None + routes: BridgeRoutes + control_config: ControlConfig - def __init__(self, chain: str, is_optimism: bool, send_sig: any): + def __init__(self, chain: str, is_optimism: bool, send_sig: any, routes: BridgeRoutes, control_config: ControlConfig): self.chain = chain self.chain_id = chain.encode() self.sign_min_height = get_config_item([self.chain, 'sign_min_height']) @@ -50,6 +62,8 @@ def __init__(self, chain: str, is_optimism: bool, send_sig: any): self.l1_block_contract_address = get_config_item([self.chain, 'l1_block_contract_address']) self.send_sig = send_sig + self.routes = routes + self.control_config = control_config def getDb(self): @@ -148,12 +162,21 @@ async def messageListener(self): while True: try: + if not await control_allows_start(self.control_config): + logging.info(f"{self.chain_id.decode()} message listener: control stop; sleeping 60s") + await asyncio.sleep(60) + continue + next_message_event = await self.getEventByIntNonce(web3, contract, latest_synced_nonce_int + 1, last_synced_height - 1) if next_message_event is None: logging.info(f"{self.chain_id.decode()} message listener: all on-chain messages synced; listening for new messages.") while next_message_event is None: + if not await control_allows_start(self.control_config): + logging.info(f"{self.chain_id.decode()} message listener: control stop; sleeping 60s") + await asyncio.sleep(60) + continue await asyncio.sleep(30) next_message_event = await self.getEventByIntNonce(web3, contract, latest_synced_nonce_int + 1, last_synced_height - 1) @@ -207,6 +230,11 @@ async def messageListener(self): l1_block_number = await self.l1_block_contract.functions.number().call() logging.info(f"{self.chain_id.decode()} message listener: Current L1 block number is {l1_block_number}") + if not await control_allows_start(self.control_config): + logging.info(f"{self.chain_id.decode()} message listener: control stop; sleeping 60s") + await asyncio.sleep(60) + continue + next_message_event_copy = await self.getEventByIntNonce(web3, contract, latest_synced_nonce_int + 1, last_synced_height - 1) if next_message_event_copy is None: logging.info(f"{self.chain_id.decode()} message listener: could not get message event again; assuming reorg and retrying...") @@ -222,6 +250,34 @@ async def messageListener(self): last_synced_height -= self.max_query_block_limit self.last_safe_height -= 10 * self.max_query_block_limit continue + + tx_hash = next_message_event['transactionHash'].hex() + if not tx_hash.startswith("0x"): + tx_hash = "0x" + tx_hash + + route = self.routes.for_chain(self.chain_id) + if route is None: + logging.error(f"{self.chain_id.decode()} message listener: no route configured") + sys.exit(1) + + impl_result = await check_portal_impl(web3, route, event_block_number) + if impl_result.kind == "hold": + logging.info(f"{self.chain_id.decode()} message listener: portal impl hold; leaving unsigned") + elif impl_result.kind == "retry": + logging.info(f"{self.chain_id.decode()} message listener: portal impl retry; leaving unsigned") + else: + policy_result = await evaluate_evm_message_policy( + web3=web3, + routes=self.routes, + source_chain=next_message.source_chain, + source=next_message.source, + destination=next_message.destination, + destination_chain=next_message.destination_chain, + contents=split_message_contents(next_message.contents), + block_number=event_block_number, + tx_hash=tx_hash, + ) + apply_policy_result(next_message, policy_result) logging.info(f"{self.chain_id.decode()} message listener: Adding message #{self.chain_id.decode()}-{next_message.nonce.hex()}") db.add(next_message) @@ -234,6 +290,32 @@ async def messageListener(self): sys.exit(1) async def signMessage(self, db, web3: AsyncWeb3, message: Message): + assert message.destination_chain == self.chain_id + + if message.source_chain != b"xch": + message.sig = REJECTED_SIG + db.commit() + return + + policy_ok = await self._recheck_chia_source_policy(message) + if not policy_ok: + db.commit() + return + + route = self.routes.for_chain(self.chain_id) + if route is None: + return + + impl_result = await check_portal_impl(web3, route, await web3.eth.block_number) + if impl_result.kind != "accept": + apply_policy_result(message, impl_result) + db.commit() + return + + if not await control_allows_start(self.control_config): + logging.info(f"{self.chain} Signer: control gate stop; not signing") + return + domain = { 'name': 'warp.green Portal', 'version': '1', @@ -281,25 +363,85 @@ async def signMessage(self, db, web3: AsyncWeb3, message: Message): logging.info(f"{self.chain} Signer: {message.source_chain.decode()}-{message.nonce.hex()}: Raw signature: {sig.hex()}") - message.sig = encode_signature( + encoded = encode_signature( message.source_chain, message.destination_chain, message.nonce, None, sig - ).encode() + ) + + if not await control_allows_start(self.control_config): + logging.info(f"{self.chain} Signer: control gate stop before write; discarding signature") + return + + message.sig = encoded.encode() db.commit() logging.info(f"{self.chain} Signer: {message.source_chain.decode()}-{message.nonce.hex()}: Signature: {message.sig.decode()}") self.send_sig(message.sig.decode()) + async def _recheck_chia_source_policy(self, message: Message) -> bool: + node = await get_node_client("xch", log=False) + if node is None: + return apply_policy_result(message, PolicyResult("retry", "chia node unavailable")) + try: + coin_record = await node.get_coin_record_by_name(message.nonce) + if coin_record is None: + return apply_policy_result(message, PolicyResult("retry", "bridging coin not found")) + parent_id = coin_record.coin.parent_coin_info + parent_record = await node.get_coin_record_by_name(parent_id) + if parent_record is None or parent_record.spent_block_index == 0: + return apply_policy_result(message, PolicyResult("retry", "parent spend not found")) + parent_spend = await node.get_puzzle_and_solution(parent_id, parent_record.spent_block_index) + if parent_spend is None: + return apply_policy_result(message, PolicyResult("retry", "parent spend missing")) + block_spends = await load_block_spends(node, parent_record.spent_block_index) + + def web3_for_chain(chain: bytes) -> Optional[AsyncWeb3]: + key = chain.decode() if isinstance(chain, (bytes, bytearray)) else chain + if key not in ("eth", "bse"): + return None + headers = { + 'User-Agent': 'requests/1.0.0', + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + return AsyncWeb3(AsyncHTTPProvider( + get_config_item([key, 'rpc_url']), + request_kwargs={'headers': headers}, + )) + + result = await evaluate_chia_message_policy( + bridging_coin=coin_record.coin, + parent_spend=parent_spend, + routes=self.routes, + node=node, + block_spends=block_spends, + web3_for_chain=web3_for_chain, + evm_block_number=None, + ) + return apply_policy_result(message, result) + except Exception: + logging.error("Chia policy recheck failed", exc_info=True) + return apply_policy_result(message, PolicyResult("retry", "chia policy recheck error")) + finally: + node.close() + await node.await_closed() + + async def messageSigner(self): db = self.getDb() web3 = self.getWeb3() while True: try: + if not await control_allows_start(self.control_config): + logging.info(f"{self.chain_id.decode()} message signer: control stop; sleeping 60s") + await asyncio.sleep(60) + continue + messages = [] messages = db.query(Message).filter(and_( Message.destination_chain == self.chain_id, diff --git a/commands/followers/sig.py b/commands/followers/sig.py index 424dd2d..49331b9 100644 --- a/commands/followers/sig.py +++ b/commands/followers/sig.py @@ -1,12 +1,14 @@ from chia.util.bech32m import bech32_encode, convertbits, bech32_decode from typing import Tuple, List from commands.config import get_config_item +from commands.control_channel import ControlConfig +from commands.models import Message, setup_database +from commands.policy_runtime import control_allows_start from nostr_sdk import Keys, Client, NostrSigner, EventBuilder, Tag, Filter, SingleLetterTag, Alphabet -from datetime import timedelta +from sqlalchemy import and_ import logging import time import queue -import threading import asyncio def encode_signature( @@ -55,11 +57,33 @@ class MessageBroadcaster: relays: List[str] my_private_key: Keys message_queue: queue.Queue + control_config: ControlConfig - def __init__(self): + def __init__(self, control_config: ControlConfig): self.relays = get_config_item(["nostr", "relays"]) self.my_private_key = Keys.from_mnemonic(get_config_item(["nostr", "my_mnemonic"]), None) self.message_queue = queue.Queue() + self.control_config = control_config + + + def _clear_message_sig(self, sig: str): + try: + origin_chain, destination_chain, nonce, _, __ = decode_signature(sig) + db = setup_database() + msg = db.query(Message).filter(and_( + Message.source_chain == origin_chain, + Message.nonce == nonce, + Message.destination_chain == destination_chain, + )).first() + if msg is not None and msg.sig == sig.encode(): + msg.sig = b'' + db.commit() + logging.info( + f"Nostr: discarded signature for {origin_chain.decode()}-{nonce.hex()} after control stop" + ) + db.close() + except Exception: + logging.error("Nostr: failed to clear message sig after control stop", exc_info=True) async def send_signature( @@ -73,6 +97,11 @@ async def send_signature( except: open("messages.txt", "w").write(sig + "\n") + if not await control_allows_start(self.control_config): + logging.info("Nostr: control gate stop; not broadcasting and clearing sig") + self._clear_message_sig(sig) + return + try: [route_data, coin_data, sig_data] = sig.split("-") @@ -98,6 +127,10 @@ async def send_signature( await client.disconnect() except: + if not await control_allows_start(self.control_config): + logging.info("Nostr: control gate stop during retry; clearing sig") + self._clear_message_sig(sig) + return if retries < 3: retries += 1 logging.error("Nostr: failed to send signature to relays; retrying in 3s...", exc_info=True) diff --git a/commands/followers/xch_follower.py b/commands/followers/xch_follower.py index eac874a..0720627 100644 --- a/commands/followers/xch_follower.py +++ b/commands/followers/xch_follower.py @@ -7,15 +7,26 @@ from chia.types.condition_opcodes import ConditionOpcode from chia.types.blockchain_format.coin import Coin from chia.types.blockchain_format.program import Program -from chia.consensus.block_record import BlockRecord from chia.types.coin_spend import CoinSpend from chia.types.coin_record import CoinRecord from commands.followers.sig import encode_signature, decode_signature +from commands.control_channel import ControlConfig +from commands.spend_policy import REJECTED_SIG, BridgeRoutes, evm_address_from_bytes32 +from commands.policy_runtime import ( + apply_policy_result, + control_allows_start, + evaluate_chia_message_policy, + evaluate_evm_message_policy, + load_block_spends, +) from drivers.portal import BRIDGING_PUZZLE_HASH -from typing import Tuple +from typing import Optional +from web3 import AsyncWeb3 +from web3.providers.async_rpc import AsyncHTTPProvider import logging import asyncio import sys +import json from sqlalchemy import and_ from chia_rs import AugSchemeMPL, PrivateKey @@ -32,8 +43,10 @@ class ChiaFollower: syncing: bool send_sig: any consecutive_portal_rollbacks: int + routes: BridgeRoutes + control_config: ControlConfig - def __init__(self, chain: str, send_sig: any): + def __init__(self, chain: str, send_sig: any, routes: BridgeRoutes, control_config: ControlConfig): self.chain = chain self.chain_id = chain.encode() self.private_key = PrivateKey.from_bytes(bytes.fromhex(get_config_item([chain, "my_hot_private_key"]))) @@ -44,6 +57,9 @@ def __init__(self, chain: str, send_sig: any): self.syncing = True self.send_sig = send_sig self.consecutive_portal_rollbacks = 0 + self.routes = routes + self.control_config = control_config + self._evm_web3 = {} async def getUnspentPortalId(self) -> bytes: @@ -69,6 +85,23 @@ async def getNode(self, log: bool = True): return await get_node_client(self.chain, log) + def getEvmWeb3(self, chain: bytes) -> Optional[AsyncWeb3]: + key = chain.decode() if isinstance(chain, (bytes, bytearray)) else chain + if key not in ("eth", "bse"): + return None + if key not in self._evm_web3: + headers = { + 'User-Agent': 'requests/1.0.0', + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + self._evm_web3[key] = AsyncWeb3(AsyncHTTPProvider( + get_config_item([key, 'rpc_url']), + request_kwargs={'headers': headers}, + )) + return self._evm_web3[key] + + # to save space, used_chains_and_nonces has a special format: # [([chain] [a] [nonce1] [nonce2]), ...] # the above would say that [chain] used all consecutive nonces from 1 to [a] (i.e., 1, 2, 3, ..., a) AND nonce1 AND nonce2 @@ -144,9 +177,39 @@ async def signMessage(self, db, message: Message): logging.info(f"{self.chain}: Signing message {message.source_chain.decode()}-0x{message.nonce.hex()}") assert message.destination_chain == self.chain_id + + if message.source_chain not in (b"eth", b"bse"): + message.sig = REJECTED_SIG + db.commit() + return + + policy_ok = await self._recheck_evm_source_policy(db, message) + if not policy_ok: + db.commit() + return + + if not await control_allows_start(self.control_config): + logging.info(f"{self.chain} Signer: control gate stop; not signing") + return + source = message.source - while source.startswith(b'\x00'): - source = source[1:] + if len(source) > 32: + dropped = source[:-32] + if dropped != b"\x00" * len(dropped): + logging.error( + f"{self.chain} Signer: {message.source_chain.decode()}-{message.nonce.hex()}: " + f"source longer than 32 bytes with non-zero prefix; not signing" + ) + return + source = source[-32:] + try: + source = evm_address_from_bytes32(source) + except ValueError: + logging.error( + f"{self.chain} Signer: {message.source_chain.decode()}-{message.nonce.hex()}: " + f"invalid EVM source encoding; not signing" + ) + return # source_chain nonce source destination message msg_bytes: bytes = Program(Program.to([ message.source_chain, @@ -179,33 +242,94 @@ async def signMessage(self, db, message: Message): sig = AugSchemeMPL.sign(self.private_key, msg_bytes) logging.info(f"{self.chain} Signer: {message.source_chain.decode()}-{message.nonce.hex()}: Raw signature: {bytes(sig).hex()}") - message.sig = encode_signature( + encoded = encode_signature( message.source_chain, message.destination_chain, message.nonce, portal_id, bytes(sig) - ).encode() + ) + + if not await control_allows_start(self.control_config): + logging.info(f"{self.chain} Signer: control gate stop before write; discarding signature") + return + + message.sig = encoded.encode() db.commit() logging.info(f"{self.chain} Signer: {message.source_chain.decode()}-{message.nonce.hex()}: Signature: {message.sig.decode()}") self.send_sig(message.sig.decode()) + async def _recheck_evm_source_policy(self, db, message: Message) -> bool: + from commands.spend_policy import PolicyResult + web3 = self.getEvmWeb3(message.source_chain) + if web3 is None: + return apply_policy_result(message, PolicyResult("retry", "no web3 for source chain")) + + route = self.routes.for_chain(message.source_chain) + if route is None: + return apply_policy_result(message, PolicyResult("reject", "unknown source chain")) + + portal_contract_abi = json.loads(open("artifacts/contracts/Portal.sol/Portal.json", "r").read())["abi"] + portal_hex = route.portal_address.hex() + if len(route.portal_address) > 20: + portal_hex = route.portal_address[-20:].hex() + contract = web3.eth.contract( + address=AsyncWeb3.to_checksum_address("0x" + portal_hex), + abi=portal_contract_abi, + ) + try: + nonce_hex = "0x" + message.nonce.hex() + logs = await contract.events.MessageSent().get_logs( + fromBlock=max(0, message.block_number - 5), + toBlock=message.block_number + 5, + argument_filters={"nonce": nonce_hex}, + ) + logs = list(logs) + if not logs: + return apply_policy_result(message, PolicyResult("retry", "MessageSent not found for recheck")) + event = logs[0] + tx_hash = event["transactionHash"].hex() + if not tx_hash.startswith("0x"): + tx_hash = "0x" + tx_hash + result = await evaluate_evm_message_policy( + web3=web3, + routes=self.routes, + source_chain=message.source_chain, + source=message.source, + destination=message.destination, + destination_chain=message.destination_chain, + contents=split_message_contents(message.contents), + block_number=int(event["blockNumber"]), + tx_hash=tx_hash, + ) + return apply_policy_result(message, result) + except Exception: + logging.error("EVM policy recheck failed", exc_info=True) + return apply_policy_result(message, PolicyResult("retry", "evm policy recheck error")) + + async def messageSigner(self): db = self.getDb() - while not self.syncing: - logging.info(f"{self.chain_id.decode} message signer: Waiting to be synced before signing messages...") + while self.syncing: + logging.info(f"{self.chain_id.decode()} message signer: Waiting to be synced before signing messages...") await asyncio.sleep(10) + await self.getUnspentPortalId() + while True: + if not await control_allows_start(self.control_config): + logging.info(f"{self.chain_id.decode()} message signer: control stop; sleeping 60s") + await asyncio.sleep(60) + continue + messages = [] try: messages = db.query(Message).filter(and_( Message.destination_chain == self.chain_id, Message.sig == b'', - Message.sig != SIG_USED_VALUE, )).all() except Exception as e: logging.error(f"Error querying messages: {e}", exc_info=True) @@ -366,7 +490,9 @@ async def syncPortal( if not self.syncing: messages = db.query(Message).filter(and_( Message.destination_chain == self.chain_id, - Message.sig != SIG_USED_VALUE + Message.sig != SIG_USED_VALUE, + Message.sig != REJECTED_SIG, + Message.sig != b'', )).all() for message in messages: try: @@ -422,6 +548,10 @@ async def portalFollower(self): while True: try: + if not await control_allows_start(self.control_config): + logging.info(f"{self.chain_id.decode()} portal follower: control stop; sleeping 60s") + await asyncio.sleep(60) + continue last_synced_portal = await self.syncPortal(db, node, last_synced_portal) db.commit() except: @@ -438,7 +568,10 @@ async def createMessageFromMemo( nonce: bytes, source: bytes, created_height: int, - memo: Program + memo: Program, + bridging_coin: Coin, + parent_spend: CoinSpend, + node: FullNodeRpcClient, ): try: destination_chain = memo.first().as_atom() @@ -479,9 +612,22 @@ async def createMessageFromMemo( block_number=created_height, sig=b'' ) + + block_spends = await load_block_spends(node, created_height) + result = await evaluate_chia_message_policy( + bridging_coin=bridging_coin, + parent_spend=parent_spend, + routes=self.routes, + node=node, + block_spends=block_spends, + web3_for_chain=self.getEvmWeb3, + evm_block_number=None, + ) + apply_policy_result(msg, result) + db.add(msg) db.commit() - logging.info(f"Message {self.chain}-{nonce.hex()} added to db.") + logging.info(f"Message {self.chain}-{nonce.hex()} added to db (policy={result.kind}).") async def processCoinRecord(self, db: any, node: FullNodeRpcClient, coin_record: CoinRecord): @@ -513,7 +659,10 @@ async def processCoinRecord(self, db: any, node: FullNodeRpcClient, coin_record: coin.name(), parent_record.coin.puzzle_hash, parent_record.spent_block_index, - memo + memo, + coin, + parent_spend, + node, ) except Exception as e: logging.error(f"Coin {self.chain}-{coin.name().hex()} - error when parsing memo to create message; skipping even though we shouldn't") @@ -537,6 +686,11 @@ async def messageListener(self): while True: try: + if not await control_allows_start(self.control_config): + logging.info(f"{self.chain_id.decode()} message listener: control stop; sleeping 60s") + await asyncio.sleep(60) + continue + last_synced_height = db.query(Message.block_number).filter( Message.source_chain == self.chain_id ).order_by(Message.block_number.desc()).first() @@ -588,6 +742,12 @@ async def messageListener(self): while earliest_unprocessed_coin_record.confirmed_block_index + self.sign_min_height > (await self.get_current_height(node)): await asyncio.sleep(10) + if not await control_allows_start(self.control_config): + logging.info(f"{self.chain_id.decode()} message listener: control stop; sleeping 60s") + await asyncio.sleep(60) + reorg = True + break + coin_record_copy = await self.get_coin_record_by_name(node, earliest_unprocessed_coin_record.coin.name(), 3) if coin_record_copy is None or coin_record_copy.confirmed_block_index != earliest_unprocessed_coin_record.confirmed_block_index: logging.info(f"{self.chain} message follower: Coin {self.chain}-0x{earliest_unprocessed_coin_record.coin.name().hex()}: possible reorg; re-processing") diff --git a/commands/listen.py b/commands/listen.py index ac5fdaa..38fab1e 100644 --- a/commands/listen.py +++ b/commands/listen.py @@ -1,5 +1,6 @@ import click from commands.models import * +from commands.config import load_listener_config import time import logging from commands.followers.eth_follower import EthereumFollower @@ -12,14 +13,16 @@ @click.option('--log-startup-connection-errors', is_flag=True, default=False) @click.command() def listen(log_startup_connection_errors: bool): - msg_broadcaster = MessageBroadcaster() + routes, control_config = load_listener_config() + + msg_broadcaster = MessageBroadcaster(control_config) def send_sig(sig: str): msg_broadcaster.add_signature(sig) - eth_follower = EthereumFollower("eth", False, send_sig) - bse_follower = EthereumFollower("bse", True, send_sig) - xch_follower = ChiaFollower("xch", send_sig) + eth_follower = EthereumFollower("eth", False, send_sig, routes, control_config) + bse_follower = EthereumFollower("bse", True, send_sig, routes, control_config) + xch_follower = ChiaFollower("xch", send_sig, routes, control_config) asyncio.run(xch_follower.wait_for_node(log_startup_connection_errors)) asyncio.run(eth_follower.wait_for_node(log_startup_connection_errors)) diff --git a/commands/policy_runtime.py b/commands/policy_runtime.py new file mode 100644 index 0000000..90e33d1 --- /dev/null +++ b/commands/policy_runtime.py @@ -0,0 +1,892 @@ +from __future__ import annotations + +import logging +import time +from typing import List, Optional, Sequence, Tuple + +from chia.types.blockchain_format.coin import Coin +from chia.types.blockchain_format.program import Program +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.types.coin_spend import CoinSpend +from chia.wallet.cat_wallet.cat_utils import CAT_MOD, construct_cat_puzzle +from chia.wallet.cat_wallet.cat_wallet import CAT_MOD_HASH +from web3 import AsyncWeb3, Web3 + +from commands.control_channel import ( + ControlConfig, + decide_control, + fetch_control_events, +) +from commands.spend_policy import ( + REJECTED_SIG, + BridgeRoutes, + PolicyResult, + compare_portal_impl, + evm_address_from_bytes32, + left_pad_32, + validate_chia_cat_wrap, + validate_chia_erc20_unwrap, + validate_evm_cat_unwrap, + validate_evm_erc20_wrap, +) +from drivers.wrapped_assets import ( + CAT_BURNER_MOD, + get_cat_burn_inner_puzzle, + get_wrapped_tail, +) +from drivers.wrapped_cats import LOCKER_MOD + +EIP1967_IMPL_SLOT = ( + "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" +) + +BRIDGE_TO_CHIA_SEL = bytes.fromhex("cdb50da7") +BRIDGE_ETHER_SEL = bytes.fromhex("9ec911b0") +BRIDGE_PERMIT_SEL = bytes.fromhex("77c3ebbb") +BRIDGE_BACK_SEL = bytes.fromhex("415d9361") + +ERC20_ABI = [ + { + "inputs": [], + "name": "decimals", + "outputs": [{"type": "uint8"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{"name": "account", "type": "address"}], + "name": "balanceOf", + "outputs": [{"type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, +] + +BRIDGE_ABI = [ + { + "inputs": [], + "name": "tip", + "outputs": [{"type": "uint16"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "wethToEthRatio", + "outputs": [{"type": "uint64"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "iweth", + "outputs": [{"type": "address"}], + "stateMutability": "view", + "type": "function", + }, +] + +WRAPPED_CAT_ABI = [ + { + "inputs": [], + "name": "portal", + "outputs": [{"type": "address"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "otherChain", + "outputs": [{"type": "bytes3"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "lockerPuzzleHash", + "outputs": [{"type": "bytes32"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "unlockerPuzzleHash", + "outputs": [{"type": "bytes32"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "tip", + "outputs": [{"type": "uint16"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "mojoToTokenRatio", + "outputs": [{"type": "uint64"}], + "stateMutability": "view", + "type": "function", + }, +] + +PORTAL_ABI = [ + { + "inputs": [], + "name": "messageToll", + "outputs": [{"type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, +] + + +def _policy_message_id(message) -> str: + return f"{message.source_chain.decode()}-{message.nonce.hex()}" + + +def apply_policy_result(message, result: PolicyResult) -> bool: + message_id = _policy_message_id(message) + if result.kind == "reject": + logging.warning(f"REJECTED {message_id}: {result.reason}") + message.sig = REJECTED_SIG + return False + if result.kind == "retry": + logging.info(f"RETRY {message_id}: {result.reason}") + return False + if result.kind == "hold": + logging.warning(f"HOLD {message_id}: {result.reason}") + return False + return True + + +_last_control_command: str | None = None + + +def _note_control_command(command: str) -> None: + global _last_control_command + if command == _last_control_command: + return + if command == "stop": + logging.warning("NOSTR STOP") + elif command == "start" and _last_control_command == "stop": + logging.warning("NOSTR START: resume after stop") + _last_control_command = command + + +async def control_allows_start(control_config: ControlConfig) -> bool: + global _last_control_command + try: + events = await fetch_control_events( + control_config.relays, + control_config.stop_authors, + control_config.start_authors, + ) + command = decide_control( + events, + int(time.time()), + control_config.stop_authors, + control_config.start_authors, + ) + _note_control_command(command) + return command == "start" + except Exception: + if _last_control_command != "stop": + logging.warning("NOSTR STOP: control relay fetch failed", exc_info=True) + _last_control_command = "stop" + return False + + +def _checksum(addr: bytes) -> str: + return Web3.to_checksum_address("0x" + evm_address_from_bytes32(left_pad_32(addr)).hex()) + + +def _as_bytes3(value) -> bytes: + if isinstance(value, (bytes, bytearray)): + raw = bytes(value) + elif isinstance(value, str): + raw = bytes.fromhex(value.replace("0x", "")) if value.startswith("0x") else value.encode() + else: + raw = bytes(value) + if len(raw) >= 3: + return raw[:3] + return raw + b"\x00" * (3 - len(raw)) + + +def _as_bytes32(value) -> bytes: + if isinstance(value, (bytes, bytearray)): + raw = bytes(value) + elif isinstance(value, str): + raw = bytes.fromhex(value.replace("0x", "")) + elif isinstance(value, int): + raw = value.to_bytes(32, "big") + else: + raw = bytes(value) + return left_pad_32(raw) + + +async def portal_impl_codehash( + web3: AsyncWeb3, portal_address: bytes, block_number: int +) -> Optional[str]: + try: + portal = _checksum(portal_address) + storage = await web3.eth.get_storage_at( + portal, EIP1967_IMPL_SLOT, block_identifier=block_number + ) + impl = storage[-20:] + if impl == b"\x00" * 20: + return None + code = await web3.eth.get_code( + Web3.to_checksum_address("0x" + impl.hex()), block_identifier=block_number + ) + return Web3.keccak(code).hex() + except Exception: + logging.error("Failed to read portal impl codehash", exc_info=True) + return None + + +async def check_portal_impl( + web3: AsyncWeb3, route, block_number: int +) -> PolicyResult: + actual = await portal_impl_codehash(web3, route.portal_address, block_number) + if actual is None: + return PolicyResult("retry", "could not read portal impl codehash") + return compare_portal_impl(actual, route.portal_impl_codehash) + + +def _storage_key(holder: bytes, slot: int) -> str: + return "0x" + Web3.keccak(left_pad_32(holder) + left_pad_32(slot.to_bytes(32, "big"))).hex() + + +def _parse_uint(value) -> Optional[int]: + if value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, (bytes, bytearray)): + return int.from_bytes(value, "big") + if isinstance(value, str): + return int(value, 16) if value.startswith("0x") else int(value) + if isinstance(value, dict): + if "to" in value: + return _parse_uint(value["to"]) + if "+" in value: + return _parse_uint(value["+"]) + if "*" in value and isinstance(value["*"], dict) and "to" in value["*"]: + return _parse_uint(value["*"]["to"]) + return None + + +def _parse_uint_from(value) -> Optional[int]: + if value is None: + return None + if isinstance(value, dict): + if "from" in value: + return _parse_uint(value["from"]) + if "-" in value: + return None + if "*" in value and isinstance(value["*"], dict) and "from" in value["*"]: + return _parse_uint(value["*"]["from"]) + if "+" in value: + return 0 + return _parse_uint(value) + + +def _storage_diff_map(account_diff: dict) -> dict: + if not account_diff: + return {} + storage = account_diff.get("storage") or account_diff.get("Storage") or {} + return storage + + +def _find_storage_entry(storage_diff: dict, holder: bytes, slot: int): + key_alt = _storage_key(holder, slot).lower() + for k, v in storage_diff.items(): + if str(k).lower() == key_alt: + return v + return None + + +def _slot_delta_from_entry(entry) -> Optional[int]: + if entry is None: + return 0 + if isinstance(entry, dict) and ( + "from" in entry or "to" in entry or "*" in entry or "+" in entry or "-" in entry + ): + pre = _parse_uint_from(entry) + post = _parse_uint(entry) + if pre is None or post is None: + return None + return post - pre + return None + + +async def _find_balance_mapping_slot( + web3: AsyncWeb3, + token: bytes, + holder: bytes, + block_number: int, + storage_diff: dict, +) -> Optional[int]: + token_cs = _checksum(token) + holder_cs = _checksum(holder) + try: + contract = web3.eth.contract(address=token_cs, abi=ERC20_ABI) + balance = int( + await contract.functions.balanceOf(holder_cs).call( + block_identifier=block_number + ) + ) + except Exception: + logging.info("balanceOf failed during balance-slot identification", exc_info=True) + return None + + matching: List[int] = [] + for slot in range(0, 64): + key = _storage_key(holder, slot) + try: + raw = await web3.eth.get_storage_at( + token_cs, key, block_identifier=block_number + ) + except Exception: + continue + stored = int.from_bytes(bytes(raw), "big") + if stored == balance: + matching.append(slot) + + if balance == 0: + matching = [ + slot + for slot in matching + if _find_storage_entry(storage_diff, holder, slot) is not None + ] + + if len(matching) == 1: + return matching[0] + return None + + +def _account_key_variants(address: bytes) -> List[str]: + addr = evm_address_from_bytes32(left_pad_32(address)).hex() + return [ + "0x" + addr.lower(), + Web3.to_checksum_address("0x" + addr), + addr.lower(), + ] + + +def _find_account_diff(state_diff: dict, address: bytes) -> Optional[dict]: + variants = {v.lower() for v in _account_key_variants(address)} + for key, value in state_diff.items(): + if str(key).lower() in variants: + return value + return None + + +async def _extract_token_balance_diff( + web3: AsyncWeb3, + state_diff: dict, + token: bytes, + holder: bytes, + block_number: int, +) -> Optional[int]: + account = _find_account_diff(state_diff, token) + if account is None: + return None + storage = _storage_diff_map(account) + if not storage: + return None + slot = await _find_balance_mapping_slot( + web3, token, holder, block_number, storage + ) + if slot is None: + return None + return _slot_delta_from_entry(_find_storage_entry(storage, holder, slot)) + + +def _normalize_trace_state_diff(result) -> Optional[dict]: + if result is None: + return None + if isinstance(result, dict): + if "stateDiff" in result: + return result["stateDiff"] + if "pre" in result and "post" in result: + merged = {} + pre = result["pre"] or {} + post = result["post"] or {} + addrs = set(pre.keys()) | set(post.keys()) + for addr in addrs: + pre_acc = pre.get(addr) or {} + post_acc = post.get(addr) or {} + pre_storage = pre_acc.get("storage") or {} + post_storage = post_acc.get("storage") or {} + storage = {} + for slot in set(pre_storage.keys()) | set(post_storage.keys()): + storage[slot] = { + "from": pre_storage.get(slot, "0x0"), + "to": post_storage.get(slot, "0x0"), + } + merged[addr] = {"storage": storage} + return merged + if any(isinstance(v, dict) and ("storage" in v or "balance" in v) for v in result.values()): + return result + return None + + +async def _rpc(web3: AsyncWeb3, method: str, params: list): + provider = web3.provider + if hasattr(provider, "make_request"): + response = await provider.make_request(method, params) + if isinstance(response, dict) and "result" in response: + return response["result"] + if isinstance(response, dict) and "error" in response: + raise RuntimeError(response["error"]) + return response + raise RuntimeError("web3 provider does not support make_request") + + +async def fetch_token_balance_diff( + web3: AsyncWeb3, + tx_hash: str, + token: bytes, + holder: bytes, + block_number: int, +) -> Optional[int]: + tx_hash_hex = tx_hash if tx_hash.startswith("0x") else "0x" + tx_hash + + try: + result = await _rpc( + web3, + "debug_traceTransaction", + [tx_hash_hex, {"tracer": "prestateTracer", "tracerConfig": {"diffMode": True}}], + ) + state_diff = _normalize_trace_state_diff(result) + if state_diff is not None: + diff = await _extract_token_balance_diff( + web3, state_diff, token, holder, block_number + ) + if diff is not None: + return diff + except Exception: + logging.info("debug_traceTransaction prestateTracer failed", exc_info=True) + + try: + result = await _rpc( + web3, + "trace_replayTransaction", + [tx_hash_hex, ["stateDiff"]], + ) + state_diff = _normalize_trace_state_diff(result) + if state_diff is not None: + diff = await _extract_token_balance_diff( + web3, state_diff, token, holder, block_number + ) + if diff is not None: + return diff + except Exception: + logging.info("trace_replayTransaction stateDiff failed", exc_info=True) + + return None + + +async def read_wrapped_cat_views( + web3: AsyncWeb3, address: bytes, block_number: int +) -> Tuple[Optional[bytes], Optional[bytes], Optional[bytes], Optional[bytes], Optional[bytes], Optional[int], Optional[int]]: + try: + contract = web3.eth.contract(address=_checksum(address), abi=WRAPPED_CAT_ABI) + portal = await contract.functions.portal().call(block_identifier=block_number) + other_chain = await contract.functions.otherChain().call(block_identifier=block_number) + locker = await contract.functions.lockerPuzzleHash().call(block_identifier=block_number) + unlocker = await contract.functions.unlockerPuzzleHash().call(block_identifier=block_number) + tip = await contract.functions.tip().call(block_identifier=block_number) + ratio = await contract.functions.mojoToTokenRatio().call(block_identifier=block_number) + code = await web3.eth.get_code(_checksum(address), block_identifier=block_number) + return ( + bytes(code), + left_pad_32(bytes.fromhex(portal[2:] if isinstance(portal, str) else portal.hex())), + _as_bytes3(other_chain), + _as_bytes32(locker), + _as_bytes32(unlocker), + int(tip), + int(ratio), + ) + except Exception: + logging.error("Failed to read WrappedCAT views", exc_info=True) + return None, None, None, None, None, None, None + + +def _decode_uint256(data: bytes, position: int) -> int: + return int.from_bytes(data[position:position + 32], "big") + + +def _decode_address(data: bytes, position: int) -> bytes: + return data[position + 12:position + 32] + + +async def evaluate_chia_message_policy( + *, + bridging_coin: Coin, + parent_spend: CoinSpend, + routes: BridgeRoutes, + node, + block_spends: Optional[Sequence[CoinSpend]], + web3_for_chain, + evm_block_number: Optional[int], +) -> PolicyResult: + try: + puzzle = Program.from_bytes(bytes(parent_spend.puzzle_reveal)) + mod, _ = puzzle.uncurry() + except Exception as exc: + return PolicyResult("reject", f"parent puzzle parse error: {exc}") + + if mod == CAT_BURNER_MOD: + cat_spend = await _fetch_burner_cat_spend(node, parent_spend, routes) + if cat_spend is None: + return PolicyResult("retry", "could not load CAT spend for burner") + return validate_chia_erc20_unwrap(bridging_coin, parent_spend, cat_spend, routes) + + if mod == LOCKER_MOD: + if block_spends is None: + return PolicyResult("retry", "missing block spends for locker") + try: + curry_args = list(puzzle.uncurry()[1].as_iter()) + dest_chain = curry_args[0].as_atom() + dest_addr = curry_args[1].as_atom() + except Exception: + return PolicyResult("reject", "locker curry parse error") + + web3 = web3_for_chain(dest_chain) + if web3 is None: + return PolicyResult("retry", "no web3 for locker destination chain") + if evm_block_number is None: + try: + evm_block_number = await web3.eth.block_number + except Exception: + return PolicyResult("retry", "could not read destination block number") + + code, view_portal, view_other, view_locker, _, __, ___ = await read_wrapped_cat_views( + web3, dest_addr, evm_block_number + ) + return validate_chia_cat_wrap( + bridging_coin, + parent_spend, + block_spends, + routes, + wrapped_cat_runtime_code=code, + view_portal=view_portal, + view_other_chain=view_other, + view_locker_puzzle_hash=view_locker, + ) + + return PolicyResult("reject", "parent is neither burner nor locker") + + +async def _fetch_burner_cat_spend(node, burner_spend: CoinSpend, routes: BridgeRoutes) -> Optional[CoinSpend]: + try: + puzzle = Program.from_bytes(bytes(burner_spend.puzzle_reveal)) + solution = Program.from_bytes(bytes(burner_spend.solution)) + mod, curried = puzzle.uncurry() + if mod != CAT_BURNER_MOD: + return None + curry_args = list(curried.as_iter()) + dest_chain = curry_args[3].as_atom() + dest_addr = curry_args[4].as_atom() + sol_args = list(solution.as_iter()) + cat_parent_info = sol_args[0].as_atom() + cat_amount = sol_args[2].as_int() + memo_asset = sol_args[3].as_atom() + memo_receiver = sol_args[4].as_atom() + + wrapped_tail = get_wrapped_tail( + bytes32(routes.portal_launcher_id), + dest_chain, + dest_addr, + left_pad_32(memo_asset), + ) + expected_inner = get_cat_burn_inner_puzzle( + dest_chain, + dest_addr, + left_pad_32(memo_asset), + memo_receiver, + burner_spend.coin.amount, + ) + expected_cat_puzzle = construct_cat_puzzle( + CAT_MOD, + wrapped_tail.get_tree_hash(), + expected_inner, + CAT_MOD_HASH, + ) + expected_cat_coin = Coin(cat_parent_info, expected_cat_puzzle.get_tree_hash(), cat_amount) + cat_id = expected_cat_coin.name() + + coin_record = await node.get_coin_record_by_name(cat_id) + if coin_record is None or coin_record.spent_block_index == 0: + return None + spend = await node.get_puzzle_and_solution(cat_id, coin_record.spent_block_index) + return spend + except Exception: + logging.error("Failed to fetch burner CAT spend", exc_info=True) + return None + + +async def evaluate_evm_message_policy( + *, + web3: AsyncWeb3, + routes: BridgeRoutes, + source_chain: bytes, + source: bytes, + destination: bytes, + destination_chain: bytes, + contents: Sequence[bytes], + block_number: int, + tx_hash: str, +) -> PolicyResult: + if len(source) > 32: + source = source[-32:] + if len(destination) > 32: + destination = destination[-32:] + + route = routes.for_chain(source_chain) + if route is None: + return PolicyResult("reject", "unknown source chain") + + impl_result = await check_portal_impl(web3, route, block_number) + if impl_result.kind != "accept": + return impl_result + + try: + tx = await web3.eth.get_transaction(tx_hash) + receipt = await web3.eth.get_transaction_receipt(tx_hash) + tx_status = int(receipt["status"]) + except Exception: + return PolicyResult("retry", "could not load tx/receipt") + + source_addr = evm_address_from_bytes32(left_pad_32(source)) + bridge_addr = evm_address_from_bytes32(left_pad_32(route.erc20_bridge_address)) + + if source_addr == bridge_addr: + return await _evaluate_evm_erc20_wrap( + web3=web3, + routes=routes, + route=route, + source_chain=source_chain, + source=source, + destination=destination, + destination_chain=destination_chain, + contents=contents, + block_number=block_number, + tx=tx, + tx_hash=tx_hash, + tx_status=tx_status, + ) + + return await _evaluate_evm_cat_unwrap( + web3=web3, + routes=routes, + source_chain=source_chain, + source=source, + destination=destination, + destination_chain=destination_chain, + contents=contents, + block_number=block_number, + tx=tx, + tx_hash=tx_hash, + tx_status=tx_status, + ) + + +async def _evaluate_evm_erc20_wrap( + *, + web3: AsyncWeb3, + routes: BridgeRoutes, + route, + source_chain: bytes, + source: bytes, + destination: bytes, + destination_chain: bytes, + contents: Sequence[bytes], + block_number: int, + tx, + tx_hash: str, + tx_status: int, +) -> PolicyResult: + try: + bridge = web3.eth.contract(address=_checksum(route.erc20_bridge_address), abi=BRIDGE_ABI) + tip_bps = await bridge.functions.tip().call(block_identifier=block_number) + weth_to_eth_ratio = await bridge.functions.wethToEthRatio().call( + block_identifier=block_number + ) + portal = web3.eth.contract(address=_checksum(route.portal_address), abi=PORTAL_ABI) + message_toll = await portal.functions.messageToll().call(block_identifier=block_number) + except Exception: + return PolicyResult("retry", "could not read bridge/portal views") + + raw_input = bytes(tx.input) + selector = raw_input[:4] if len(raw_input) >= 4 else b"" + msg_value = int(tx.value) + + is_bridge_ether = selector == BRIDGE_ETHER_SEL + gross_mojo_amount: Optional[int] = None + asset_for_decimals: Optional[bytes] = None + + try: + if selector == BRIDGE_TO_CHIA_SEL: + asset_for_decimals = _decode_address(raw_input, 4) + gross_mojo_amount = _decode_uint256(raw_input, 4 + 32 + 32) + elif selector == BRIDGE_PERMIT_SEL: + asset_for_decimals = _decode_address(raw_input, 4) + gross_mojo_amount = _decode_uint256(raw_input, 4 + 32 + 32) + elif selector == BRIDGE_ETHER_SEL: + asset_for_decimals = evm_address_from_bytes32(left_pad_32(route.millieth_address)) + if msg_value < message_toll: + return PolicyResult("reject", "msg.value < message toll") + amount_after_toll = msg_value - message_toll + token = web3.eth.contract( + address=_checksum(route.millieth_address), abi=ERC20_ABI + ) + decimals = await token.functions.decimals().call(block_identifier=block_number) + factor = 10 ** (int(decimals) - 3) + if weth_to_eth_ratio <= 0 or amount_after_toll % weth_to_eth_ratio != 0: + return PolicyResult("reject", "amountAfterToll not divisible by wethToEthRatio") + gross_mojo_amount = amount_after_toll // weth_to_eth_ratio // factor + else: + if len(contents) == 3: + asset_for_decimals = evm_address_from_bytes32(left_pad_32(contents[0])) + gross_mojo_amount = None + except Exception: + return PolicyResult("retry", "could not decode bridge calldata") + + decimals: Optional[int] = None + if asset_for_decimals is not None: + try: + token = web3.eth.contract(address=_checksum(asset_for_decimals), abi=ERC20_ABI) + decimals = int(await token.functions.decimals().call(block_identifier=block_number)) + except Exception: + return PolicyResult("retry", "could not read token decimals") + + balance_diff_asset = left_pad_32(contents[0]) if contents else None + bridge_token_balance_diff = None + if balance_diff_asset is not None: + bridge_token_balance_diff = await fetch_token_balance_diff( + web3, + tx_hash, + balance_diff_asset, + route.erc20_bridge_address, + block_number, + ) + if bridge_token_balance_diff is None: + return PolicyResult("retry", "missing bridge balance diff") + + return validate_evm_erc20_wrap( + routes=routes, + source_chain=source_chain, + source=source, + destination=destination, + destination_chain=destination_chain, + contents=contents, + tx_status=tx_status, + tip_bps=int(tip_bps) if tip_bps is not None else None, + decimals=decimals, + gross_mojo_amount=gross_mojo_amount, + bridge_token_balance_diff=bridge_token_balance_diff, + balance_diff_asset=balance_diff_asset, + is_bridge_ether=is_bridge_ether, + msg_value=msg_value if is_bridge_ether else None, + message_toll=int(message_toll) if is_bridge_ether else None, + weth_to_eth_ratio=int(weth_to_eth_ratio) if is_bridge_ether else None, + ) + + +async def _evaluate_evm_cat_unwrap( + *, + web3: AsyncWeb3, + routes: BridgeRoutes, + source_chain: bytes, + source: bytes, + destination: bytes, + destination_chain: bytes, + contents: Sequence[bytes], + block_number: int, + tx, + tx_hash: str, + tx_status: int, +) -> PolicyResult: + code, view_portal, view_other, _, unlocker, tip_bps, mojo_to_token_ratio = await read_wrapped_cat_views( + web3, source, block_number + ) + if code is None: + return PolicyResult("retry", "missing WrappedCAT runtime code") + + raw_input = bytes(tx.input) + gross_mojo_amount: Optional[int] = None + selector = raw_input[:4] if len(raw_input) >= 4 else b"" + if selector == BRIDGE_BACK_SEL and len(raw_input) >= 4 + 64: + gross_mojo_amount = _decode_uint256(raw_input, 4 + 32) + + tx_from = tx["from"] + if isinstance(tx_from, str): + sender = bytes.fromhex(tx_from[2:] if tx_from.startswith("0x") else tx_from) + else: + sender = bytes(tx_from) + route = routes.for_chain(source_chain) + if route is None: + return PolicyResult("reject", "unknown source chain") + + sender_balance_diff = await fetch_token_balance_diff( + web3, tx_hash, source, sender, block_number + ) + portal_balance_diff = await fetch_token_balance_diff( + web3, tx_hash, source, route.portal_address, block_number + ) + if sender_balance_diff is None or portal_balance_diff is None: + return PolicyResult("retry", "missing balance diffs") + + return validate_evm_cat_unwrap( + routes=routes, + source_chain=source_chain, + source=source, + destination=destination, + destination_chain=destination_chain, + contents=contents, + tx_status=tx_status, + tip_bps=tip_bps, + mojo_to_token_ratio=mojo_to_token_ratio, + gross_mojo_amount=gross_mojo_amount, + sender_balance_diff=sender_balance_diff, + portal_balance_diff=portal_balance_diff, + view_portal=view_portal, + view_other_chain=view_other, + unlocker_puzzle_hash=unlocker, + wrapped_cat_runtime_code=code, + asset_id_known=False, + ) + + +async def load_block_spends(node, height: int) -> Optional[List[CoinSpend]]: + try: + record = await node.get_block_record_by_height(height) + if record is None: + return None + spends = await node.get_block_spends(record.header_hash) + if spends is not None: + return spends + except Exception: + logging.info("get_block_spends failed; trying additions_and_removals", exc_info=True) + + try: + record = await node.get_block_record_by_height(height) + if record is None: + return None + additions, removals = await node.get_additions_and_removals(record.header_hash) + spends: List[CoinSpend] = [] + for coin_record in removals: + spend = await node.get_puzzle_and_solution( + coin_record.coin.name(), coin_record.spent_block_index + ) + if spend is not None: + spends.append(spend) + return spends + except Exception: + logging.error("Failed to load block spends", exc_info=True) + return None diff --git a/commands/spend_policy.py b/commands/spend_policy.py new file mode 100644 index 0000000..2d3ac1e --- /dev/null +++ b/commands/spend_policy.py @@ -0,0 +1,997 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from glob import glob +from typing import List, Literal, Optional, Sequence, Tuple, Union + +from chia.types.blockchain_format.coin import Coin +from chia.types.blockchain_format.program import INFINITE_COST, Program +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.types.coin_spend import CoinSpend +from chia.types.condition_opcodes import ConditionOpcode +from chia.wallet.cat_wallet.cat_utils import CAT_MOD, CAT_MOD_HASH_HASH, construct_cat_puzzle +from chia.wallet.cat_wallet.cat_wallet import CAT_MOD_HASH +from chia.wallet.trading.offer import OFFER_MOD, OFFER_MOD_HASH +from chia.wallet.util.curry_and_treehash import calculate_hash_of_quoted_mod_hash, curry_and_treehash + +from drivers.portal import BRIDGING_PUZZLE_HASH +from drivers.utils import raw_hash +from drivers.wrapped_assets import ( + BURN_INNER_PUZZLE_MOD, + BURN_INNER_PUZZLE_MOD_HASH, + CAT_BURNER_MOD, + get_cat_burn_inner_puzzle, + get_cat_burner_puzzle, + get_cat_minter_puzzle, + get_wrapped_tail, +) +from drivers.wrapped_cats import ( + LOCKER_MOD, + get_locker_puzzle, + get_p2_controller_puzzle_hash_inner_puzzle_hash, + get_unlocker_puzzle, +) + +REJECTED_SIG = b"rejected" + +PolicyKind = Literal["accept", "reject", "retry", "hold"] + + +@dataclass(frozen=True) +class PolicyResult: + kind: PolicyKind + reason: str + + +@dataclass(frozen=True) +class EvmRoute: + chain_id: bytes + portal_address: bytes + erc20_bridge_address: bytes + millieth_address: bytes + portal_impl_codehash: str + + +@dataclass(frozen=True) +class BridgeRoutes: + portal_launcher_id: bytes + eth: EvmRoute + bse: EvmRoute + + def for_chain(self, chain_id: bytes) -> Optional[EvmRoute]: + if chain_id == self.eth.chain_id: + return self.eth + if chain_id == self.bse.chain_id: + return self.bse + return None + + def pairs(self) -> Tuple[EvmRoute, EvmRoute]: + return (self.eth, self.bse) + + @staticmethod + def from_config(config: dict) -> BridgeRoutes: + xch = config["xch"] + return BridgeRoutes( + portal_launcher_id=_parse_hex_bytes(xch["portal_launcher_id"]), + eth=_parse_evm_route(config, b"eth"), + bse=_parse_evm_route(config, b"bse"), + ) + + +def evm_address_from_bytes32(value: bytes) -> bytes: + if len(value) == 20: + return value + if len(value) == 32: + if value[:12] != b"\x00" * 12: + raise ValueError("bytes32 address has non-zero prefix") + return value[-20:] + raise ValueError("address must be 20 bytes or left-padded 32 bytes") + + +def left_pad_32(value: bytes) -> bytes: + if len(value) > 32: + raise ValueError("value longer than 32 bytes") + if len(value) < 32: + return b"\x00" * (32 - len(value)) + value + return value + + +def compare_portal_impl(actual_codehash: str, expected_codehash: str) -> PolicyResult: + if _norm_hex(actual_codehash) != _norm_hex(expected_codehash): + return PolicyResult("hold", "portal impl codehash mismatch") + return PolicyResult("accept", "portal impl matches") + + +def mask_immutable_bytecode(code: bytes, spans: Sequence[Tuple[int, int]]) -> bytes: + buf = bytearray(code) + for start, length in spans: + end = start + length + if start < 0 or length < 0 or end > len(buf): + raise ValueError("immutable span out of range") + buf[start:end] = b"\x00" * length + return bytes(buf) + + +def _wrapped_cat_from_build_info(data: dict) -> Optional[Tuple[bytes, List[Tuple[int, int]]]]: + try: + contract = data["output"]["contracts"]["contracts/WrappedCAT.sol"]["WrappedCAT"] + except KeyError: + return None + deployed = contract["evm"]["deployedBytecode"] + bytecode_hex = deployed.get("object") + if bytecode_hex is None: + return None + bytecode = bytes.fromhex(str(bytecode_hex).replace("0x", "")) + refs = deployed.get("immutableReferences") or {} + spans: List[Tuple[int, int]] = [] + for entries in refs.values(): + for entry in entries: + spans.append((int(entry["start"]), int(entry["length"]))) + return bytecode, spans + + +def load_wrapped_cat_build_info() -> Tuple[bytes, List[Tuple[int, int]]]: + matches = glob(os.path.join("artifacts", "build-info", "*.json")) + found: Optional[Tuple[bytes, Tuple[Tuple[int, int], ...]]] = None + for path in matches: + data = json.loads(open(path, "r").read()) + extracted = _wrapped_cat_from_build_info(data) + if extracted is None: + continue + bytecode, spans = extracted + normalized = (bytecode, tuple(sorted(spans))) + if found is None: + found = normalized + elif found != normalized: + raise ValueError("WrappedCAT build-info artifacts disagree") + if found is None: + raise FileNotFoundError("no WrappedCAT in artifacts/build-info") + bytecode, spans_t = found + return bytecode, list(spans_t) + + +def load_wrapped_cat_artifact_bytecode() -> bytes: + bytecode, _ = load_wrapped_cat_build_info() + return bytecode + + +def load_wrapped_cat_immutable_spans() -> List[Tuple[int, int]]: + _, spans = load_wrapped_cat_build_info() + return spans + + +def match_wrapped_cat_bytecode(runtime_code: bytes) -> bool: + expected_runtime_code, immutable_spans = load_wrapped_cat_build_info() + if len(runtime_code) != len(expected_runtime_code): + return False + return mask_immutable_bytecode(runtime_code, immutable_spans) == mask_immutable_bytecode( + expected_runtime_code, immutable_spans + ) + + +def compute_bridge_tip(amount: int, tip_bps: int) -> int: + if tip_bps < 1 or tip_bps > 1000: + raise ValueError("tip_bps out of range") + tip = (amount * tip_bps) // 10000 + if tip == 0: + tip = 1 + return tip + + +def validate_chia_erc20_unwrap( + bridging_coin: Coin, + burner_spend: CoinSpend, + cat_spend: Optional[CoinSpend], + routes: BridgeRoutes, +) -> PolicyResult: + if cat_spend is None: + return PolicyResult("reject", "missing CAT spend") + + try: + burner_puzzle = _as_program(burner_spend.puzzle_reveal) + burner_solution = _as_program(burner_spend.solution) + mod, curried = burner_puzzle.uncurry() + if mod != CAT_BURNER_MOD: + return PolicyResult("reject", "parent is not CAT_BURNER_MOD") + curry_args = _program_list(curried) + if len(curry_args) != 5: + return PolicyResult("reject", "burner curry arity") + cat_mod_hash, burn_inner_mod_hash, bridging_ph, dest_chain, dest_addr = ( + _as_atom(curry_args[0]), + _as_atom(curry_args[1]), + _as_atom(curry_args[2]), + _as_atom(curry_args[3]), + _as_atom(curry_args[4]), + ) + if cat_mod_hash != CAT_MOD_HASH: + return PolicyResult("reject", "burner CAT_MOD_HASH mismatch") + if burn_inner_mod_hash != BURN_INNER_PUZZLE_MOD_HASH: + return PolicyResult("reject", "burner BURN_INNER_PUZZLE_MOD_HASH mismatch") + if bridging_ph != BRIDGING_PUZZLE_HASH: + return PolicyResult("reject", "burner BRIDGING_PUZZLE_HASH mismatch") + + route = _route_for_burner_destination(routes, dest_chain, dest_addr) + if route is None: + return PolicyResult("reject", "burner destination is not a configured route") + + expected_burner = get_cat_burner_puzzle(dest_chain, dest_addr) + if burner_puzzle.get_tree_hash() != expected_burner.get_tree_hash(): + return PolicyResult("reject", "burner puzzle hash mismatch") + if burner_spend.coin.puzzle_hash != burner_puzzle.get_tree_hash(): + return PolicyResult("reject", "burner coin puzzle hash mismatch") + + sol_args = _program_list(burner_solution) + if len(sol_args) != 8: + return PolicyResult("reject", "burner solution arity") + cat_parent_info = _as_atom(sol_args[0]) + tail_hash_hash = _as_atom(sol_args[1]) + cat_amount = sol_args[2].as_int() + memo_asset = _as_atom(sol_args[3]) + memo_receiver = _as_atom(sol_args[4]) + my_amount = sol_args[5].as_int() + my_puzzle_hash = _as_atom(sol_args[6]) + my_coin_id = _as_atom(sol_args[7]) + + if my_amount != burner_spend.coin.amount: + return PolicyResult("reject", "burner amount mismatch") + if my_puzzle_hash != burner_spend.coin.puzzle_hash: + return PolicyResult("reject", "burner solution puzzle hash mismatch") + if my_coin_id != burner_spend.coin.name(): + return PolicyResult("reject", "burner solution coin id mismatch") + + memo = _extract_bridging_memo(burner_puzzle, burner_solution, burner_spend.coin.amount) + if memo is None: + return PolicyResult("reject", "burner missing bridging CREATE_COIN memo") + + memo_chain, memo_dest, memo_contents = memo + if memo_chain != dest_chain or left_pad_32(memo_dest) != left_pad_32(dest_addr): + return PolicyResult("reject", "memo destination does not match burner curry") + if evm_address_from_bytes32(left_pad_32(memo_dest)) != evm_address_from_bytes32( + left_pad_32(route.erc20_bridge_address) + ): + return PolicyResult("reject", "memo destination is not the route bridge") + if len(memo_contents) != 3: + return PolicyResult("reject", "unwrap memo must be exactly 3 words") + if left_pad_32(memo_contents[0]) != left_pad_32(memo_asset): + return PolicyResult("reject", "memo asset mismatch") + if left_pad_32(memo_contents[1]) != left_pad_32(memo_receiver): + return PolicyResult("reject", "memo receiver mismatch") + if _as_int_word(memo_contents[2]) != cat_amount: + return PolicyResult("reject", "memo amount mismatch") + + if bridging_coin.parent_coin_info != burner_spend.coin.name(): + return PolicyResult("reject", "bridging coin parent is not burner") + if bridging_coin.puzzle_hash != BRIDGING_PUZZLE_HASH: + return PolicyResult("reject", "bridging coin puzzle hash") + if bridging_coin.amount != burner_spend.coin.amount: + return PolicyResult("reject", "bridging coin amount") + + asset = left_pad_32(memo_asset) + wrapped_tail = get_wrapped_tail( + bytes32(routes.portal_launcher_id), + dest_chain, + dest_addr, + asset, + ) + expected_tail_hash_hash = raw_hash([b"\x01", wrapped_tail.get_tree_hash()]) + if tail_hash_hash != expected_tail_hash_hash: + return PolicyResult("reject", "tail_hash_hash mismatch") + + expected_inner = get_cat_burn_inner_puzzle( + dest_chain, + dest_addr, + asset, + memo_receiver, + burner_spend.coin.amount, + ) + expected_cat_puzzle = construct_cat_puzzle( + CAT_MOD, + wrapped_tail.get_tree_hash(), + expected_inner, + CAT_MOD_HASH, + ) + expected_cat_coin = Coin(cat_parent_info, expected_cat_puzzle.get_tree_hash(), cat_amount) + if cat_spend.coin != expected_cat_coin: + return PolicyResult("reject", "CAT coin does not match burner reconstruction") + + cat_puzzle = _as_program(cat_spend.puzzle_reveal) + cat_solution = _as_program(cat_spend.solution) + cat_mod, cat_args_p = cat_puzzle.uncurry() + if cat_mod != CAT_MOD: + return PolicyResult("reject", "CAT spend is not CAT_MOD") + cat_args = _program_list(cat_args_p) + if len(cat_args) != 3: + return PolicyResult("reject", "CAT curry arity") + if _as_atom(cat_args[0]) != CAT_MOD_HASH: + return PolicyResult("reject", "CAT inner CAT_MOD_HASH mismatch") + if _as_atom(cat_args[1]) != wrapped_tail.get_tree_hash(): + return PolicyResult("reject", "CAT tail hash mismatch") + inner_puzzle = cat_args[2] + if inner_puzzle.get_tree_hash() != expected_inner.get_tree_hash(): + return PolicyResult("reject", "CAT burn-inner mismatch") + + inner_mod_1, inner_args_1_p = inner_puzzle.uncurry() + inner_args_1 = _program_list(inner_args_1_p) + if len(inner_args_1) != 2: + return PolicyResult("reject", "burn-inner second curry arity") + if left_pad_32(_as_atom(inner_args_1[0])) != left_pad_32(memo_receiver): + return PolicyResult("reject", "burn-inner receiver mismatch") + if inner_args_1[1].as_int() != burner_spend.coin.amount: + return PolicyResult("reject", "burn-inner fee mismatch") + inner_mod_0, inner_args_0_p = inner_mod_1.uncurry() + if inner_mod_0 != BURN_INNER_PUZZLE_MOD: + return PolicyResult("reject", "inner is not BURN_INNER_PUZZLE_MOD") + inner_args_0 = _program_list(inner_args_0_p) + if len(inner_args_0) != 2: + return PolicyResult("reject", "burn-inner first curry arity") + if _as_atom(inner_args_0[0]) != burner_spend.coin.puzzle_hash: + return PolicyResult("reject", "burn-inner burner puzzle hash mismatch") + if left_pad_32(_as_atom(inner_args_0[1])) != asset: + return PolicyResult("reject", "burn-inner token mismatch") + + cat_sol_args = _program_list(cat_solution) + if len(cat_sol_args) < 7: + return PolicyResult("reject", "CAT solution arity") + inner_solution = cat_sol_args[0] + extra_delta = cat_sol_args[6].as_int() + inner_sol_args = _program_list(inner_solution) + if len(inner_sol_args) != 3: + return PolicyResult("reject", "burn-inner solution arity") + tail_reveal = inner_sol_args[2] + if bytes(tail_reveal) != bytes(wrapped_tail): + return PolicyResult("reject", "tail reveal is not reconstructed wrapped_tail") + + if cat_spend.coin.amount != cat_amount: + return PolicyResult("reject", "CAT amount does not match message amount") + if extra_delta != -cat_amount: + return PolicyResult("reject", "CAT extra_delta is not full melt") + + try: + _, inner_conds = inner_puzzle.run_with_cost(INFINITE_COST, inner_solution) + except Exception: + return PolicyResult("reject", "burn-inner spend failed") + if not _is_full_melt(inner_conds): + return PolicyResult("reject", "CAT spend is not a full melt") + + try: + _, burner_conds = burner_puzzle.run_with_cost(INFINITE_COST, burner_solution) + except Exception: + return PolicyResult("reject", "burner spend failed") + if not _announcements_tie(burner_conds, inner_conds, burner_spend.coin.name(), cat_spend.coin.name()): + return PolicyResult("reject", "burner/CAT announcements do not tie") + + return PolicyResult("accept", "chia erc20 unwrap") + except Exception as exc: + return PolicyResult("reject", f"chia erc20 unwrap parse error: {exc}") + + +def validate_chia_cat_wrap( + bridging_coin: Coin, + locker_spend: CoinSpend, + other_spends: Sequence[CoinSpend], + routes: BridgeRoutes, + *, + wrapped_cat_runtime_code: Optional[bytes], + view_portal: Optional[bytes], + view_other_chain: Optional[bytes], + view_locker_puzzle_hash: Optional[bytes], +) -> PolicyResult: + try: + locker_puzzle = _as_program(locker_spend.puzzle_reveal) + locker_solution = _as_program(locker_spend.solution) + mod, curried = locker_puzzle.uncurry() + if mod != LOCKER_MOD: + return PolicyResult("reject", "parent is not LOCKER_MOD") + curry_args = _program_list(curried) + if len(curry_args) != 7: + return PolicyResult("reject", "locker curry arity") + + dest_chain = _as_atom(curry_args[0]) + dest_addr = _as_atom(curry_args[1]) + cat_mod_hash = _as_atom(curry_args[2]) + offer_mod_hash = _as_atom(curry_args[3]) + bridging_ph = _as_atom(curry_args[4]) + vault_inner_ph = _as_atom(curry_args[5]) + asset_id_atom = _as_atom(curry_args[6]) + + if cat_mod_hash != CAT_MOD_HASH: + return PolicyResult("reject", "locker CAT_MOD_HASH mismatch") + if offer_mod_hash != OFFER_MOD_HASH: + return PolicyResult("reject", "locker OFFER_MOD_HASH mismatch") + if bridging_ph != BRIDGING_PUZZLE_HASH: + return PolicyResult("reject", "locker BRIDGING_PUZZLE_HASH mismatch") + + route = routes.for_chain(dest_chain) + if route is None: + return PolicyResult("reject", "locker destination chain is not configured") + + asset_id: Optional[bytes32] + if asset_id_atom == b"": + asset_id = None + else: + if len(asset_id_atom) != 32: + return PolicyResult("reject", "locker asset_id length") + asset_id = bytes32(asset_id_atom) + + expected_locker = get_locker_puzzle( + dest_chain, + dest_addr, + bytes32(routes.portal_launcher_id), + asset_id, + ) + if locker_puzzle.get_tree_hash() != expected_locker.get_tree_hash(): + return PolicyResult("reject", "locker puzzle hash mismatch") + if locker_spend.coin.puzzle_hash != locker_puzzle.get_tree_hash(): + return PolicyResult("reject", "locker coin puzzle hash mismatch") + if vault_inner_ph != _expected_vault_inner_ph( + dest_chain, dest_addr, routes.portal_launcher_id, asset_id + ): + return PolicyResult("reject", "locker vault inner mismatch") + + sol_args = _program_list(locker_solution) + if len(sol_args) != 4: + return PolicyResult("reject", "locker solution arity") + my_amount = sol_args[0].as_int() + my_id = _as_atom(sol_args[1]) + asset_amount = sol_args[2].as_int() + receiver = _as_atom(sol_args[3]) + + if my_amount != locker_spend.coin.amount: + return PolicyResult("reject", "locker amount mismatch") + if my_id != locker_spend.coin.name(): + return PolicyResult("reject", "locker coin id mismatch") + + memo = _extract_bridging_memo(locker_puzzle, locker_solution, locker_spend.coin.amount) + if memo is None: + return PolicyResult("reject", "locker missing bridging CREATE_COIN memo") + memo_chain, memo_dest, memo_contents = memo + if memo_chain != dest_chain or left_pad_32(memo_dest) != left_pad_32(dest_addr): + return PolicyResult("reject", "memo destination does not match locker curry") + if len(memo_contents) != 2: + return PolicyResult("reject", "wrap memo must be exactly (receiver, asset_amount)") + if left_pad_32(memo_contents[0]) != left_pad_32(receiver): + return PolicyResult("reject", "memo receiver mismatch") + if _as_int_word(memo_contents[1]) != asset_amount: + return PolicyResult("reject", "memo asset_amount mismatch") + + if bridging_coin.parent_coin_info != locker_spend.coin.name(): + return PolicyResult("reject", "bridging coin parent is not locker") + if bridging_coin.puzzle_hash != BRIDGING_PUZZLE_HASH: + return PolicyResult("reject", "bridging coin puzzle hash") + if bridging_coin.amount != locker_spend.coin.amount: + return PolicyResult("reject", "bridging coin amount") + + if ( + wrapped_cat_runtime_code is None + or view_portal is None + or view_other_chain is None + or view_locker_puzzle_hash is None + ): + return PolicyResult("retry", "missing WrappedCAT bytecode or views") + + try: + if not match_wrapped_cat_bytecode(wrapped_cat_runtime_code): + return PolicyResult("reject", "WrappedCAT bytecode mismatch after immutable mask") + except Exception: + return PolicyResult("retry", "could not load WrappedCAT artifact for mask compare") + + if evm_address_from_bytes32(left_pad_32(view_portal)) != evm_address_from_bytes32( + left_pad_32(route.portal_address) + ): + return PolicyResult("reject", "WrappedCAT portal() does not match route portal") + if view_other_chain != b"xch": + return PolicyResult("reject", "WrappedCAT otherChain() is not xch") + if view_locker_puzzle_hash != locker_spend.coin.puzzle_hash: + return PolicyResult("reject", "WrappedCAT lockerPuzzleHash() mismatch") + + vault_ph = _vault_puzzle_hash(asset_id, vault_inner_ph) + settlement = _find_settlement_spend( + other_spends, + asset_id, + locker_spend.coin.name(), + vault_inner_ph, + vault_ph, + asset_amount, + ) + if settlement is None: + return PolicyResult("reject", "settlement offer spend not found") + + return PolicyResult("accept", "chia cat/xch wrap") + except Exception as exc: + return PolicyResult("reject", f"chia cat wrap parse error: {exc}") + + +def validate_evm_erc20_wrap( + *, + routes: BridgeRoutes, + source_chain: bytes, + source: bytes, + destination: bytes, + destination_chain: bytes, + contents: Sequence[bytes], + tx_status: Optional[int], + tip_bps: Optional[int], + decimals: Optional[int], + gross_mojo_amount: Optional[int], + bridge_token_balance_diff: Optional[int], + balance_diff_asset: Optional[bytes], + is_bridge_ether: bool = False, + msg_value: Optional[int] = None, + message_toll: Optional[int] = None, + weth_to_eth_ratio: Optional[int] = None, +) -> PolicyResult: + route = routes.for_chain(source_chain) + if route is None: + return PolicyResult("reject", "unknown source chain") + if destination_chain != b"xch": + return PolicyResult("reject", "destination_chain is not xch") + try: + if evm_address_from_bytes32(source) != evm_address_from_bytes32( + left_pad_32(route.erc20_bridge_address) + ): + return PolicyResult("reject", "source is not pinned erc20 bridge") + except ValueError: + return PolicyResult("reject", "source is not pinned erc20 bridge") + + expected_dest = get_cat_minter_puzzle( + bytes32(routes.portal_launcher_id), + source_chain, + evm_address_from_bytes32(route.erc20_bridge_address), + ).get_tree_hash() + if destination != expected_dest: + return PolicyResult("reject", "destination is not cat-minter puzzle hash") + + if len(contents) != 3: + return PolicyResult("reject", "erc20 wrap contents must be 3 words") + + if tx_status is None: + return PolicyResult("retry", "missing tx status") + if tx_status != 1: + return PolicyResult("reject", "tx status not success") + + if tip_bps is None or decimals is None or gross_mojo_amount is None: + return PolicyResult("retry", "missing tip/decimals/gross amount") + if tip_bps < 1 or tip_bps > 1000: + return PolicyResult("reject", "tip_bps out of range") + if decimals < 3: + return PolicyResult("reject", "decimals < 3") + if gross_mojo_amount <= 0: + return PolicyResult("reject", "gross amount must be positive") + + tip = compute_bridge_tip(gross_mojo_amount, tip_bps) + if gross_mojo_amount <= tip: + return PolicyResult("reject", "amount <= tip") + net = gross_mojo_amount - tip + if _as_int_word(contents[2]) != net: + return PolicyResult("reject", "message amount != net") + + factor = 10 ** (decimals - 3) + asset = left_pad_32(contents[0]) + + if bridge_token_balance_diff is None: + return PolicyResult("retry", "missing bridge balance diff") + if balance_diff_asset is None: + return PolicyResult("retry", "missing balance diff asset") + if evm_address_from_bytes32(balance_diff_asset) != evm_address_from_bytes32(contents[0]): + return PolicyResult("reject", "balance diff asset mismatch") + + if is_bridge_ether: + if msg_value is None or message_toll is None or weth_to_eth_ratio is None: + return PolicyResult("retry", "missing ether-bridge inputs") + if msg_value < message_toll: + return PolicyResult("reject", "msg.value < message toll") + amount_after_toll = msg_value - message_toll + if weth_to_eth_ratio <= 0 or amount_after_toll % weth_to_eth_ratio != 0: + return PolicyResult("reject", "amountAfterToll not divisible by wethToEthRatio") + mojos = amount_after_toll // weth_to_eth_ratio // factor + if mojos != gross_mojo_amount: + return PolicyResult("reject", "ether bridge mojo amount mismatch") + if evm_address_from_bytes32(asset) != evm_address_from_bytes32( + left_pad_32(route.millieth_address) + ): + return PolicyResult("reject", "ether bridge asset is not milliETH/WETH") + expected_diff = (gross_mojo_amount - tip) * factor + if bridge_token_balance_diff != expected_diff: + return PolicyResult("reject", "milliETH bridge balance diff mismatch") + else: + expected_diff = (gross_mojo_amount - tip) * factor + if bridge_token_balance_diff != expected_diff: + return PolicyResult("reject", "bridge token balance diff mismatch") + + return PolicyResult("accept", "evm erc20 wrap") + + +def validate_evm_cat_unwrap( + *, + routes: BridgeRoutes, + source_chain: bytes, + source: bytes, + destination: bytes, + destination_chain: bytes, + contents: Sequence[bytes], + tx_status: Optional[int], + tip_bps: Optional[int], + mojo_to_token_ratio: Optional[int], + gross_mojo_amount: Optional[int], + sender_balance_diff: Optional[int], + portal_balance_diff: Optional[int], + view_portal: Optional[bytes], + view_other_chain: Optional[bytes], + unlocker_puzzle_hash: Optional[bytes], + wrapped_cat_runtime_code: Optional[bytes] = None, + asset_id: Optional[bytes] = None, + asset_id_known: bool = False, +) -> PolicyResult: + route = routes.for_chain(source_chain) + if route is None: + return PolicyResult("reject", "unknown source chain") + if destination_chain != b"xch": + return PolicyResult("reject", "destination_chain is not xch") + + if tx_status is None: + return PolicyResult("retry", "missing tx status") + if tx_status != 1: + return PolicyResult("reject", "tx status not success") + + if wrapped_cat_runtime_code is None: + return PolicyResult("retry", "missing WrappedCAT runtime code") + try: + if not match_wrapped_cat_bytecode(wrapped_cat_runtime_code): + return PolicyResult("reject", "WrappedCAT bytecode mismatch after immutable mask") + except Exception: + return PolicyResult("retry", "could not load WrappedCAT artifact for mask compare") + + if view_portal is None or view_other_chain is None or unlocker_puzzle_hash is None: + return PolicyResult("retry", "missing WrappedCAT views") + if evm_address_from_bytes32(left_pad_32(view_portal)) != evm_address_from_bytes32( + left_pad_32(route.portal_address) + ): + return PolicyResult("reject", "WrappedCAT portal() does not match route portal") + if view_other_chain != b"xch": + return PolicyResult("reject", "WrappedCAT otherChain() is not xch") + if destination != unlocker_puzzle_hash: + return PolicyResult("reject", "destination != unlockerPuzzleHash()") + + if asset_id_known: + driver_asset_id: Optional[bytes32] + if asset_id is None or asset_id == b"": + driver_asset_id = None + else: + driver_asset_id = bytes32(left_pad_32(asset_id)) + expected_unlocker = get_unlocker_puzzle( + source_chain, + evm_address_from_bytes32(left_pad_32(source)), + bytes32(routes.portal_launcher_id), + driver_asset_id, + ).get_tree_hash() + if expected_unlocker != unlocker_puzzle_hash: + return PolicyResult("reject", "unlockerPuzzleHash inconsistent with drivers") + + if len(contents) != 2: + return PolicyResult("reject", "cat unwrap contents must be (receiver, net)") + + if tip_bps is None or mojo_to_token_ratio is None or gross_mojo_amount is None: + return PolicyResult("retry", "missing tip/ratio/gross amount") + if tip_bps < 1 or tip_bps > 1000: + return PolicyResult("reject", "tip_bps out of range") + if mojo_to_token_ratio <= 0 or gross_mojo_amount <= 0: + return PolicyResult("reject", "invalid ratio or gross amount") + + tip = compute_bridge_tip(gross_mojo_amount, tip_bps) + if gross_mojo_amount <= tip: + return PolicyResult("reject", "amount <= tip") + net = gross_mojo_amount - tip + if _as_int_word(contents[1]) != net: + return PolicyResult("reject", "message net amount mismatch") + + if sender_balance_diff is None or portal_balance_diff is None: + return PolicyResult("retry", "missing balance diffs") + + expected_sender_diff = -(gross_mojo_amount * mojo_to_token_ratio) + expected_portal_diff = tip * mojo_to_token_ratio + if sender_balance_diff != expected_sender_diff: + return PolicyResult("reject", "sender balance diff mismatch") + if portal_balance_diff != expected_portal_diff: + return PolicyResult("reject", "portal balance diff mismatch") + + return PolicyResult("accept", "evm cat unwrap") + + +def _parse_evm_route(config: dict, chain_id: bytes) -> EvmRoute: + key = chain_id.decode() + chain_cfg = config[key] + millieth = chain_cfg.get("millieth_address", chain_cfg.get("weth_address")) + if millieth is None: + raise KeyError(f"{key}.millieth_address") + return EvmRoute( + chain_id=chain_id, + portal_address=_parse_hex_bytes(chain_cfg["portal_address"]), + erc20_bridge_address=_parse_hex_bytes(chain_cfg["erc20_bridge_address"]), + millieth_address=_parse_hex_bytes(millieth), + portal_impl_codehash=str(chain_cfg["portal_impl_codehash"]), + ) + + +def _parse_hex_bytes(value: Union[str, bytes]) -> bytes: + if isinstance(value, bytes): + return value + return bytes.fromhex(value.replace("0x", "").replace("0X", "")) + + +def _norm_hex(value: str) -> str: + return value.lower().replace("0x", "") + + +def _as_program(value) -> Program: + if isinstance(value, Program): + return value + return Program.from_bytes(bytes(value)) + + +def _program_list(program: Program) -> List[Program]: + return list(program.as_iter()) + + +def _as_atom(program: Program) -> bytes: + atom = program.as_atom() + if atom is None: + raise ValueError("expected atom") + return atom + + +def _as_int_word(value: bytes) -> int: + return int.from_bytes(left_pad_32(value), "big") + + +def _route_for_burner_destination( + routes: BridgeRoutes, dest_chain: bytes, dest_addr: bytes +) -> Optional[EvmRoute]: + route = routes.for_chain(dest_chain) + if route is None: + return None + try: + if evm_address_from_bytes32(left_pad_32(dest_addr)) != evm_address_from_bytes32( + left_pad_32(route.erc20_bridge_address) + ): + return None + except ValueError: + return None + return route + + +def _extract_bridging_memo( + puzzle: Program, solution: Program, expected_amount: int +) -> Optional[Tuple[bytes, bytes, List[bytes]]]: + try: + _, conditions = puzzle.run_with_cost(INFINITE_COST, solution) + except Exception: + return None + create_coin = bytes(ConditionOpcode.CREATE_COIN) + found: Optional[Tuple[bytes, bytes, Tuple[bytes, ...]]] = None + for condition in conditions.as_iter(): + items = _program_list(condition) + if not items or _as_atom(items[0]) != create_coin: + continue + if len(items) < 4: + continue + created_ph = _as_atom(items[1]) + if created_ph != BRIDGING_PUZZLE_HASH: + continue + if items[2].as_int() != expected_amount: + continue + memo = items[3] + memo_items = _program_list(memo) + if len(memo_items) < 2: + return None + chain = _as_atom(memo_items[0]) + dest = _as_atom(memo_items[1]) + contents = tuple(_as_atom(x) for x in memo_items[2:]) + candidate = (chain, dest, contents) + if found is not None and found != candidate: + return None + found = candidate + if found is None: + return None + chain, dest, contents = found + return chain, dest, list(contents) + + +def _is_full_melt(conditions: Program) -> bool: + saw_melt = False + create_coin = bytes(ConditionOpcode.CREATE_COIN) + for condition in conditions.as_iter(): + items = _program_list(condition) + if not items or _as_atom(items[0]) != create_coin: + continue + if len(items) < 3: + return False + amount = items[2].as_int() + if amount == -113: + saw_melt = True + continue + if amount > 0: + return False + return saw_melt + + +def _announcements_tie( + burner_conds: Program, + cat_inner_conds: Program, + burner_id: bytes, + cat_id: bytes, +) -> bool: + return ( + _has_announcement(burner_conds, ConditionOpcode.CREATE_COIN_ANNOUNCEMENT, cat_id) + and _has_announcement( + burner_conds, ConditionOpcode.ASSERT_COIN_ANNOUNCEMENT, raw_hash([cat_id, burner_id]) + ) + and _has_announcement(cat_inner_conds, ConditionOpcode.CREATE_COIN_ANNOUNCEMENT, burner_id) + and _has_announcement( + cat_inner_conds, ConditionOpcode.ASSERT_COIN_ANNOUNCEMENT, raw_hash([burner_id, cat_id]) + ) + ) + + +def _has_announcement(conditions: Program, opcode: ConditionOpcode, payload: bytes) -> bool: + op = bytes(opcode) + for condition in conditions.as_iter(): + items = _program_list(condition) + if not items or _as_atom(items[0]) != op: + continue + if len(items) < 2: + continue + if _as_atom(items[1]) == payload: + return True + return False + + +def _expected_vault_inner_ph( + dest_chain: bytes, + dest_addr: bytes, + portal_launcher_id: bytes, + asset_id: Optional[bytes32], +) -> bytes: + unlocker = get_unlocker_puzzle( + dest_chain, + dest_addr, + bytes32(portal_launcher_id), + asset_id, + ) + return get_p2_controller_puzzle_hash_inner_puzzle_hash(unlocker.get_tree_hash()).get_tree_hash() + + +_QUOTED_CAT_MOD_HASH = calculate_hash_of_quoted_mod_hash(CAT_MOD_HASH) + + +def _vault_puzzle_hash(asset_id: Optional[bytes32], vault_inner_ph: bytes) -> bytes: + if asset_id is None: + return vault_inner_ph + return curry_and_treehash( + _QUOTED_CAT_MOD_HASH, + CAT_MOD_HASH_HASH, + Program.to(asset_id).get_tree_hash(), + bytes32(vault_inner_ph), + ) + + +def _find_settlement_spend( + spends: Sequence[CoinSpend], + asset_id: Optional[bytes32], + locker_coin_id: bytes, + vault_inner_ph: bytes, + vault_ph: bytes, + asset_amount: int, +) -> Optional[CoinSpend]: + for spend in spends: + try: + puzzle = _as_program(spend.puzzle_reveal) + if spend.coin.puzzle_hash != puzzle.get_tree_hash(): + continue + if asset_id is None: + if not (puzzle == OFFER_MOD or puzzle.get_tree_hash() == OFFER_MOD_HASH): + continue + else: + mod, args_p = puzzle.uncurry() + if mod != CAT_MOD: + continue + args = _program_list(args_p) + if len(args) != 3: + continue + if _as_atom(args[0]) != CAT_MOD_HASH: + continue + if _as_atom(args[1]) != asset_id: + continue + inner = args[2] + if not (inner == OFFER_MOD or inner.get_tree_hash() == OFFER_MOD_HASH): + continue + if not _settlement_has_notarized_payment( + spend, locker_coin_id, vault_inner_ph, asset_amount + ): + continue + if not _settlement_creates_vault( + spend, asset_id, vault_inner_ph, vault_ph, asset_amount + ): + continue + return spend + except Exception: + continue + return None + + +def _settlement_has_notarized_payment( + settlement: CoinSpend, + locker_coin_id: bytes, + vault_inner_ph: bytes, + asset_amount: int, +) -> bool: + solution = _as_program(settlement.solution) + # CAT settlement: solution[0] is inner OFFER solution; plain OFFER: whole solution + puzzle = _as_program(settlement.puzzle_reveal) + mod, _ = puzzle.uncurry() + offer_solution = solution + if mod == CAT_MOD: + sol_args = _program_list(solution) + if not sol_args: + return False + offer_solution = sol_args[0] + for payment in _program_list(offer_solution): + parts = _program_list(payment) + if len(parts) < 2: + continue + nonce = _as_atom(parts[0]) + if nonce != locker_coin_id: + continue + for notarized in parts[1:]: + np = _program_list(notarized) + if len(np) < 2: + continue + if _as_atom(np[0]) == vault_inner_ph and np[1].as_int() == asset_amount: + return True + return False + + +def _settlement_creates_vault( + settlement: CoinSpend, + asset_id: Optional[bytes32], + vault_inner_ph: bytes, + vault_ph: bytes, + asset_amount: int, +) -> bool: + puzzle = _as_program(settlement.puzzle_reveal) + solution = _as_program(settlement.solution) + create_coin = bytes(ConditionOpcode.CREATE_COIN) + + if asset_id is None: + try: + _, conditions = puzzle.run_with_cost(INFINITE_COST, solution) + except Exception: + return False + for condition in conditions.as_iter(): + items = _program_list(condition) + if not items or _as_atom(items[0]) != create_coin: + continue + if len(items) < 3: + continue + if _as_atom(items[1]) == vault_ph and items[2].as_int() == asset_amount: + return True + return False + + # CAT(asset_id, OFFER_MOD): OFFER creates the inner vault ph; CAT wraps it. + sol_args = _program_list(solution) + if not sol_args: + return False + try: + _, conditions = OFFER_MOD.run_with_cost(INFINITE_COST, sol_args[0]) + except Exception: + return False + for condition in conditions.as_iter(): + items = _program_list(condition) + if not items or _as_atom(items[0]) != create_coin: + continue + if len(items) < 3: + continue + if _as_atom(items[1]) == vault_inner_ph and items[2].as_int() == asset_amount: + return True + return False diff --git a/rpc_server.py b/rpc_server.py index 5af9653..86b5413 100644 --- a/rpc_server.py +++ b/rpc_server.py @@ -35,6 +35,8 @@ "get_mempool_items_by_coin_name", "get_network_info", "get_puzzle_and_solution", + "get_block_spends", + "get_additions_and_removals", "get_routes", "healthz", "push_tx" diff --git a/tests/test_control_channel.py b/tests/test_control_channel.py new file mode 100644 index 0000000..4b21d47 --- /dev/null +++ b/tests/test_control_channel.py @@ -0,0 +1,147 @@ +"""Pure control-channel tests: no network, no nostr I/O.""" + +from __future__ import annotations + +import pytest + +from commands.control_channel import ( + CLOCK_SKEW_SECONDS, + ControlEvent, + decide_control, + load_control_config, +) + +STOP_AUTHOR = "11" * 32 +START_AUTHOR = "22" * 32 +OTHER_AUTHOR = "33" * 32 + +NOW = 1_700_000_000 + + +def _ev( + *, + id: str, + author: str, + content: str, + created_at: int, + e_tags: tuple[str, ...] = (), +) -> ControlEvent: + return ControlEvent( + id=id, + author=author, + content=content, + created_at=created_at, + e_tag_ids=e_tags, + ) + + +def _nostr_config(stop=None, start=None, relays=None): + return { + "nostr": { + "control_stop_authors": stop if stop is not None else [STOP_AUTHOR], + "control_start_authors": start if start is not None else [START_AUTHOR], + "control_relays": relays if relays is not None else ["wss://relay.example"], + } + } + + +class TestLoadControlConfig: + def test_overlapping_authors_fail_load(self): + overlap = "aa" * 32 + with pytest.raises(ValueError, match="overlap"): + load_control_config( + _nostr_config(stop=[STOP_AUTHOR, overlap], start=[START_AUTHOR, overlap]) + ) + + def test_loads_disjoint_authors(self): + cfg = load_control_config(_nostr_config()) + assert STOP_AUTHOR in cfg.stop_authors + assert START_AUTHOR in cfg.start_authors + + +class TestDecideControl: + def test_stop_key_start_ignored(self): + events = [ + _ev(id="a1", author=STOP_AUTHOR, content="start", created_at=NOW - 10), + ] + assert ( + decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + ) + + def test_start_key_stop_ignored(self): + events = [ + _ev(id="b1", author=START_AUTHOR, content="stop", created_at=NOW - 10), + ] + # No valid stop and no valid start → stop + assert ( + decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + ) + + def test_start_without_tagging_latest_stop_does_not_resume(self): + events = [ + _ev(id="stop1", author=STOP_AUTHOR, content="stop", created_at=NOW - 100), + _ev( + id="start1", + author=START_AUTHOR, + content="start", + created_at=NOW - 50, + e_tags=("deadbeef" + "00" * 28,), # not stop1 + ), + ] + assert ( + decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + ) + + def test_start_tagging_stop_resumes(self): + stop_id = "ab" * 32 + events = [ + _ev(id=stop_id, author=STOP_AUTHOR, content="stop", created_at=NOW - 100), + _ev( + id="cd" * 32, + author=START_AUTHOR, + content="start", + created_at=NOW - 50, + e_tags=(stop_id,), + ), + ] + assert ( + decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "start" + ) + + def test_future_dated_start_does_not_override_stop(self): + stop_id = "ab" * 32 + events = [ + _ev(id=stop_id, author=STOP_AUTHOR, content="stop", created_at=NOW - 10), + _ev( + id="cd" * 32, + author=START_AUTHOR, + content="start", + created_at=NOW + CLOCK_SKEW_SECONDS + 1, + e_tags=(stop_id,), + ), + ] + assert ( + decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + ) + + def test_no_events_is_stop(self): + assert decide_control([], NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + + def test_other_note_text_ignored(self): + events = [ + _ev(id="x1", author=STOP_AUTHOR, content="halt", created_at=NOW - 20), + _ev(id="x2", author=START_AUTHOR, content="go", created_at=NOW - 10), + _ev(id="x3", author=OTHER_AUTHOR, content="stop", created_at=NOW - 5), + _ev(id="x4", author=OTHER_AUTHOR, content="start", created_at=NOW - 1), + ] + assert ( + decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + ) + + def test_only_valid_starts_without_stops_is_start(self): + events = [ + _ev(id="s1", author=START_AUTHOR, content="start", created_at=NOW - 10), + ] + assert ( + decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "start" + ) diff --git a/tests/test_spend_policy.py b/tests/test_spend_policy.py new file mode 100644 index 0000000..b7fab07 --- /dev/null +++ b/tests/test_spend_policy.py @@ -0,0 +1,637 @@ +"""Pure spend-policy tests: no full node, no network.""" + +from __future__ import annotations + +from typing import List, Optional, Tuple +from unittest.mock import patch + +import pytest +from chia.types.blockchain_format.coin import Coin +from chia.types.blockchain_format.program import Program +from chia.types.blockchain_format.serialized_program import SerializedProgram +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.types.coin_spend import CoinSpend +from chia.wallet.cat_wallet.cat_utils import ( + CAT_MOD, + SpendableCAT, + construct_cat_puzzle, + unsigned_spend_bundle_for_spendable_cats, +) +from chia.wallet.cat_wallet.cat_wallet import CAT_MOD_HASH +from chia.wallet.lineage_proof import LineageProof +from chia.wallet.trading.offer import OFFER_MOD, OFFER_MOD_HASH + +from commands import spend_policy +from commands.spend_policy import ( + BridgeRoutes, + compute_bridge_tip, + evm_address_from_bytes32, + left_pad_32, + load_wrapped_cat_artifact_bytecode, + validate_chia_cat_wrap, + validate_chia_erc20_unwrap, + validate_evm_erc20_wrap, +) +from drivers.portal import BRIDGING_PUZZLE_HASH +from drivers.utils import raw_hash +from drivers.wrapped_assets import ( + get_burn_inner_puzzle_solution, + get_cat_burn_inner_puzzle, + get_cat_burner_puzzle, + get_cat_burner_puzzle_solution, + get_cat_minter_puzzle, + get_wrapped_tail, +) +from drivers.wrapped_cats import ( + get_locker_puzzle, + get_locker_solution, + get_p2_controller_puzzle_hash_inner_puzzle_hash, + get_unlocker_puzzle, +) + +PORTAL = bytes32(b"\x01" * 32) +ETH_BRIDGE = bytes.fromhex("22" * 20) +ETH_PORTAL = bytes.fromhex("11" * 20) +MILLIETH = bytes.fromhex("33" * 20) +BSE_BRIDGE = bytes.fromhex("66" * 20) +WRAPPED_CAT_ADDR = bytes.fromhex("99" * 20) +ASSET_A = left_pad_32(bytes.fromhex("aa" * 20)) +ASSET_B = left_pad_32(bytes.fromhex("cc" * 20)) +RECEIVER = bytes.fromhex("bb" * 20) +DEST_CHAIN = b"eth" +CAT_AMOUNT = 10_000 +BURNER_AMOUNT = 1 +BRIDGING_TOLL = 10**9 +ASSET_AMOUNT = 1_337_000 +CHANGE_AMOUNT = 10_000 + +Q_TAIL = Program.from_bytes(bytes.fromhex("ff0180")) + + +def _routes() -> BridgeRoutes: + return BridgeRoutes.from_config( + { + "xch": {"portal_launcher_id": PORTAL.hex()}, + "eth": { + "portal_address": "0x" + ETH_PORTAL.hex(), + "erc20_bridge_address": "0x" + ETH_BRIDGE.hex(), + "millieth_address": "0x" + MILLIETH.hex(), + "portal_impl_codehash": "0x" + "44" * 32, + }, + "bse": { + "portal_address": "0x" + "55" * 20, + "erc20_bridge_address": "0x" + BSE_BRIDGE.hex(), + "millieth_address": "0x" + "77" * 20, + "portal_impl_codehash": "0x" + "88" * 32, + }, + } + ) + + +def _coin_spend(coin: Coin, puzzle: Program, solution: Program) -> CoinSpend: + return CoinSpend( + coin, + SerializedProgram.from_bytes(bytes(puzzle)), + SerializedProgram.from_bytes(bytes(solution)), + ) + + +def _build_erc20_burn( + *, + asset: bytes = ASSET_A, + memo_asset: Optional[bytes] = None, + tail: Optional[Program] = None, + tail_hash_for_burner: Optional[bytes32] = None, + dest_chain: bytes = DEST_CHAIN, + dest_addr: bytes = ETH_BRIDGE, + include_cat: bool = True, + burner_puzzle: Optional[Program] = None, +) -> Tuple[Coin, CoinSpend, Optional[CoinSpend]]: + """Build burner + CAT melt CoinSpends and the bridging coin they create.""" + memo_asset = asset if memo_asset is None else memo_asset + burner_puzzle = burner_puzzle or get_cat_burner_puzzle(dest_chain, dest_addr) + burner_parent = bytes32(b"\x02" * 32) + burner_coin = Coin(burner_parent, burner_puzzle.get_tree_hash(), BURNER_AMOUNT) + + if tail is None: + tail = get_wrapped_tail(PORTAL, dest_chain, dest_addr, asset) + tail_hash = tail.get_tree_hash() + if tail_hash_for_burner is None: + tail_hash_for_burner = ( + get_wrapped_tail(PORTAL, dest_chain, dest_addr, memo_asset).get_tree_hash() + if memo_asset != asset + else tail_hash + ) + + inner = get_cat_burn_inner_puzzle( + dest_chain, dest_addr, asset, RECEIVER, BURNER_AMOUNT + ) + cat_puzzle = construct_cat_puzzle(CAT_MOD, tail_hash, inner, CAT_MOD_HASH) + cat_parent = bytes32(b"\x03" * 32) + cat_coin = Coin(cat_parent, cat_puzzle.get_tree_hash(), CAT_AMOUNT) + + cat_spend: Optional[CoinSpend] = None + if include_cat: + inner_sol = get_burn_inner_puzzle_solution( + burner_parent, cat_coin.name(), tail + ) + spendable = SpendableCAT( + cat_coin, + tail_hash, + inner, + inner_sol, + lineage_proof=LineageProof( + bytes32(b"\x04" * 32), bytes32(b"\x05" * 32), CAT_AMOUNT + ), + extra_delta=-CAT_AMOUNT, + limitations_program_reveal=tail, + limitations_solution=Program.to( + ( + raw_hash([b"\x01", RECEIVER]), + raw_hash([b"\x01", bytes([BURNER_AMOUNT])]), + ) + ), + ) + cat_spend = unsigned_spend_bundle_for_spendable_cats( + CAT_MOD, [spendable] + ).coin_spends[0] + + burner_sol = get_cat_burner_puzzle_solution( + cat_parent, + tail_hash_for_burner, + CAT_AMOUNT, + memo_asset, + RECEIVER, + burner_coin, + ) + burner_spend = _coin_spend(burner_coin, burner_puzzle, burner_sol) + bridging = Coin(burner_coin.name(), BRIDGING_PUZZLE_HASH, BURNER_AMOUNT) + return bridging, burner_spend, cat_spend + + +def _vault_inner_ph(asset_id: Optional[bytes32]) -> bytes32: + return get_p2_controller_puzzle_hash_inner_puzzle_hash( + get_unlocker_puzzle(DEST_CHAIN, WRAPPED_CAT_ADDR, PORTAL, asset_id).get_tree_hash() + ).get_tree_hash() + + +def _build_lock( + *, + asset_id: Optional[bytes32], + with_change: bool = False, + omit_settlement: bool = False, + empty_settlement_payments: bool = False, + spend_vault: bool = False, +) -> Tuple[Coin, CoinSpend, List[CoinSpend]]: + toll = BRIDGING_TOLL + locker_puzzle = get_locker_puzzle(DEST_CHAIN, WRAPPED_CAT_ADDR, PORTAL, asset_id) + locker_parent = bytes32(b"\x10" * 32) + locker_coin = Coin(locker_parent, locker_puzzle.get_tree_hash(), toll) + locker_sol = get_locker_solution( + toll, locker_coin.name(), ASSET_AMOUNT, RECEIVER + ) + locker_spend = _coin_spend(locker_coin, locker_puzzle, locker_sol) + bridging = Coin(locker_coin.name(), BRIDGING_PUZZLE_HASH, toll) + + vault_inner_ph = _vault_inner_ph(asset_id) + other: List[CoinSpend] = [] + if omit_settlement: + return bridging, locker_spend, other + + offer_parent = bytes32(b"\x11" * 32) + if empty_settlement_payments: + payments: list = [] + elif with_change and asset_id is not None: + change_ph = bytes32(b"\xcd" * 32) + payments = [ + [locker_coin.name(), [vault_inner_ph, ASSET_AMOUNT]], + [bytes32(b"\xee" * 32), [change_ph, CHANGE_AMOUNT]], + ] + else: + payments = [[locker_coin.name(), [vault_inner_ph, ASSET_AMOUNT]]] + + if asset_id is None: + offer_coin = Coin( + offer_parent, + OFFER_MOD_HASH, + ASSET_AMOUNT if payments else 1, + ) + settlement = _coin_spend(offer_coin, OFFER_MOD, Program.to(payments)) + else: + total = ASSET_AMOUNT + (CHANGE_AMOUNT if with_change else 0) + if empty_settlement_payments: + total = max(total, 1) + cat_offer_puzzle = construct_cat_puzzle( + CAT_MOD, asset_id, OFFER_MOD, CAT_MOD_HASH + ) + cat_offer_coin = Coin( + offer_parent, cat_offer_puzzle.get_tree_hash(), total + ) + spendable = SpendableCAT( + cat_offer_coin, + asset_id, + OFFER_MOD, + Program.to(payments), + lineage_proof=LineageProof( + bytes32(b"\x04" * 32), OFFER_MOD_HASH, total + ), + ) + settlement = unsigned_spend_bundle_for_spendable_cats( + CAT_MOD, [spendable] + ).coin_spends[0] + other.append(settlement) + + if spend_vault and payments: + if asset_id is None: + vault_ph = vault_inner_ph + vault_puzzle: Program = get_p2_controller_puzzle_hash_inner_puzzle_hash( + get_unlocker_puzzle( + DEST_CHAIN, WRAPPED_CAT_ADDR, PORTAL, asset_id + ).get_tree_hash() + ) + else: + vault_inner = get_p2_controller_puzzle_hash_inner_puzzle_hash( + get_unlocker_puzzle( + DEST_CHAIN, WRAPPED_CAT_ADDR, PORTAL, asset_id + ).get_tree_hash() + ) + vault_puzzle = construct_cat_puzzle( + CAT_MOD, asset_id, vault_inner, CAT_MOD_HASH + ) + vault_ph = vault_puzzle.get_tree_hash() + vault_coin = Coin(settlement.coin.name(), vault_ph, ASSET_AMOUNT) + # Spend the just-created vault in the same list; policy only needs the create. + vault_spend = _coin_spend( + vault_coin, vault_puzzle, Program.to([]) + ) + other.append(vault_spend) + + return bridging, locker_spend, other + + +def _wrap_views(locker_ph: bytes32): + try: + code = load_wrapped_cat_artifact_bytecode() + except FileNotFoundError: + return None + return dict( + wrapped_cat_runtime_code=code, + view_portal=left_pad_32(ETH_PORTAL), + view_other_chain=b"xch", + view_locker_puzzle_hash=locker_ph, + ) + + +# --------------------------------------------------------------------------- +# 1. Chia ERC-20 unwrap (burn) +# --------------------------------------------------------------------------- + + +class TestChiaErc20Unwrap: + def test_honest_full_melt_accepts(self): + bridging, burner, cat = _build_erc20_burn() + result = validate_chia_erc20_unwrap(bridging, burner, cat, _routes()) + assert result.kind == "accept" + assert result.reason == "chia erc20 unwrap" + + def test_q_tail_rejects(self): + bridging, burner, cat = _build_erc20_burn(tail=Q_TAIL) + result = validate_chia_erc20_unwrap(bridging, burner, cat, _routes()) + assert result.kind == "reject" + assert "tail" in result.reason.lower() or "cat" in result.reason.lower() + + def test_tail_token_a_memo_token_b_rejects(self): + bridging, burner, cat = _build_erc20_burn( + asset=ASSET_A, memo_asset=ASSET_B + ) + result = validate_chia_erc20_unwrap(bridging, burner, cat, _routes()) + assert result.kind == "reject" + + def test_memo_destination_chain_swapped_rejects(self): + bridging, burner, cat = _build_erc20_burn() + real = spend_policy._extract_bridging_memo( + Program.from_bytes(bytes(burner.puzzle_reveal)), + Program.from_bytes(bytes(burner.solution)), + burner.coin.amount, + ) + assert real is not None + + def _swapped(*_a, **_k): + _chain, dest, contents = real + return b"bse", dest, contents + + with patch.object(spend_policy, "_extract_bridging_memo", _swapped): + result = validate_chia_erc20_unwrap(bridging, burner, cat, _routes()) + assert result.kind == "reject" + assert "memo destination" in result.reason + + def test_partial_melt_extra_delta_rejects(self): + bridging, burner, cat = _build_erc20_burn() + assert cat is not None + sol_args = list(Program.from_bytes(bytes(cat.solution)).as_iter()) + sol_args[6] = Program.to(0) # not -amount + cat = CoinSpend( + cat.coin, + cat.puzzle_reveal, + SerializedProgram.from_bytes(bytes(Program.to(sol_args))), + ) + result = validate_chia_erc20_unwrap(bridging, burner, cat, _routes()) + assert result.kind == "reject" + assert "extra_delta" in result.reason + + def test_missing_cat_spend_rejects(self): + bridging, burner, _ = _build_erc20_burn(include_cat=False) + result = validate_chia_erc20_unwrap(bridging, burner, None, _routes()) + assert result.kind == "reject" + assert result.reason == "missing CAT spend" + + def test_parent_not_burner_rejects(self): + _, _, cat = _build_erc20_burn() + fake = CoinSpend( + Coin(bytes32(b"\x02" * 32), Program.to(1).get_tree_hash(), BURNER_AMOUNT), + SerializedProgram.from_bytes(bytes(Program.to(1))), + SerializedProgram.from_bytes(bytes(Program.to([]))), + ) + bridging = Coin(fake.coin.name(), BRIDGING_PUZZLE_HASH, fake.coin.amount) + result = validate_chia_erc20_unwrap(bridging, fake, cat, _routes()) + assert result.kind == "reject" + assert "CAT_BURNER_MOD" in result.reason or "parent" in result.reason + + +# --------------------------------------------------------------------------- +# 2. Chia CAT / XCH wrap (lock) +# --------------------------------------------------------------------------- + + +class TestChiaCatWrap: + def test_honest_cat_lock_with_change_accepts(self): + views = _wrap_views(bytes32(b"\x00" * 32)) + if views is None: + pytest.skip("WrappedCAT artifacts missing") + asset_id = bytes32(b"\xab" * 32) + bridging, locker, other = _build_lock(asset_id=asset_id, with_change=True) + views["view_locker_puzzle_hash"] = locker.coin.puzzle_hash + result = validate_chia_cat_wrap( + bridging, locker, other, _routes(), **views + ) + assert result.kind == "accept" + + def test_honest_xch_lock_accepts(self): + views = _wrap_views(bytes32(b"\x00" * 32)) + if views is None: + pytest.skip("WrappedCAT artifacts missing") + bridging, locker, other = _build_lock(asset_id=None) + views["view_locker_puzzle_hash"] = locker.coin.puzzle_hash + result = validate_chia_cat_wrap( + bridging, locker, other, _routes(), **views + ) + assert result.kind == "accept" + + def test_missing_vault_settlement_rejects(self): + views = _wrap_views(bytes32(b"\x00" * 32)) + if views is None: + # Structural path still rejects before / without bytecode when views missing → retry + bridging, locker, other = _build_lock( + asset_id=None, omit_settlement=True + ) + result = validate_chia_cat_wrap( + bridging, + locker, + other, + _routes(), + wrapped_cat_runtime_code=None, + view_portal=None, + view_other_chain=None, + view_locker_puzzle_hash=None, + ) + assert result.kind == "retry" + return + bridging, locker, other = _build_lock(asset_id=None, omit_settlement=True) + views["view_locker_puzzle_hash"] = locker.coin.puzzle_hash + result = validate_chia_cat_wrap( + bridging, locker, other, _routes(), **views + ) + assert result.kind == "reject" + assert "settlement" in result.reason + + def test_announcement_without_vault_create_rejects(self): + views = _wrap_views(bytes32(b"\x00" * 32)) + if views is None: + pytest.skip("WrappedCAT artifacts missing") + bridging, locker, other = _build_lock( + asset_id=None, empty_settlement_payments=True + ) + views["view_locker_puzzle_hash"] = locker.coin.puzzle_hash + result = validate_chia_cat_wrap( + bridging, locker, other, _routes(), **views + ) + assert result.kind == "reject" + assert "settlement" in result.reason + + def test_vault_spent_in_same_list_still_accepts(self): + views = _wrap_views(bytes32(b"\x00" * 32)) + if views is None: + pytest.skip("WrappedCAT artifacts missing") + bridging, locker, other = _build_lock(asset_id=None, spend_vault=True) + views["view_locker_puzzle_hash"] = locker.coin.puzzle_hash + result = validate_chia_cat_wrap( + bridging, locker, other, _routes(), **views + ) + assert result.kind == "accept" + + def test_parent_not_locker_rejects(self): + fake = CoinSpend( + Coin(bytes32(b"\x10" * 32), Program.to(1).get_tree_hash(), BRIDGING_TOLL), + SerializedProgram.from_bytes(bytes(Program.to(1))), + SerializedProgram.from_bytes(bytes(Program.to([]))), + ) + bridging = Coin(fake.coin.name(), BRIDGING_PUZZLE_HASH, BRIDGING_TOLL) + result = validate_chia_cat_wrap( + bridging, + fake, + [], + _routes(), + wrapped_cat_runtime_code=b"\x00", + view_portal=left_pad_32(ETH_PORTAL), + view_other_chain=b"xch", + view_locker_puzzle_hash=fake.coin.puzzle_hash, + ) + assert result.kind == "reject" + assert "LOCKER_MOD" in result.reason or "parent" in result.reason + + def test_bytecode_mismatch_rejects_when_artifacts_exist(self): + try: + code = load_wrapped_cat_artifact_bytecode() + except FileNotFoundError: + pytest.skip("WrappedCAT artifacts missing") + bridging, locker, other = _build_lock(asset_id=None) + result = validate_chia_cat_wrap( + bridging, + locker, + other, + _routes(), + wrapped_cat_runtime_code=code + b"\xff", + view_portal=left_pad_32(ETH_PORTAL), + view_other_chain=b"xch", + view_locker_puzzle_hash=locker.coin.puzzle_hash, + ) + assert result.kind == "reject" + assert "bytecode" in result.reason + + +# --------------------------------------------------------------------------- +# 3. EVM ERC-20 wrap tip / balance math +# --------------------------------------------------------------------------- + + +class TestEvmErc20Wrap: + def _dest(self) -> bytes32: + return get_cat_minter_puzzle(PORTAL, b"eth", ETH_BRIDGE).get_tree_hash() + + def _base_kwargs(self, gross: int = 10_000, tip_bps: int = 30, decimals: int = 18): + tip = compute_bridge_tip(gross, tip_bps) + net = gross - tip + factor = 10 ** (decimals - 3) + asset = ASSET_A + return dict( + routes=_routes(), + source_chain=b"eth", + source=left_pad_32(ETH_BRIDGE), + destination=self._dest(), + destination_chain=b"xch", + contents=[asset, left_pad_32(RECEIVER), net.to_bytes(32, "big")], + tx_status=1, + tip_bps=tip_bps, + decimals=decimals, + gross_mojo_amount=gross, + bridge_token_balance_diff=net * factor, + balance_diff_asset=asset, + ) + + def test_tip_math_and_matching_diff_accepts(self): + # tip = amount * bps / 10000; if 0 then tip is 1; message amount == net + assert compute_bridge_tip(10_000, 30) == 30 + assert compute_bridge_tip(1, 30) == 1 + result = validate_evm_erc20_wrap(**self._base_kwargs()) + assert result.kind == "accept" + + def test_decimals_below_3_rejects(self): + kwargs = self._base_kwargs(decimals=2) + # recompute factor for contents net still ok; validator rejects decimals + result = validate_evm_erc20_wrap(**kwargs) + assert result.kind == "reject" + assert "decimals" in result.reason + + def test_tip_bps_out_of_range_rejects(self): + for bad in (0, 1001): + kwargs = self._base_kwargs(tip_bps=30) + kwargs["tip_bps"] = bad + result = validate_evm_erc20_wrap(**kwargs) + assert result.kind == "reject" + assert "tip_bps" in result.reason + + def test_balance_diff_none_retries(self): + kwargs = self._base_kwargs() + kwargs["bridge_token_balance_diff"] = None + result = validate_evm_erc20_wrap(**kwargs) + assert result.kind == "retry" + + def test_balance_diff_asset_mismatch_rejects(self): + kwargs = self._base_kwargs() + kwargs["balance_diff_asset"] = ASSET_B + result = validate_evm_erc20_wrap(**kwargs) + assert result.kind == "reject" + assert "balance diff asset" in result.reason + + def test_message_amount_must_equal_net(self): + kwargs = self._base_kwargs(gross=10_000, tip_bps=30) + wrong_net = 10_000 # not net after tip + kwargs["contents"] = [ + ASSET_A, + left_pad_32(RECEIVER), + wrong_net.to_bytes(32, "big"), + ] + result = validate_evm_erc20_wrap(**kwargs) + assert result.kind == "reject" + assert "message amount" in result.reason + + def test_ether_amount_after_toll_divisible(self): + gross = 1000 + tip_bps = 30 + tip = compute_bridge_tip(gross, tip_bps) + net = gross - tip + decimals = 18 + factor = 10 ** (decimals - 3) + ratio = 10**12 + toll = 10**15 + amount_after_toll = gross * ratio * factor + msg_value = amount_after_toll + toll + contents = [ + left_pad_32(MILLIETH), + left_pad_32(RECEIVER), + net.to_bytes(32, "big"), + ] + ok = validate_evm_erc20_wrap( + routes=_routes(), + source_chain=b"eth", + source=left_pad_32(ETH_BRIDGE), + destination=self._dest(), + destination_chain=b"xch", + contents=contents, + tx_status=1, + tip_bps=tip_bps, + decimals=decimals, + gross_mojo_amount=gross, + bridge_token_balance_diff=net * factor, + balance_diff_asset=left_pad_32(MILLIETH), + is_bridge_ether=True, + msg_value=msg_value, + message_toll=toll, + weth_to_eth_ratio=ratio, + ) + assert ok.kind == "accept" + + bad = validate_evm_erc20_wrap( + routes=_routes(), + source_chain=b"eth", + source=left_pad_32(ETH_BRIDGE), + destination=self._dest(), + destination_chain=b"xch", + contents=contents, + tx_status=1, + tip_bps=tip_bps, + decimals=decimals, + gross_mojo_amount=gross, + bridge_token_balance_diff=net * factor, + balance_diff_asset=left_pad_32(MILLIETH), + is_bridge_ether=True, + msg_value=msg_value + 1, + message_toll=toll, + weth_to_eth_ratio=ratio, + ) + assert bad.kind == "reject" + assert "wethToEthRatio" in bad.reason + + +# --------------------------------------------------------------------------- +# 4. evm_address_from_bytes32 +# --------------------------------------------------------------------------- + + +class TestEvmAddressFromBytes32: + def test_left_padded_source_normalizes_to_20_bytes(self): + source = bytes.fromhex("000bA7E6824AA002033Df7BEDf8bAB72Fc6465e9") + assert len(source) == 20 + padded = left_pad_32(source) + assert len(padded) == 32 + out = evm_address_from_bytes32(padded) + assert len(out) == 20 + assert out == bytes.fromhex("000ba7e6824aa002033df7bedf8bab72fc6465e9") + assert out != out[-19:] # not truncated to 19 bytes + + def test_nonzero_prefix_rejects(self): + addr20 = bytes.fromhex("000bA7E6824AA002033Df7BEDf8bAB72Fc6465e9") + bad = b"\x01" + b"\x00" * 11 + addr20 + assert len(bad) == 32 + with pytest.raises(ValueError, match="non-zero prefix"): + evm_address_from_bytes32(bad) From 282ae66002207574c8f7783110cec49c799d1021 Mon Sep 17 00:00:00 2001 From: Yak Date: Thu, 24 Sep 2026 00:20:25 +0300 Subject: [PATCH 02/19] fixes --- commands/deployment.py | 6 +- commands/ffs.py | 8 +- commands/followers/sig.py | 18 +++- commands/policy_runtime.py | 12 ++- commands/rekey.py | 8 +- tests/test_portal.py | 16 ++-- tests/test_sig_and_block_spends.py | 137 +++++++++++++++++++++++++++++ tests/test_wrapped_assets.py | 14 +-- tests/test_wrapped_cats.py | 22 ++--- 9 files changed, 200 insertions(+), 41 deletions(-) create mode 100644 tests/test_sig_and_block_spends.py diff --git a/commands/deployment.py b/commands/deployment.py index 8f7a40d..ce12da8 100644 --- a/commands/deployment.py +++ b/commands/deployment.py @@ -13,7 +13,7 @@ from chia.util.keychain import bytes_to_mnemonic, mnemonic_to_seed from commands.keys import mnemonic_to_validator_pk from chia.types.blockchain_format.coin import Coin -from chia.types.coin_spend import CoinSpend +from chia.types.coin_spend import make_spend from drivers.multisig import get_multisig_inner_puzzle from drivers.portal import * from drivers.wrapped_assets import get_cat_minter_puzzle, get_cat_burner_puzzle, get_wrapped_tail @@ -233,7 +233,7 @@ async def securely_launch_singleton( launcher_parent_parent_solution = Program.to([ [nonce, [launcher_parent_puzzle_hash, 1]] ]) - launcher_parent_parent_spend = CoinSpend(launcher_parent_parent, launcher_parent_parent_puzzle, launcher_parent_parent_solution) + launcher_parent_parent_spend = make_spend(launcher_parent_parent, launcher_parent_parent_puzzle, launcher_parent_parent_solution) coin_spends.append(launcher_parent_parent_spend) # spend launcher coin @@ -261,7 +261,7 @@ async def securely_launch_singleton( # finally, spend launcher parent launcher_parent_solution = solution_for_conditions(Program.to(conditions)) - launcher_parent_spend = CoinSpend(launcher_parent, launcher_parent_puzzle, launcher_parent_solution) + launcher_parent_spend = make_spend(launcher_parent, launcher_parent_puzzle, launcher_parent_solution) coin_spends.append(launcher_parent_spend) def just_return_the_fing_key(arg: any): diff --git a/commands/ffs.py b/commands/ffs.py index 86b1a68..4208962 100644 --- a/commands/ffs.py +++ b/commands/ffs.py @@ -3,7 +3,7 @@ from commands.cli_wrappers import * from chia.rpc.full_node_rpc_client import FullNodeRpcClient from chia.util.bech32m import bech32_encode, convertbits, bech32_decode -from chia.types.coin_spend import CoinSpend +from chia.types.coin_spend import make_spend from chia.util.condition_tools import conditions_dict_for_solution from chia.types.blockchain_format.program import Program from chia.types.blockchain_format.coin import Coin @@ -154,7 +154,7 @@ async def partial_relay_message( [source_xch_coin.name(), [security_coin_puzzle_hash, 1]], ]) - source_coin_spend = CoinSpend( + source_coin_spend = make_spend( source_xch_coin, OFFER_MOD, source_coin_solution @@ -166,7 +166,7 @@ async def partial_relay_message( security_coin_spend = Program.to([]) - security_coin_spend = CoinSpend( + security_coin_spend = make_spend( security_coin, security_coin_puzzle, security_coin_spend @@ -221,7 +221,7 @@ async def partial_relay_message( ) portal_coin = Coin(parent_record.coin.name(), portal_puzzle_hash, 1) - portal_coin_spend = CoinSpend( + portal_coin_spend = make_spend( portal_coin, portal_puzzle, portal_solution diff --git a/commands/followers/sig.py b/commands/followers/sig.py index 49331b9..4f5de81 100644 --- a/commands/followers/sig.py +++ b/commands/followers/sig.py @@ -34,6 +34,14 @@ def encode_signature( return res +def _bech32_payload_to_bytes(encoded: str, max_length: int | None = None) -> bytes: + if max_length is None: + data = bech32_decode(encoded)[1] + else: + data = bech32_decode(encoded, max_length)[1] + return bytes(convertbits(data, 5, 8, False)) + + def decode_signature(enc_sig: str) -> Tuple[ bytes, # origin_chain bytes, # destination_chain @@ -42,13 +50,17 @@ def decode_signature(enc_sig: str) -> Tuple[ bytes # sig ]: parts = enc_sig.split("-") - route_data = convertbits(bech32_decode(parts[0], (32 + 3 + 3) * 2)[1], 5, 8, False) + route_data = _bech32_payload_to_bytes(parts[0], (32 + 3 + 3) * 2) origin_chain = route_data[:3] destination_chain = route_data[3:6] nonce = route_data[6:] - coin_id = convertbits(bech32_decode(parts[1])[1], 5, 8, False) - sig = convertbits(bech32_decode(parts[-1], 96 * 2)[1], 5, 8, False) + # xch→eth/bse signatures are encoded with no coin id: "route--sig". + if parts[1] == "": + coin_id = None + else: + coin_id = _bech32_payload_to_bytes(parts[1]) + sig = _bech32_payload_to_bytes(parts[-1], 96 * 2) return origin_chain, destination_chain, nonce, coin_id, sig diff --git a/commands/policy_runtime.py b/commands/policy_runtime.py index 90e33d1..060e28d 100644 --- a/commands/policy_runtime.py +++ b/commands/policy_runtime.py @@ -884,8 +884,16 @@ async def load_block_spends(node, height: int) -> Optional[List[CoinSpend]]: spend = await node.get_puzzle_and_solution( coin_record.coin.name(), coin_record.spent_block_index ) - if spend is not None: - spends.append(spend) + # A missing spend is an incomplete load. Returning the partial list + # makes CAT-wrap policy reject the message permanently. + if spend is None: + logging.info( + "block spend missing for coin %s at height %s; will retry", + coin_record.coin.name().hex(), + height, + ) + return None + spends.append(spend) return spends except Exception: logging.error("Failed to load block spends", exc_info=True) diff --git a/commands/rekey.py b/commands/rekey.py index 9987267..dfca404 100644 --- a/commands/rekey.py +++ b/commands/rekey.py @@ -5,7 +5,7 @@ from chia.types.blockchain_format.sized_bytes import bytes32 from chia.types.coin_record import CoinRecord from chia.rpc.full_node_rpc_client import FullNodeRpcClient -from chia.types.coin_spend import CoinSpend, compute_additions +from chia.types.coin_spend import CoinSpend, compute_additions, make_spend from chia.util.condition_tools import conditions_dict_for_solution from chia.types.blockchain_format.program import Program from chia.types.blockchain_format.coin import Coin @@ -317,7 +317,7 @@ async def broadcast_spend( [source_xch_coin.name(), [security_coin_puzzle_hash, 1]], ]) - source_coin_spend = CoinSpend( + source_coin_spend = make_spend( source_xch_coin, OFFER_MOD, source_coin_solution @@ -329,7 +329,7 @@ async def broadcast_spend( security_coin_spend = Program.to([]) - security_coin_spend = CoinSpend( + security_coin_spend = make_spend( security_coin, security_coin_puzzle, security_coin_spend @@ -390,7 +390,7 @@ async def broadcast_spend( ) portal_coin = Coin(parent_record.coin.name(), portal_puzzle_hash, 1) - portal_coin_spend = CoinSpend( + portal_coin_spend = make_spend( portal_coin, portal_puzzle, portal_solution diff --git a/tests/test_portal.py b/tests/test_portal.py index d948b07..bad5633 100644 --- a/tests/test_portal.py +++ b/tests/test_portal.py @@ -5,7 +5,7 @@ from chia.wallet.puzzles.singleton_top_layer_v1_1 import generate_launcher_coin from chia.wallet.puzzles.singleton_top_layer_v1_1 import \ launch_conditions_and_coinsol, solution_for_singleton, lineage_proof_for_coinsol -from chia.types.coin_spend import CoinSpend +from chia.types.coin_spend import make_spend from chia.consensus.default_constants import DEFAULT_CONSTANTS from chia.types.condition_opcodes import ConditionOpcode from chia.util.bech32m import decode_puzzle_hash @@ -124,7 +124,7 @@ async def test_receive_message_and_upgrade(self, setup, validator_set): [], 1 ) - portal_launcher_parent_spend = CoinSpend(portal_launcher_parent, one_puzzle, Program.to(conditions)) + portal_launcher_parent_spend = make_spend(portal_launcher_parent, one_puzzle, Program.to(conditions)) portal_creation_bundle = SpendBundle( [portal_launcher_parent_spend, portal_launcher_spend], @@ -187,7 +187,7 @@ async def test_receive_message_and_upgrade(self, setup, validator_set): ) portal_spend_bundle = SpendBundle( - [CoinSpend(portal, portal_full_puzzle, portal_solution)], + [make_spend(portal, portal_full_puzzle, portal_solution)], message_signature ) @@ -221,7 +221,7 @@ async def test_receive_message_and_upgrade(self, setup, validator_set): portal_inner_puzzle.get_tree_hash(), message_coin.name() ) - message_coin_spend = CoinSpend( + message_coin_spend = make_spend( message_coin, message_coin_puzzle, message_coin_solution @@ -233,7 +233,7 @@ async def test_receive_message_and_upgrade(self, setup, validator_set): [ConditionOpcode.CREATE_COIN, my_puzzle_hash, 1] ]) - message_claimer_spend = CoinSpend( + message_claimer_spend = make_spend( message_claimer, one_puzzle, message_claimer_solution @@ -287,7 +287,7 @@ async def test_receive_message_and_upgrade(self, setup, validator_set): portal_launcher_id, new_portal_inner_puzzle, ) - portal_update_spend = CoinSpend(new_portal, portal_puzzle, portal_solution) + portal_update_spend = make_spend(new_portal, portal_puzzle, portal_solution) sigs = get_validator_set_sigs( updater_delegated_puzzle.get_tree_hash(), @@ -357,7 +357,7 @@ async def test_receive_message_and_upgrade(self, setup, validator_set): portal_spend_bundle = SpendBundle( [ - CoinSpend(portal, portal_full_puzzle, portal_solution) + make_spend(portal, portal_full_puzzle, portal_solution) ], message_signature ) @@ -414,7 +414,7 @@ async def test_receive_message_and_upgrade(self, setup, validator_set): ) portal = Coin(portal.name(), portal_puzzle.get_tree_hash(), 1) - portal_update_spend2 = CoinSpend(portal, portal_puzzle, portal_solution) + portal_update_spend2 = make_spend(portal, portal_puzzle, portal_solution) sigs = get_validator_set_sigs( updater_delegated_puzzle.get_tree_hash(), diff --git a/tests/test_sig_and_block_spends.py b/tests/test_sig_and_block_spends.py new file mode 100644 index 0000000..2f45969 --- /dev/null +++ b/tests/test_sig_and_block_spends.py @@ -0,0 +1,137 @@ +"""decode_signature and block-spend loading. No full node, no network.""" + +from __future__ import annotations + +import pytest + +from commands.followers.sig import MessageBroadcaster, decode_signature, encode_signature +from commands.models import Message, setup_database +from commands.policy_runtime import load_block_spends + + +def test_decode_signature_round_trip_with_coin_id(): + nonce = bytes(range(32)) + coin_id = bytes(range(32, 64)) + sig = bytes(range(96)) + encoded = encode_signature(b"eth", b"xch", nonce, coin_id, sig) + + origin, dest, got_nonce, got_coin, got_sig = decode_signature(encoded) + + assert origin == b"eth" + assert dest == b"xch" + assert got_nonce == nonce + assert got_coin == coin_id + assert got_sig == sig + + +def test_decode_signature_round_trip_without_coin_id(): + nonce = b"\xab" * 32 + sig = b"\xcd" * 96 + encoded = encode_signature(b"xch", b"eth", nonce, None, sig) + assert encoded.split("-")[1] == "" + + origin, dest, got_nonce, got_coin, got_sig = decode_signature(encoded) + + assert origin == b"xch" + assert dest == b"eth" + assert got_nonce == nonce + assert got_coin is None + assert got_sig == sig + + +def test_clear_message_sig_resets_eth_bound_row(tmp_path, monkeypatch): + db_url = f"sqlite:///{tmp_path / 'msgs.db'}" + + def open_db(): + return setup_database(db_url) + + monkeypatch.setattr("commands.followers.sig.setup_database", open_db) + + nonce = b"\x11" * 32 + encoded = encode_signature(b"xch", b"bse", nonce, None, b"\x22" * 96) + db = open_db() + db.add(Message( + nonce=nonce, + source_chain=b"xch", + source=b"\x00" * 32, + destination_chain=b"bse", + destination=b"\x33" * 32, + contents=b"", + block_number=1, + sig=encoded.encode(), + )) + db.commit() + db.close() + + broadcaster = MessageBroadcaster.__new__(MessageBroadcaster) + broadcaster._clear_message_sig(encoded) + + db = open_db() + msg = db.query(Message).one() + assert msg.sig == b"" + db.close() + + +class _Record: + def __init__(self, header_hash: bytes): + self.header_hash = header_hash + + +class _Coin: + def __init__(self, name: bytes): + self._name = name + + def name(self) -> bytes: + return self._name + + +class _Removal: + def __init__(self, name: bytes, height: int): + self.coin = _Coin(name) + self.spent_block_index = height + + +class _Node: + def __init__(self, spends: dict, block_spends=None): + self.spends = spends + self.block_spends = block_spends + self.header = b"\xab" * 32 + + async def get_block_record_by_height(self, height: int): + return _Record(self.header) + + async def get_block_spends(self, header_hash: bytes): + return self.block_spends + + async def get_additions_and_removals(self, header_hash: bytes): + removals = [_Removal(name, 7) for name in self.spends] + return [], removals + + async def get_puzzle_and_solution(self, name: bytes, height: int): + return self.spends[name] + + +@pytest.mark.asyncio +async def test_load_block_spends_returns_none_when_any_spend_is_missing(): + present = object() + node = _Node({b"\x01" * 32: present, b"\x02" * 32: None}) + + assert await load_block_spends(node, 7) is None + + +@pytest.mark.asyncio +async def test_load_block_spends_returns_complete_fallback(): + first, second = object(), object() + node = _Node({b"\x01" * 32: first, b"\x02" * 32: second}) + + spends = await load_block_spends(node, 7) + + assert spends == [first, second] + + +@pytest.mark.asyncio +async def test_load_block_spends_prefers_block_spends_rpc(): + direct = [object()] + node = _Node({b"\x01" * 32: None}, block_spends=direct) + + assert await load_block_spends(node, 7) == direct diff --git a/tests/test_wrapped_assets.py b/tests/test_wrapped_assets.py index 5e0ef46..1ca3a6a 100644 --- a/tests/test_wrapped_assets.py +++ b/tests/test_wrapped_assets.py @@ -6,7 +6,7 @@ from chia.wallet.puzzles.singleton_top_layer_v1_1 import \ launch_conditions_and_coinsol, solution_for_singleton, lineage_proof_for_coinsol from chia.wallet.puzzles.singleton_top_layer_v1_1 import puzzle_for_singleton -from chia.types.coin_spend import CoinSpend +from chia.types.coin_spend import make_spend from chia.types.condition_opcodes import ConditionOpcode from chia.wallet.cat_wallet.cat_utils import construct_cat_puzzle from chia.wallet.cat_wallet.cat_utils import CAT_MOD @@ -64,7 +64,7 @@ async def test_wrapped_asset_mint_and_burn(self, setup): [], 1 ) - portal_launcher_parent_spend = CoinSpend(portal_launcher_parent, one_puzzle, Program.to(conditions)) + portal_launcher_parent_spend = make_spend(portal_launcher_parent, one_puzzle, Program.to(conditions)) portal_creation_bundle = SpendBundle( [portal_launcher_parent_spend, portal_launcher_spend], @@ -110,7 +110,7 @@ async def test_wrapped_asset_mint_and_burn(self, setup): ) - message_coin_creation_spend = CoinSpend( + message_coin_creation_spend = make_spend( portal, portal_full_puzzle, portal_solution @@ -141,7 +141,7 @@ async def test_wrapped_asset_mint_and_burn(self, setup): one_puzzle_hash, message_coin.name() ) - message_coin_spend = CoinSpend( + message_coin_spend = make_spend( message_coin, message_coin_puzzle, message_coin_solution @@ -154,7 +154,7 @@ async def test_wrapped_asset_mint_and_burn(self, setup): minter_coin.name(), message_coin.parent_coin_info ) - minter_coin_spend = CoinSpend( + minter_coin_spend = make_spend( minter_coin, minter_puzzle, minter_puzzle_solution @@ -308,7 +308,7 @@ async def test_wrapped_asset_mint_and_burn(self, setup): ETH_RECEIVER, burner_coin ) - burner_spend = CoinSpend( + burner_spend = make_spend( burner_coin, burner_puzzle, burner_solution @@ -322,7 +322,7 @@ async def test_wrapped_asset_mint_and_burn(self, setup): ) bridging_solution = Program.to([1]) - bridging_coin_spend = CoinSpend( + bridging_coin_spend = make_spend( bridging_coin, BRIDGING_PUZZLE, bridging_solution diff --git a/tests/test_wrapped_cats.py b/tests/test_wrapped_cats.py index 4017097..d42ae5a 100644 --- a/tests/test_wrapped_cats.py +++ b/tests/test_wrapped_cats.py @@ -6,7 +6,7 @@ from chia.wallet.puzzles.singleton_top_layer_v1_1 import \ launch_conditions_and_coinsol, solution_for_singleton, lineage_proof_for_coinsol from chia.wallet.puzzles.singleton_top_layer_v1_1 import puzzle_for_singleton -from chia.types.coin_spend import CoinSpend +from chia.types.coin_spend import CoinSpend, make_spend from chia.util.bech32m import decode_puzzle_hash from chia.types.condition_opcodes import ConditionOpcode from chia.wallet.cat_wallet.cat_utils import construct_cat_puzzle @@ -175,7 +175,7 @@ async def test_wrapped_cats_locker(self, setup, with_xch): xch_source_coin_solution = Program.to(notarized_payments) - xch_source_coin_spend = CoinSpend( + xch_source_coin_spend = make_spend( xch_source_coin, OFFER_MOD, xch_source_coin_solution @@ -190,7 +190,7 @@ async def test_wrapped_cats_locker(self, setup, with_xch): ETH_RECEIVER ) - locker_coin_spend = CoinSpend( + locker_coin_spend = make_spend( locker_coin, locker_puzzle, locker_coin_solution @@ -205,7 +205,7 @@ async def test_wrapped_cats_locker(self, setup, with_xch): ) bridging_solution = Program.to([ BRIDGING_TOLL ]) - bridging_coin_spend = CoinSpend( + bridging_coin_spend = make_spend( bridging_coin, BRIDGING_PUZZLE, bridging_solution @@ -279,7 +279,7 @@ async def test_wrapped_cats_unlocker(self, setup, with_xch, with_change, multipl [], 1 ) - portal_launcher_parent_spend = CoinSpend(portal_launcher_parent, one_puzzle, Program.to(conditions)) + portal_launcher_parent_spend = make_spend(portal_launcher_parent, one_puzzle, Program.to(conditions)) portal_creation_bundle = SpendBundle( [portal_launcher_parent_spend, portal_launcher_spend], @@ -332,6 +332,8 @@ async def test_wrapped_cats_unlocker(self, setup, with_xch, with_change, multipl total_amount = BRIDGED_ASSET_AMOUNT + (BRIDGED_ASSET_CHANGE if with_change else 0) await wallet.send_transaction(1, total_amount, vault_addr, get_tx_config(1)) else: + while not await wallet.get_synced(): + time.sleep(0.05) if multiple_coins: coin3_amount = BRIDGED_ASSET_AMOUNT3 + (BRIDGED_ASSET_CHANGE if with_change else 0) await wallet.cat_spend(cat_wallet_id, get_tx_config(1), amount=BRIDGED_ASSET_AMOUNT1, inner_address=vault_addr) @@ -396,7 +398,7 @@ async def test_wrapped_cats_unlocker(self, setup, with_xch, with_change, multipl portal_inner_solution ) - message_coin_creation_spend = CoinSpend( + message_coin_creation_spend = make_spend( portal, portal_full_puzzle, portal_solution @@ -451,7 +453,7 @@ async def test_wrapped_cats_unlocker(self, setup, with_xch, with_change, multipl [xch_source_coin.name(), [unlocker_puzzle_hash, 1]] ]) - xch_source_coin_spend = CoinSpend( + xch_source_coin_spend = make_spend( xch_source_coin, OFFER_MOD, xch_source_coin_solution @@ -475,7 +477,7 @@ async def test_wrapped_cats_unlocker(self, setup, with_xch, with_change, multipl [(vault_coin.coin.parent_coin_info, vault_coin.coin.amount) for vault_coin in vault_coins] ) - unlocker_coin_spend = CoinSpend( + unlocker_coin_spend = make_spend( unlocker_coin, unlocker_puzzle, unlocker_coin_solution @@ -503,7 +505,7 @@ async def test_wrapped_cats_unlocker(self, setup, with_xch, with_change, multipl Program.to([]) ) - spend = CoinSpend( + spend = make_spend( vault_coin.coin, vault_full_puzzle, solution @@ -553,7 +555,7 @@ async def test_wrapped_cats_unlocker(self, setup, with_xch, with_change, multipl message_coin.name() ) - message_coin_spend = CoinSpend( + message_coin_spend = make_spend( message_coin, message_coin_puzzle, message_coin_solution From daf2d64b499c380c4bd33087929bad30723d218e Mon Sep 17 00:00:00 2001 From: Yak Date: Wed, 30 Sep 2026 11:28:25 +0300 Subject: [PATCH 03/19] more checks, just to be safe --- commands/followers/eth_follower.py | 20 +++- commands/followers/xch_follower.py | 37 +++++++- commands/spend_policy.py | 148 ++++++++++++++++++++++++++++- tests/test_spend_policy.py | 122 +++++++++++++++++++++++- 4 files changed, 321 insertions(+), 6 deletions(-) diff --git a/commands/followers/eth_follower.py b/commands/followers/eth_follower.py index 7d0c613..04c0ec6 100644 --- a/commands/followers/eth_follower.py +++ b/commands/followers/eth_follower.py @@ -6,7 +6,12 @@ from eth_account.messages import encode_typed_data from commands.followers.sig import encode_signature from commands.control_channel import ControlConfig -from commands.spend_policy import REJECTED_SIG, BridgeRoutes, PolicyResult +from commands.spend_policy import ( + REJECTED_SIG, + BridgeRoutes, + PolicyResult, + chia_row_matches_verified, +) from commands.policy_runtime import ( apply_policy_result, check_portal_impl, @@ -422,6 +427,19 @@ def web3_for_chain(chain: bytes) -> Optional[AsyncWeb3]: web3_for_chain=web3_for_chain, evm_block_number=None, ) + if result.kind == "accept": + verified = result.verified + if verified is None or not chia_row_matches_verified( + source=bytes(message.source), + destination_chain=bytes(message.destination_chain), + destination=bytes(message.destination), + contents=split_message_contents(bytes(message.contents or b"")), + verified=verified, + ): + return apply_policy_result( + message, + PolicyResult("retry", "signed fields do not match verified chia spend"), + ) return apply_policy_result(message, result) except Exception: logging.error("Chia policy recheck failed", exc_info=True) diff --git a/commands/followers/xch_follower.py b/commands/followers/xch_follower.py index 0720627..a887a78 100644 --- a/commands/followers/xch_follower.py +++ b/commands/followers/xch_follower.py @@ -11,7 +11,13 @@ from chia.types.coin_record import CoinRecord from commands.followers.sig import encode_signature, decode_signature from commands.control_channel import ControlConfig -from commands.spend_policy import REJECTED_SIG, BridgeRoutes, evm_address_from_bytes32 +from commands.spend_policy import ( + REJECTED_SIG, + BridgeRoutes, + decode_message_sent_fields, + evm_address_from_bytes32, + evm_log_matches_row, +) from commands.policy_runtime import ( apply_policy_result, control_allows_start, @@ -289,7 +295,34 @@ async def _recheck_evm_source_policy(self, db, message: Message) -> bool: logs = list(logs) if not logs: return apply_policy_result(message, PolicyResult("retry", "MessageSent not found for recheck")) - event = logs[0] + row_contents = split_message_contents(bytes(message.contents or b"")) + matching = [] + for event in logs: + decoded = decode_message_sent_fields(event) + if decoded is None: + continue + log_nonce, log_source, log_destination_chain, log_destination, log_contents = decoded + if evm_log_matches_row( + nonce=bytes(message.nonce), + source=bytes(message.source), + destination_chain=bytes(message.destination_chain), + destination=bytes(message.destination), + contents=row_contents, + log_nonce=log_nonce, + log_source=log_source, + log_destination_chain=log_destination_chain, + log_destination=log_destination, + log_contents=log_contents, + ): + matching.append(event) + if len(matching) != 1: + reason = ( + "multiple MessageSent logs match row" + if len(matching) > 1 + else "MessageSent fields do not match row" + ) + return apply_policy_result(message, PolicyResult("retry", reason)) + event = matching[0] tx_hash = event["transactionHash"].hex() if not tx_hash.startswith("0x"): tx_hash = "0x" + tx_hash diff --git a/commands/spend_policy.py b/commands/spend_policy.py index 2d3ac1e..f48ee7d 100644 --- a/commands/spend_policy.py +++ b/commands/spend_policy.py @@ -39,10 +39,19 @@ PolicyKind = Literal["accept", "reject", "retry", "hold"] +@dataclass(frozen=True) +class VerifiedChiaMessage: + source: bytes + destination_chain: bytes + destination: bytes + contents: Tuple[bytes, ...] + + @dataclass(frozen=True) class PolicyResult: kind: PolicyKind reason: str + verified: Optional[VerifiedChiaMessage] = None @dataclass(frozen=True) @@ -98,6 +107,125 @@ def left_pad_32(value: bytes) -> bytes: return value +def signed_destination_address(destination: bytes) -> bytes: + """Last 20 bytes, the address the Base signer puts in typed data.""" + raw = destination.hex() + if len(raw) < 40: + raw = "0" * (40 - len(raw)) + raw + else: + raw = raw[-40:] + return bytes.fromhex(raw) + + +def _canonical_contents(words: Sequence[bytes]) -> Optional[Tuple[bytes, ...]]: + canonical = [] + for word in words: + if len(word) > 32: + return None + canonical.append(left_pad_32(word)) + return tuple(canonical) + + +def _verified_chia_message( + *, + source: bytes, + destination_chain: bytes, + destination: bytes, + contents: Sequence[bytes], +) -> Optional[VerifiedChiaMessage]: + canonical = _canonical_contents(contents) + if canonical is None or len(source) != 32: + return None + return VerifiedChiaMessage( + source=source, + destination_chain=destination_chain, + destination=destination, + contents=canonical, + ) + + +def chia_row_matches_verified( + *, + source: bytes, + destination_chain: bytes, + destination: bytes, + contents: Sequence[bytes], + verified: VerifiedChiaMessage, +) -> bool: + if source != verified.source: + return False + if destination_chain != verified.destination_chain: + return False + if signed_destination_address(destination) != signed_destination_address(verified.destination): + return False + if len(contents) != len(verified.contents): + return False + return tuple(contents) == verified.contents + + +def normalize_signed_evm_source(source: bytes) -> Optional[bytes]: + """20-byte address, using the same padding rules as the Chia signer.""" + if len(source) > 32: + dropped = source[:-32] + if dropped != b"\x00" * len(dropped): + return None + source = source[-32:] + try: + return evm_address_from_bytes32(source) + except ValueError: + return None + + +def _event_atom(value) -> bytes: + if isinstance(value, str): + if value.startswith(("0x", "0X")): + return bytes.fromhex(value[2:]) + return value.encode() + return bytes(value) + + +def decode_message_sent_fields( + event, +) -> Optional[Tuple[bytes, bytes, bytes, bytes, Tuple[bytes, ...]]]: + """nonce, source, destination_chain, destination, contents from a MessageSent log.""" + try: + args = event["args"] + source = _event_atom(args["source"]) + nonce = _event_atom(args["nonce"]) + destination_chain = _event_atom(args["destination_chain"]) + destination = _event_atom(args["destination"]) + contents = tuple(_event_atom(word) for word in args["contents"]) + except Exception: + return None + return nonce, source, destination_chain, destination, contents + + +def evm_log_matches_row( + *, + nonce: bytes, + source: bytes, + destination_chain: bytes, + destination: bytes, + contents: Sequence[bytes], + log_nonce: bytes, + log_source: bytes, + log_destination_chain: bytes, + log_destination: bytes, + log_contents: Sequence[bytes], +) -> bool: + if bytes(nonce) != bytes(log_nonce): + return False + row_source = normalize_signed_evm_source(bytes(source)) + event_source = normalize_signed_evm_source(bytes(log_source)) + if row_source is None or event_source is None or row_source != event_source: + return False + if bytes(destination_chain) != bytes(log_destination_chain): + return False + if bytes(destination) != bytes(log_destination): + return False + return tuple(bytes(word) for word in contents) == tuple(bytes(word) for word in log_contents) + + def compare_portal_impl(actual_codehash: str, expected_codehash: str) -> PolicyResult: if _norm_hex(actual_codehash) != _norm_hex(expected_codehash): return PolicyResult("hold", "portal impl codehash mismatch") @@ -362,7 +490,15 @@ def validate_chia_erc20_unwrap( if not _announcements_tie(burner_conds, inner_conds, burner_spend.coin.name(), cat_spend.coin.name()): return PolicyResult("reject", "burner/CAT announcements do not tie") - return PolicyResult("accept", "chia erc20 unwrap") + verified = _verified_chia_message( + source=bytes(burner_spend.coin.puzzle_hash), + destination_chain=memo_chain, + destination=memo_dest, + contents=memo_contents, + ) + if verified is None: + return PolicyResult("reject", "memo content longer than 32 bytes") + return PolicyResult("accept", "chia erc20 unwrap", verified) except Exception as exc: return PolicyResult("reject", f"chia erc20 unwrap parse error: {exc}") @@ -498,7 +634,15 @@ def validate_chia_cat_wrap( if settlement is None: return PolicyResult("reject", "settlement offer spend not found") - return PolicyResult("accept", "chia cat/xch wrap") + verified = _verified_chia_message( + source=bytes(locker_spend.coin.puzzle_hash), + destination_chain=memo_chain, + destination=memo_dest, + contents=memo_contents, + ) + if verified is None: + return PolicyResult("reject", "memo content longer than 32 bytes") + return PolicyResult("accept", "chia cat/xch wrap", verified) except Exception as exc: return PolicyResult("reject", f"chia cat wrap parse error: {exc}") diff --git a/tests/test_spend_policy.py b/tests/test_spend_policy.py index b7fab07..0e27026 100644 --- a/tests/test_spend_policy.py +++ b/tests/test_spend_policy.py @@ -24,10 +24,15 @@ from commands import spend_policy from commands.spend_policy import ( BridgeRoutes, + chia_row_matches_verified, compute_bridge_tip, + decode_message_sent_fields, evm_address_from_bytes32, + evm_log_matches_row, left_pad_32, load_wrapped_cat_artifact_bytecode, + VerifiedChiaMessage, + signed_destination_address, validate_chia_cat_wrap, validate_chia_erc20_unwrap, validate_evm_erc20_wrap, @@ -293,12 +298,78 @@ def test_honest_full_melt_accepts(self): result = validate_chia_erc20_unwrap(bridging, burner, cat, _routes()) assert result.kind == "accept" assert result.reason == "chia erc20 unwrap" + verified = result.verified + assert verified is not None + assert verified.source == bytes(burner.coin.puzzle_hash) + assert verified.destination_chain == DEST_CHAIN + assert signed_destination_address(verified.destination) == ETH_BRIDGE + assert verified.contents == ( + ASSET_A, + left_pad_32(RECEIVER), + CAT_AMOUNT.to_bytes(32, "big"), + ) + assert chia_row_matches_verified( + source=verified.source, + destination_chain=verified.destination_chain, + destination=verified.destination, + contents=verified.contents, + verified=verified, + ) + + def test_row_asset_amount_or_receiver_must_match_memo(self): + bridging, burner, cat = _build_erc20_burn() + result = validate_chia_erc20_unwrap(bridging, burner, cat, _routes()) + verified = result.verified + assert verified is not None + asset, receiver, amount = verified.contents + other_asset = ASSET_B + other_receiver = left_pad_32(bytes.fromhex("ee" * 20)) + other_amount = (CAT_AMOUNT + 1).to_bytes(32, "big") + base = dict( + source=verified.source, + destination_chain=verified.destination_chain, + destination=left_pad_32(verified.destination), + verified=verified, + ) + assert chia_row_matches_verified(contents=verified.contents, **base) + assert not chia_row_matches_verified( + contents=(other_asset, receiver, amount), **base + ) + assert not chia_row_matches_verified( + contents=(asset, other_receiver, amount), **base + ) + assert not chia_row_matches_verified( + contents=(asset, receiver, other_amount), **base + ) + + def test_source_must_be_exact_puzzle_hash(self): + bridging, burner, cat = _build_erc20_burn() + result = validate_chia_erc20_unwrap(bridging, burner, cat, _routes()) + verified = result.verified + assert verified is not None + source = b"\x00" + verified.source[1:] + verified = VerifiedChiaMessage( + source=source, + destination_chain=verified.destination_chain, + destination=verified.destination, + contents=verified.contents, + ) + fields = dict( + destination_chain=verified.destination_chain, + destination=verified.destination, + contents=verified.contents, + verified=verified, + ) + assert chia_row_matches_verified(source=source, **fields) + assert not chia_row_matches_verified(source=source[1:], **fields) + assert not chia_row_matches_verified(source=source + b"\x00", **fields) def test_q_tail_rejects(self): bridging, burner, cat = _build_erc20_burn(tail=Q_TAIL) result = validate_chia_erc20_unwrap(bridging, burner, cat, _routes()) assert result.kind == "reject" - assert "tail" in result.reason.lower() or "cat" in result.reason.lower() + assert result.reason == "tail_hash_hash mismatch" + assert result.verified is None def test_tail_token_a_memo_token_b_rejects(self): bridging, burner, cat = _build_erc20_burn( @@ -375,6 +446,15 @@ def test_honest_cat_lock_with_change_accepts(self): bridging, locker, other, _routes(), **views ) assert result.kind == "accept" + verified = result.verified + assert verified is not None + assert verified.source == bytes(locker.coin.puzzle_hash) + assert verified.destination_chain == DEST_CHAIN + assert signed_destination_address(verified.destination) == WRAPPED_CAT_ADDR + assert verified.contents == ( + left_pad_32(RECEIVER), + ASSET_AMOUNT.to_bytes(32, "big"), + ) def test_honest_xch_lock_accepts(self): views = _wrap_views(bytes32(b"\x00" * 32)) @@ -635,3 +715,43 @@ def test_nonzero_prefix_rejects(self): assert len(bad) == 32 with pytest.raises(ValueError, match="non-zero prefix"): evm_address_from_bytes32(bad) + + +class TestEvmLogFieldMatch: + def test_identical_log_matches_and_receiver_mismatch_does_not(self): + nonce = b"\x11" * 32 + source = ETH_BRIDGE + row_source = b"\x00" * (64 - len(source)) + source + destination = b"\x42" * 32 + receiver = left_pad_32(RECEIVER) + amount = (1000).to_bytes(32, "big") + contents = (ASSET_A, receiver, amount) + event = { + "args": { + "nonce": "0x" + nonce.hex(), + "source": "0x" + source.hex(), + "destination_chain": b"xch", + "destination": "0x" + destination.hex(), + "contents": ["0x" + word.hex() for word in contents], + } + } + decoded = decode_message_sent_fields(event) + assert decoded is not None + log_nonce, log_source, log_chain, log_dest, log_contents = decoded + kwargs = dict( + nonce=nonce, + source=row_source, + destination_chain=b"xch", + destination=destination, + contents=contents, + log_nonce=log_nonce, + log_source=log_source, + log_destination_chain=log_chain, + log_destination=log_dest, + log_contents=log_contents, + ) + assert evm_log_matches_row(**kwargs) + other_receiver = left_pad_32(bytes.fromhex("ee" * 20)) + assert not evm_log_matches_row( + **{**kwargs, "log_contents": (ASSET_A, other_receiver, amount)} + ) From f8d920cd9c561c4b1a4f186cb608acb85bdd0471 Mon Sep 17 00:00:00 2001 From: Yak Date: Thu, 1 Oct 2026 09:20:37 +0300 Subject: [PATCH 04/19] update, validation --- commands/policy_runtime.py | 69 ++++++++++++++++++++++-- commands/spend_policy.py | 14 ++--- tests/test_spend_policy.py | 105 +++++++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 9 deletions(-) diff --git a/commands/policy_runtime.py b/commands/policy_runtime.py index 060e28d..826de63 100644 --- a/commands/policy_runtime.py +++ b/commands/policy_runtime.py @@ -24,11 +24,13 @@ compare_portal_impl, evm_address_from_bytes32, left_pad_32, + match_wrapped_cat_bytecode, validate_chia_cat_wrap, validate_chia_erc20_unwrap, validate_evm_cat_unwrap, validate_evm_erc20_wrap, ) +from drivers.utils import raw_hash from drivers.wrapped_assets import ( CAT_BURNER_MOD, get_cat_burn_inner_puzzle, @@ -234,9 +236,10 @@ async def portal_impl_codehash( storage = await web3.eth.get_storage_at( portal, EIP1967_IMPL_SLOT, block_identifier=block_number ) - impl = storage[-20:] + impl = bytes(storage[-20:]) if impl == b"\x00" * 20: return None + # HexBytes.hex() already includes 0x. bytes.hex() does not. code = await web3.eth.get_code( Web3.to_checksum_address("0x" + impl.hex()), block_identifier=block_number ) @@ -490,9 +493,33 @@ async def fetch_token_balance_diff( return None +def _bytecode_is_not_wrapped_cat(code: bytes) -> bool: + """True when code is present and cannot be the current WrappedCAT. + + An artifact-load failure returns False so the caller retries instead of + rejecting a contract it could not compare. + """ + try: + return not match_wrapped_cat_bytecode(code) + except Exception: + return False + + async def read_wrapped_cat_views( web3: AsyncWeb3, address: bytes, block_number: int ) -> Tuple[Optional[bytes], Optional[bytes], Optional[bytes], Optional[bytes], Optional[bytes], Optional[int], Optional[int]]: + try: + code = bytes(await web3.eth.get_code(_checksum(address), block_identifier=block_number)) + except Exception: + logging.error("Failed to read WrappedCAT code", exc_info=True) + return None, None, None, None, None, None, None + + # Empty code and any other non-matching runtime are a definite reject. + # View calls are unnecessary, and a reverted portal() must not look like + # an RPC failure. + if _bytecode_is_not_wrapped_cat(code): + return code, None, None, None, None, None, None + try: contract = web3.eth.contract(address=_checksum(address), abi=WRAPPED_CAT_ABI) portal = await contract.functions.portal().call(block_identifier=block_number) @@ -501,9 +528,8 @@ async def read_wrapped_cat_views( unlocker = await contract.functions.unlockerPuzzleHash().call(block_identifier=block_number) tip = await contract.functions.tip().call(block_identifier=block_number) ratio = await contract.functions.mojoToTokenRatio().call(block_identifier=block_number) - code = await web3.eth.get_code(_checksum(address), block_identifier=block_number) return ( - bytes(code), + code, left_pad_32(bytes.fromhex(portal[2:] if isinstance(portal, str) else portal.hex())), _as_bytes3(other_chain), _as_bytes32(locker), @@ -524,6 +550,41 @@ def _decode_address(data: bytes, position: int) -> bytes: return data[position + 12:position + 32] +def _burner_tail_hash_hash_mismatch(burner_spend: CoinSpend, routes: BridgeRoutes) -> bool: + """True only when the burner solution parses and tail_hash_hash is wrong. + + A match, or a solution that cannot be parsed, returns False so the caller + can keep retrying the CAT coin lookup. + """ + try: + puzzle = Program.from_bytes(bytes(burner_spend.puzzle_reveal)) + solution = Program.from_bytes(bytes(burner_spend.solution)) + _, curried = puzzle.uncurry() + curry_args = list(curried.as_iter()) + dest_chain = curry_args[3].as_atom() + dest_addr = curry_args[4].as_atom() + sol_args = list(solution.as_iter()) + tail_hash_hash = sol_args[1].as_atom() + memo_asset = sol_args[3].as_atom() + if ( + dest_chain is None + or dest_addr is None + or tail_hash_hash is None + or memo_asset is None + ): + return False + wrapped_tail = get_wrapped_tail( + bytes32(routes.portal_launcher_id), + dest_chain, + dest_addr, + left_pad_32(memo_asset), + ) + expected = raw_hash([b"\x01", wrapped_tail.get_tree_hash()]) + return tail_hash_hash != expected + except Exception: + return False + + async def evaluate_chia_message_policy( *, bridging_coin: Coin, @@ -543,6 +604,8 @@ async def evaluate_chia_message_policy( if mod == CAT_BURNER_MOD: cat_spend = await _fetch_burner_cat_spend(node, parent_spend, routes) if cat_spend is None: + if _burner_tail_hash_hash_mismatch(parent_spend, routes): + return PolicyResult("reject", "tail_hash_hash mismatch") return PolicyResult("retry", "could not load CAT spend for burner") return validate_chia_erc20_unwrap(bridging_coin, parent_spend, cat_spend, routes) diff --git a/commands/spend_policy.py b/commands/spend_policy.py index f48ee7d..7cf5786 100644 --- a/commands/spend_policy.py +++ b/commands/spend_policy.py @@ -599,12 +599,7 @@ def validate_chia_cat_wrap( if bridging_coin.amount != locker_spend.coin.amount: return PolicyResult("reject", "bridging coin amount") - if ( - wrapped_cat_runtime_code is None - or view_portal is None - or view_other_chain is None - or view_locker_puzzle_hash is None - ): + if wrapped_cat_runtime_code is None: return PolicyResult("retry", "missing WrappedCAT bytecode or views") try: @@ -613,6 +608,13 @@ def validate_chia_cat_wrap( except Exception: return PolicyResult("retry", "could not load WrappedCAT artifact for mask compare") + if ( + view_portal is None + or view_other_chain is None + or view_locker_puzzle_hash is None + ): + return PolicyResult("retry", "missing WrappedCAT bytecode or views") + if evm_address_from_bytes32(left_pad_32(view_portal)) != evm_address_from_bytes32( left_pad_32(route.portal_address) ): diff --git a/tests/test_spend_policy.py b/tests/test_spend_policy.py index 0e27026..cd7d8de 100644 --- a/tests/test_spend_policy.py +++ b/tests/test_spend_policy.py @@ -22,6 +22,7 @@ from chia.wallet.trading.offer import OFFER_MOD, OFFER_MOD_HASH from commands import spend_policy +from commands.policy_runtime import evaluate_chia_message_policy from commands.spend_policy import ( BridgeRoutes, chia_row_matches_verified, @@ -429,6 +430,44 @@ def test_parent_not_burner_rejects(self): assert "CAT_BURNER_MOD" in result.reason or "parent" in result.reason +class _MissingCoinNode: + async def get_coin_record_by_name(self, _name): + return None + + +async def _evaluate_unloaded_burn(burner_kwargs: dict): + bridging, burner, _ = _build_erc20_burn(**burner_kwargs) + return await evaluate_chia_message_policy( + bridging_coin=bridging, + parent_spend=burner, + routes=_routes(), + node=_MissingCoinNode(), + block_spends=None, + web3_for_chain=lambda _chain: None, + evm_block_number=None, + ) + + +class TestUnloadedBurnerCatSpend: + @pytest.mark.asyncio + async def test_q_tail_missing_coin_rejects_tail_hash_hash_mismatch(self): + result = await _evaluate_unloaded_burn({"tail": Q_TAIL}) + assert result.kind == "reject" + assert result.reason == "tail_hash_hash mismatch" + + @pytest.mark.asyncio + async def test_other_tail_missing_coin_rejects_tail_hash_hash_mismatch(self): + result = await _evaluate_unloaded_burn({"tail": Program.to(1)}) + assert result.kind == "reject" + assert result.reason == "tail_hash_hash mismatch" + + @pytest.mark.asyncio + async def test_honest_wrapped_tail_missing_coin_retries(self): + result = await _evaluate_unloaded_burn({}) + assert result.kind == "retry" + assert result.reason == "could not load CAT spend for burner" + + # --------------------------------------------------------------------------- # 2. Chia CAT / XCH wrap (lock) # --------------------------------------------------------------------------- @@ -558,6 +597,40 @@ def test_bytecode_mismatch_rejects_when_artifacts_exist(self): assert result.kind == "reject" assert "bytecode" in result.reason + def test_empty_runtime_rejects_without_views(self): + try: + load_wrapped_cat_artifact_bytecode() + except FileNotFoundError: + pytest.skip("WrappedCAT artifacts missing") + bridging, locker, other = _build_lock(asset_id=None) + result = validate_chia_cat_wrap( + bridging, + locker, + other, + _routes(), + wrapped_cat_runtime_code=b"", + view_portal=None, + view_other_chain=None, + view_locker_puzzle_hash=None, + ) + assert result.kind == "reject" + assert "bytecode" in result.reason + + def test_unloaded_runtime_still_retries(self): + bridging, locker, other = _build_lock(asset_id=None) + result = validate_chia_cat_wrap( + bridging, + locker, + other, + _routes(), + wrapped_cat_runtime_code=None, + view_portal=None, + view_other_chain=None, + view_locker_puzzle_hash=None, + ) + assert result.kind == "retry" + assert "WrappedCAT" in result.reason + # --------------------------------------------------------------------------- # 3. EVM ERC-20 wrap tip / balance math @@ -755,3 +828,35 @@ def test_identical_log_matches_and_receiver_mismatch_does_not(self): assert not evm_log_matches_row( **{**kwargs, "log_contents": (ASSET_A, other_receiver, amount)} ) + + +class _PortalEth: + def __init__(self): + self.code_address = None + + async def get_storage_at(self, _portal, _slot, block_identifier=None): + from hexbytes import HexBytes + impl = bytes.fromhex("5d89db6cc9295c8e802d96c521c8209353ce3215") + return HexBytes(b"\x00" * 12 + impl) + + async def get_code(self, address, block_identifier=None): + self.code_address = address + from hexbytes import HexBytes + return HexBytes(b"\x60\x00") + + +class _PortalWeb3: + def __init__(self): + self.eth = _PortalEth() + + +class TestPortalImplCodehash: + @pytest.mark.asyncio + async def test_impl_address_is_not_double_prefixed(self): + from commands.policy_runtime import portal_impl_codehash + + web3 = _PortalWeb3() + codehash = await portal_impl_codehash(web3, bytes.fromhex("11" * 20), 1) + assert web3.eth.code_address.lower() == "0x5d89db6cc9295c8e802d96c521c8209353ce3215" + assert codehash is not None + assert not codehash.lower().startswith("0x0x") From 9f5d02f3c8d74c78a872542b6a443b00c11a60e8 Mon Sep 17 00:00:00 2001 From: Yak Date: Sat, 10 Oct 2026 15:00:43 +0100 Subject: [PATCH 05/19] updates --- commands/control.py | 65 ++-- commands/control_channel.py | 109 +++++-- commands/followers/eth_follower.py | 12 +- commands/followers/xch_follower.py | 54 +++- commands/listen.py | 4 +- commands/monitoring.py | 213 +++++++++++++ commands/policy_runtime.py | 146 ++++++++- commands/spend_policy.py | 347 +++++++++++++++++++-- tests/test_control_channel.py | 426 ++++++++++++++++++++++---- tests/test_monitoring.py | 360 ++++++++++++++++++++++ tests/test_signer_retry.py | 112 +++++++ tests/test_spend_policy.py | 335 +++++++++++++++++++- tests/test_xch_process_coin_record.py | 252 +++++++++++++++ 13 files changed, 2236 insertions(+), 199 deletions(-) create mode 100644 commands/monitoring.py create mode 100644 tests/test_monitoring.py create mode 100644 tests/test_signer_retry.py create mode 100644 tests/test_xch_process_coin_record.py diff --git a/commands/control.py b/commands/control.py index 4862cbf..60dbba4 100644 --- a/commands/control.py +++ b/commands/control.py @@ -1,17 +1,10 @@ import sys -import time import click -from nostr_sdk import Client, EventBuilder, EventId, Keys, Tag +from nostr_sdk import Client, EventBuilder, Keys from commands.cli_wrappers import async_func -from commands.control_channel import ( - CLOCK_SKEW_SECONDS, - ControlEvent, - fetch_control_events, - load_control_config, - normalize_hex, -) +from commands.control_channel import load_control_config, normalize_hex @click.group() @@ -24,22 +17,10 @@ def _prompt_keys() -> Keys: return Keys.from_mnemonic(mnemonic, None) -def _latest_valid_stop( - events: list[ControlEvent], - stop_authors: set[str], - now_unix: int, -) -> ControlEvent | None: - stop_set = {normalize_hex(a) for a in stop_authors} - stops = [ - ev - for ev in events - if ev.created_at <= now_unix + CLOCK_SKEW_SECONDS - and normalize_hex(ev.author) in stop_set - and ev.content == "stop" - ] - if not stops: - return None - return max(stops, key=lambda e: (e.created_at, normalize_hex(e.id))) +def _require_note_id(note_id: str) -> None: + if not note_id or any(ch.isspace() for ch in note_id): + click.echo("Refused: id must be a single token") + sys.exit(1) def _print_event(event) -> None: @@ -50,7 +31,11 @@ def _print_event(event) -> None: async def _publish_to_relays(relays: list[str], event) -> None: - """Publish the same signed event to each relay independently.""" + """Publish the same signed event to each relay independently. + + Exits non-zero when every relay rejects. One acceptance is enough. + """ + accepted = 0 for relay in relays: client = Client(None) try: @@ -58,6 +43,7 @@ async def _publish_to_relays(relays: list[str], event) -> None: await client.connect() event_id = await client.send_event(event) click.echo(f"ACCEPTED {relay} {event_id.to_hex()}") + accepted += 1 except Exception as e: click.echo(f"REJECTED {relay} {e}") finally: @@ -65,11 +51,16 @@ async def _publish_to_relays(relays: list[str], event) -> None: await client.disconnect() except Exception: pass + if accepted == 0: + click.echo("FAILED: no relay accepted the event") + sys.exit(1) @control.command() +@click.argument("note_id") @async_func -async def stop(): +async def stop(note_id: str): + _require_note_id(note_id) cfg = load_control_config() keys = _prompt_keys() pubkey = normalize_hex(keys.public_key().to_hex()) @@ -79,14 +70,16 @@ async def stop(): ) sys.exit(1) - event = EventBuilder.text_note("stop", []).to_event(keys) + event = EventBuilder.text_note(f"stop {note_id}", []).to_event(keys) _print_event(event) await _publish_to_relays(cfg.relays, event) @control.command("start") +@click.argument("note_id") @async_func -async def start_cmd(): +async def start_cmd(note_id: str): + _require_note_id(note_id) cfg = load_control_config() keys = _prompt_keys() pubkey = normalize_hex(keys.public_key().to_hex()) @@ -96,18 +89,6 @@ async def start_cmd(): ) sys.exit(1) - events = await fetch_control_events( - cfg.relays, - cfg.stop_authors, - cfg.start_authors, - ) - latest_stop = _latest_valid_stop( - events, cfg.stop_authors, int(time.time()) - ) - tags = [] - if latest_stop is not None: - tags = [Tag.event(EventId.from_hex(normalize_hex(latest_stop.id)))] - - event = EventBuilder.text_note("start", tags).to_event(keys) + event = EventBuilder.text_note(f"start {note_id}", []).to_event(keys) _print_event(event) await _publish_to_relays(cfg.relays, event) diff --git a/commands/control_channel.py b/commands/control_channel.py index 9c6cb03..6448509 100644 --- a/commands/control_channel.py +++ b/commands/control_channel.py @@ -21,7 +21,6 @@ class ControlEvent: author: str content: str created_at: int - e_tag_ids: tuple[str, ...] @dataclass(frozen=True) @@ -100,6 +99,40 @@ def load_control_config(config: Optional[dict] = None) -> ControlConfig: ) +def _command_and_id(content: str) -> Optional[tuple[str, str]]: + """Parse a note whose entire text is `start ` or `stop `. + + The id is one whitespace-delimited token. Comparison is case-insensitive. + Any other text is not a control note. + """ + parts = content.split() + if len(parts) != 2: + return None + command, note_id = parts + if command not in ("start", "stop"): + return None + return command, note_id.casefold() + + +def future_stop_author_notes( + events: Sequence[ControlEvent], + now_unix: int, + stop_authors: Set[str], +) -> List[ControlEvent]: + """Stop-author notes dated more than CLOCK_SKEW_SECONDS ahead of now. + + Callers log these and leave them out of the decision until a later poll, + when `now_unix` has caught up. Nothing is discarded permanently. + """ + stop_set = {normalize_hex(a) for a in stop_authors} + return [ + ev + for ev in events + if ev.created_at > now_unix + CLOCK_SKEW_SECONDS + and normalize_hex(ev.author) in stop_set + ] + + def decide_control( events: Sequence[ControlEvent], now_unix: int, @@ -107,52 +140,61 @@ def decide_control( start_authors: Set[str], ) -> ControlCommand: """ - Pure fail-closed control decision. + Pure fail-closed control decision. No I/O. Callers inject `now_unix`. - No I/O. Callers inject `now_unix`. Returns \"stop\" when nothing valid has - been posted, when the latest stop is uncleared, or when inputs are empty. + A stop note is ``stop `` from a stop author. A start note is + ``start `` from a start author. Anything else is ignored, including + notes dated more than CLOCK_SKEW_SECONDS ahead of ``now_unix``. + + Returns \"start\" only when the set of stop ids is non-empty and equal + to the set of start ids, and every stop note is cancelled by a start of + the same id with ``start.created_at >= stop.created_at - CLOCK_SKEW_SECONDS``. + A start timestamped earlier than that cannot pre-cancel a later stop. + Otherwise returns \"stop\". """ stop_set = {normalize_hex(a) for a in stop_authors} start_set = {normalize_hex(a) for a in start_authors} - stops: List[ControlEvent] = [] - starts: List[ControlEvent] = [] + stops: List[tuple[str, ControlEvent]] = [] + starts: List[tuple[str, ControlEvent]] = [] for ev in events: if ev.created_at > now_unix + CLOCK_SKEW_SECONDS: continue + parsed = _command_and_id(ev.content) + if parsed is None: + continue + command, note_id = parsed author = normalize_hex(ev.author) - if author in stop_set and ev.content == "stop": - stops.append(ev) - elif author in start_set and ev.content == "start": - starts.append(ev) + if command == "stop" and author in stop_set: + stops.append((note_id, ev)) + elif command == "start" and author in start_set: + starts.append((note_id, ev)) - if not stops: - return "start" if starts else "stop" + stop_ids = {note_id for note_id, _ in stops} + start_ids = {note_id for note_id, _ in starts} + if not stop_ids or stop_ids != start_ids: + return "stop" - latest_stop = max(stops, key=lambda e: (e.created_at, normalize_hex(e.id))) - latest_stop_id = normalize_hex(latest_stop.id) + starts_by_id: dict[str, List[ControlEvent]] = {} + for note_id, ev in starts: + starts_by_id.setdefault(note_id, []).append(ev) - for start in starts: - tagged = {normalize_hex(eid) for eid in start.e_tag_ids} - if latest_stop_id not in tagged: - continue - if start.created_at >= latest_stop.created_at - CLOCK_SKEW_SECONDS: - return "start" - - return "stop" + for note_id, stop in stops: + if not any( + start.created_at >= stop.created_at - CLOCK_SKEW_SECONDS + for start in starts_by_id.get(note_id, ()) + ): + return "stop" + return "start" def control_event_from_nostr(event) -> ControlEvent: """Map a nostr_sdk Event into the pure ControlEvent shape.""" - e_tag_ids = tuple( - normalize_hex(eid.to_hex()) for eid in event.event_ids() - ) return ControlEvent( id=normalize_hex(event.id().to_hex()), author=normalize_hex(event.author().to_hex()), content=event.content(), created_at=int(event.created_at().as_secs()), - e_tag_ids=e_tag_ids, ) @@ -189,9 +231,10 @@ async def fetch_control_events( Fetch kind-1 notes from every control relay and union by event id. Each relay is queried independently so one withholding relay cannot hide a - stop seen on another. If every relay errors, raises RuntimeError (caller - must treat that as stop). Partial success returns the union of what came - back. No caching. + stop seen on another. The events are unioned by id only when every + configured relay answers. If any relay errors, raises RuntimeError and + does not return the partial union (caller must treat that as stop). One + live relay cannot outvote a halt the others failed to return. No caching. """ if not relays: raise RuntimeError("no control relays configured") @@ -214,8 +257,12 @@ async def fetch_control_events( except Exception as e: errors.append(f"{relay}: {e}") - if successes == 0: + if successes != len(relays): detail = "; ".join(errors) if errors else "unknown errors" - raise RuntimeError(f"all control relays failed: {detail}") + if successes == 0: + raise RuntimeError(f"all control relays failed: {detail}") + raise RuntimeError( + f"control relay fetch incomplete ({successes}/{len(relays)} relays): {detail}" + ) return list(by_id.values()) diff --git a/commands/followers/eth_follower.py b/commands/followers/eth_follower.py index 04c0ec6..7ae08ea 100644 --- a/commands/followers/eth_follower.py +++ b/commands/followers/eth_follower.py @@ -13,12 +13,14 @@ chia_row_matches_verified, ) from commands.policy_runtime import ( + SignerRetryDeadlines, apply_policy_result, check_portal_impl, control_allows_start, evaluate_chia_message_policy, evaluate_evm_message_policy, load_block_spends, + sign_due_unsigned_messages, ) from web3 import AsyncWeb3 from web3.providers.async_rpc import AsyncHTTPProvider @@ -452,6 +454,11 @@ def web3_for_chain(chain: bytes) -> Optional[AsyncWeb3]: async def messageSigner(self): db = self.getDb() web3 = self.getWeb3() + deadlines = SignerRetryDeadlines() + + async def sign_one(message): + await self.signMessage(db, web3, message) + db.commit() while True: try: @@ -460,15 +467,12 @@ async def messageSigner(self): await asyncio.sleep(60) continue - messages = [] messages = db.query(Message).filter(and_( Message.destination_chain == self.chain_id, Message.sig == b'' )).all() - for message in messages: - await self.signMessage(db, web3, message) - db.commit() + await sign_due_unsigned_messages(messages, deadlines, sign_one) await asyncio.sleep(5) except: diff --git a/commands/followers/xch_follower.py b/commands/followers/xch_follower.py index a887a78..6464aa0 100644 --- a/commands/followers/xch_follower.py +++ b/commands/followers/xch_follower.py @@ -19,11 +19,13 @@ evm_log_matches_row, ) from commands.policy_runtime import ( + SignerRetryDeadlines, apply_policy_result, control_allows_start, evaluate_chia_message_policy, evaluate_evm_message_policy, load_block_spends, + sign_due_unsigned_messages, ) from drivers.portal import BRIDGING_PUZZLE_HASH from typing import Optional @@ -345,6 +347,7 @@ async def _recheck_evm_source_policy(self, db, message: Message) -> bool: async def messageSigner(self): db = self.getDb() + deadlines = SignerRetryDeadlines() while self.syncing: logging.info(f"{self.chain_id.decode()} message signer: Waiting to be synced before signing messages...") @@ -352,6 +355,14 @@ async def messageSigner(self): await self.getUnspentPortalId() + async def sign_one(message): + try: + await self.signMessage(db, message) + db.commit() + except Exception as e: + logging.error(f"Error signing message {message.nonce.hex()}: {e}", exc_info=True) + sys.exit(1) + while True: if not await control_allows_start(self.control_config): logging.info(f"{self.chain_id.decode()} message signer: control stop; sleeping 60s") @@ -368,13 +379,7 @@ async def messageSigner(self): logging.error(f"Error querying messages: {e}", exc_info=True) sys.exit(1) - for message in messages: - try: - await self.signMessage(db, message) - db.commit() - except Exception as e: - logging.error(f"Error signing message {message.nonce.hex()}: {e}", exc_info=True) - sys.exit(1) + await sign_due_unsigned_messages(messages, deadlines, sign_one) await asyncio.sleep(5) @@ -664,12 +669,31 @@ async def createMessageFromMemo( async def processCoinRecord(self, db: any, node: FullNodeRpcClient, coin_record: CoinRecord): - parent_record = await self.get_coin_record_by_name(node, coin_record.coin.parent_coin_info) - parent_spend = await self.get_puzzle_and_solution( - node, - coin_record.coin.parent_coin_info, - parent_record.spent_block_index - ) + started_height = coin_record.confirmed_block_index + child_id = coin_record.coin.name() + parent_id = coin_record.coin.parent_coin_info + parent_record = None + parent_spend = None + + while parent_spend is None: + child = await node.get_coin_record_by_name(child_id) + if child is None or child.confirmed_block_index != started_height: + logging.info( + f"{self.chain} message follower: Coin {self.chain}-0x{child_id.hex()}: possible reorg; re-processing" + ) + return True + + parent_record = await node.get_coin_record_by_name(parent_id) + spent_height = parent_record.spent_block_index if parent_record is not None else 0 + if spent_height: + parent_spend = await node.get_puzzle_and_solution(parent_id, spent_height) + if parent_spend is not None: + break + + logging.info( + f"{self.chain} message follower: Coin {self.chain}-0x{child_id.hex()}: parent coin record or spend not found; retrying in 5s" + ) + await asyncio.sleep(5) try: _, output = parent_spend.puzzle_reveal.run_with_cost(INFINITE_COST, parent_spend.solution) @@ -787,7 +811,9 @@ async def messageListener(self): reorg = True break - await self.processCoinRecord(db, node, earliest_unprocessed_coin_record) + if await self.processCoinRecord(db, node, earliest_unprocessed_coin_record): + reorg = True + break nonce = earliest_unprocessed_coin_record.coin.name() skip_coin_ids.append(nonce) diff --git a/commands/listen.py b/commands/listen.py index 38fab1e..6cd1726 100644 --- a/commands/listen.py +++ b/commands/listen.py @@ -1,11 +1,12 @@ import click from commands.models import * -from commands.config import load_listener_config +from commands.config import config, load_listener_config import time import logging from commands.followers.eth_follower import EthereumFollower from commands.followers.xch_follower import ChiaFollower from commands.followers.sig import MessageBroadcaster +from commands.monitoring import start_height_monitors import asyncio logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') @@ -33,6 +34,7 @@ def send_sig(sig: str): xch_follower.run(loop) eth_follower.run(loop) bse_follower.run(loop) + start_height_monitors(loop, config) loop.run_forever() while True: diff --git a/commands/monitoring.py b/commands/monitoring.py new file mode 100644 index 0000000..124a96c --- /dev/null +++ b/commands/monitoring.py @@ -0,0 +1,213 @@ +"""Chia and Ethereum height pings for `cli listen`. + +Matches monitor-chia.sh (every 120s) and monitor-eth.sh (every 60s): +GET {monitoring_url}?status=up&ping=&msg={height} when the height is a +positive integer and different from the last pinged height. The previous +height starts empty, so the first valid height pings. Base is not monitored. +A missing or empty monitoring_url leaves that chain off. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Awaitable, Callable, Optional + +import aiohttp + +XCH_INTERVAL_SECONDS = 120 +ETH_INTERVAL_SECONDS = 60 + +OpenNode = Callable[[], Awaitable[object]] +HttpGet = Callable[[str], Awaitable[None]] +RpcPost = Callable[[str, dict], Awaitable[dict]] + + +def monitoring_ping_url(url: str, height: int) -> str: + return f"{url}?status=up&ping=&msg={height}" + + +def positive_height(value: object) -> Optional[int]: + if isinstance(value, bool) or not isinstance(value, int): + return None + if value <= 0: + return None + return int(value) + + +def _config_string(cfg: dict, section: str, key: str) -> Optional[str]: + block = cfg.get(section) + if not isinstance(block, dict): + return None + raw = block.get(key) + if not isinstance(raw, str): + return None + value = raw.strip() + return value or None + + +def monitoring_url_from_config(cfg: dict, chain: str) -> Optional[str]: + return _config_string(cfg, chain, "monitoring_url") + + +async def _close_node(node: object) -> None: + try: + node.close() + await node.await_closed() + except Exception: + logging.error("xch height monitor: error closing full node", exc_info=True) + + +async def read_chia_peak_height(open_node: Optional[OpenNode] = None) -> int: + """Peak height via get_blockchain_state, same read as ChiaFollower.""" + if open_node is None: + from commands.cli_wrappers import get_node_client + + async def default_open_node() -> object: + return await get_node_client("xch") + + opener: OpenNode = default_open_node + else: + opener = open_node + + node = await opener() + if node is None: + raise RuntimeError("Could not connect to xch full node") + try: + return (await node.get_blockchain_state())["peak"].height + finally: + await _close_node(node) + + +async def _post_json(url: str, payload: dict) -> dict: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=payload) as response: + response.raise_for_status() + data = await response.json() + if not isinstance(data, dict): + raise RuntimeError("eth_blockNumber response was not an object") + return data + + +async def read_eth_block_number( + rpc_url: str, + post_json: Optional[RpcPost] = None, +) -> int: + if not rpc_url: + raise RuntimeError("eth.rpc_url is not configured") + post = post_json or _post_json + data = await post( + rpc_url, + {"jsonrpc": "2.0", "method": "eth_blockNumber", "params": [], "id": 1}, + ) + result = data.get("result", "0") + return int(result, 16) + + +async def default_http_get(url: str) -> None: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url) as response: + await response.read() + if response.status >= 400: + raise RuntimeError(f"monitoring ping HTTP {response.status}") + + +class HeightMonitor: + def __init__( + self, + chain: str, + url: Optional[str], + interval: int, + read_height: Callable[[], Awaitable[object]], + ): + self.chain = chain + self.url = url.strip() if isinstance(url, str) else "" + self.url = self.url or None + self.interval = interval + self.read_height = read_height + self.last_height: Optional[int] = None + + async def tick(self, http_get: HttpGet) -> None: + """One check, without sleeping. Safe to call from tests.""" + if self.url is None: + return + try: + height = positive_height(await self.read_height()) + except Exception: + logging.warning( + f"{self.chain} height monitor: could not retrieve a valid height", + exc_info=True, + ) + return + if height is None: + logging.warning(f"{self.chain} height monitor: could not retrieve a valid height") + return + if height == self.last_height: + return + try: + logging.info(f"{self.chain} height monitor: sending ping for height {height}") + await http_get(monitoring_ping_url(self.url, height)) + except Exception: + logging.error( + f"{self.chain} height monitor: ping failed for height {height}", + exc_info=True, + ) + self.last_height = height + + async def run( + self, + http_get: Optional[HttpGet] = None, + sleep: Optional[Callable[[float], Awaitable[None]]] = None, + ) -> None: + if self.url is None: + return + http_get = default_http_get if http_get is None else http_get + sleep = asyncio.sleep if sleep is None else sleep + logging.info(f"Starting {self.chain} height monitor") + while True: + await self.tick(http_get) + await sleep(self.interval) + + +def monitors_for_config( + cfg: Optional[dict] = None, + *, + open_chia_node: Optional[OpenNode] = None, + eth_rpc_post: Optional[RpcPost] = None, +) -> list[HeightMonitor]: + """XCH and ETH monitors only. Ignores bse even if that section has a URL.""" + if not isinstance(cfg, dict): + cfg = {} + rpc_url = _config_string(cfg, "eth", "rpc_url") or "" + + async def read_chia() -> int: + return await read_chia_peak_height(open_chia_node) + + async def read_eth() -> int: + return await read_eth_block_number(rpc_url, eth_rpc_post) + + return [ + HeightMonitor( + "xch", + monitoring_url_from_config(cfg, "xch"), + XCH_INTERVAL_SECONDS, + read_chia, + ), + HeightMonitor( + "eth", + monitoring_url_from_config(cfg, "eth"), + ETH_INTERVAL_SECONDS, + read_eth, + ), + ] + + +def start_height_monitors(loop, cfg: Optional[dict] = None) -> list[asyncio.Task]: + if cfg is None: + from commands.config import config as cfg + tasks = [] + for monitor in monitors_for_config(cfg): + tasks.append(loop.create_task(monitor.run())) + return tasks diff --git a/commands/policy_runtime.py b/commands/policy_runtime.py index 826de63..6ab795c 100644 --- a/commands/policy_runtime.py +++ b/commands/policy_runtime.py @@ -1,5 +1,6 @@ from __future__ import annotations +import binascii import logging import time from typing import List, Optional, Sequence, Tuple @@ -16,6 +17,7 @@ ControlConfig, decide_control, fetch_control_events, + future_stop_author_notes, ) from commands.spend_policy import ( REJECTED_SIG, @@ -163,6 +165,80 @@ def apply_policy_result(message, result: PolicyResult) -> bool: return True +SIGNER_BATCH_CAP = 8 +SIGNER_RETRY_INITIAL_SECONDS = 5 +SIGNER_RETRY_MAX_SECONDS = 5 * 60 + + +def signer_retry_delay_seconds(attempt: int) -> int: + """Exponential delay for a 1-based retry attempt, capped at five minutes.""" + delay = SIGNER_RETRY_INITIAL_SECONDS * (2 ** (attempt - 1)) + if delay > SIGNER_RETRY_MAX_SECONDS: + return SIGNER_RETRY_MAX_SECONDS + return delay + + +class SignerRetryDeadlines: + """In-memory message id → next attempt. A process restart starts over.""" + + def __init__(self) -> None: + self._next_attempt: dict[str, float] = {} + self._attempts: dict[str, int] = {} + + def due_batch(self, messages, now: float, cap: int = SIGNER_BATCH_CAP): + def rank(message): + message_id = _policy_message_id(message) + return (self._next_attempt.get(message_id, 0.0), message_id) + + due = [ + message + for message in messages + if self._next_attempt.get(_policy_message_id(message), 0.0) <= now + ] + due.sort(key=rank) + return due[:cap] + + def note_retry(self, message, now: float) -> None: + message_id = _policy_message_id(message) + attempt = self._attempts.get(message_id, 0) + 1 + self._attempts[message_id] = attempt + self._next_attempt[message_id] = now + signer_retry_delay_seconds(attempt) + + def note_finished(self, message) -> None: + message_id = _policy_message_id(message) + self._next_attempt.pop(message_id, None) + self._attempts.pop(message_id, None) + + +def _signature_still_open(message) -> bool: + sig = message.sig + return sig is None or sig == b"" + + +async def sign_due_unsigned_messages( + messages, + deadlines: SignerRetryDeadlines, + sign_one, + now: float | None = None, +): + """Sign one bounded batch of unsigned rows whose retry deadline has elapsed. + + A row that stays unsigned is a retry: its next attempt is remembered here, + not in the database. A reject writes REJECTED_SIG and drops out of the + unsigned query, so it is not scheduled again. + """ + if now is None: + now = time.monotonic() + batch = deadlines.due_batch(messages, now) + for message in batch: + await sign_one(message) + if _signature_still_open(message): + deadlines.note_retry(message, now) + else: + deadlines.note_finished(message) + return batch + + _last_control_command: str | None = None @@ -185,9 +261,17 @@ async def control_allows_start(control_config: ControlConfig) -> bool: control_config.stop_authors, control_config.start_authors, ) + now_unix = int(time.time()) + for ev in future_stop_author_notes( + events, now_unix, control_config.stop_authors + ): + logging.warning( + f"ignoring future-dated stop-author note {ev.content!r} " + f"created_at={ev.created_at} until the clock catches up" + ) command = decide_control( events, - int(time.time()), + now_unix, control_config.stop_authors, control_config.start_authors, ) @@ -201,7 +285,10 @@ async def control_allows_start(control_config: ControlConfig) -> bool: def _checksum(addr: bytes) -> str: - return Web3.to_checksum_address("0x" + evm_address_from_bytes32(left_pad_32(addr)).hex()) + # evm_address_from_bytes32 returns a 20-byte HexBytes unchanged. Copy to + # plain bytes so to_checksum_address encodes with binascii, one 0x prefix. + raw = bytes(evm_address_from_bytes32(left_pad_32(addr))) + return Web3.to_checksum_address(raw) def _as_bytes3(value) -> bytes: @@ -216,6 +303,13 @@ def _as_bytes3(value) -> bytes: return raw + b"\x00" * (3 - len(raw)) +def _decode_hex_or_bytes(value) -> bytes: + if isinstance(value, str): + text = value[2:] if value[:2].lower() == "0x" else value + return bytes.fromhex(text) + return bytes(value) + + def _as_bytes32(value) -> bytes: if isinstance(value, (bytes, bytearray)): raw = bytes(value) @@ -236,12 +330,11 @@ async def portal_impl_codehash( storage = await web3.eth.get_storage_at( portal, EIP1967_IMPL_SLOT, block_identifier=block_number ) - impl = bytes(storage[-20:]) - if impl == b"\x00" * 20: + impl = storage[-20:] + if bytes(impl) == b"\x00" * 20: return None - # HexBytes.hex() already includes 0x. bytes.hex() does not. code = await web3.eth.get_code( - Web3.to_checksum_address("0x" + impl.hex()), block_identifier=block_number + _checksum(impl), block_identifier=block_number ) return Web3.keccak(code).hex() except Exception: @@ -259,7 +352,8 @@ async def check_portal_impl( def _storage_key(holder: bytes, slot: int) -> str: - return "0x" + Web3.keccak(left_pad_32(holder) + left_pad_32(slot.to_bytes(32, "big"))).hex() + digest = Web3.keccak(left_pad_32(holder) + left_pad_32(slot.to_bytes(32, "big"))) + return "0x" + binascii.hexlify(bytes(digest)).decode("ascii") def _parse_uint(value) -> Optional[int]: @@ -371,11 +465,12 @@ async def _find_balance_mapping_slot( def _account_key_variants(address: bytes) -> List[str]: - addr = evm_address_from_bytes32(left_pad_32(address)).hex() + raw = bytes(evm_address_from_bytes32(left_pad_32(address))) + bare = binascii.hexlify(raw).decode("ascii") return [ - "0x" + addr.lower(), - Web3.to_checksum_address("0x" + addr), - addr.lower(), + "0x" + bare, + Web3.to_checksum_address(raw), + bare, ] @@ -514,10 +609,14 @@ async def read_wrapped_cat_views( logging.error("Failed to read WrappedCAT code", exc_info=True) return None, None, None, None, None, None, None - # Empty code and any other non-matching runtime are a definite reject. - # View calls are unnecessary, and a reverted portal() must not look like - # an RPC failure. - if _bytecode_is_not_wrapped_cat(code): + # Empty code and structurally non-matching runtime are a definite reject. + # Empty is checked first: an artifact-load failure makes + # _bytecode_is_not_wrapped_cat return False, and that must not turn an + # EOA into a retry. A non-empty runtime we could not compare falls through + # so the caller retries. A masked match is not identity: validate_chia_cat_wrap + # and validate_evm_cat_unwrap still check every immutable occurrence against + # the route portal, xch, and the tip and ratio those callers pass. + if len(code) == 0 or _bytecode_is_not_wrapped_cat(code): return code, None, None, None, None, None, None try: @@ -530,7 +629,7 @@ async def read_wrapped_cat_views( ratio = await contract.functions.mojoToTokenRatio().call(block_identifier=block_number) return ( code, - left_pad_32(bytes.fromhex(portal[2:] if isinstance(portal, str) else portal.hex())), + left_pad_32(_decode_hex_or_bytes(portal)), _as_bytes3(other_chain), _as_bytes32(locker), _as_bytes32(unlocker), @@ -628,9 +727,14 @@ async def evaluate_chia_message_policy( except Exception: return PolicyResult("retry", "could not read destination block number") - code, view_portal, view_other, view_locker, _, __, ___ = await read_wrapped_cat_views( + code, view_portal, view_other, view_locker, _, view_tip, view_ratio = await read_wrapped_cat_views( web3, dest_addr, evm_block_number ) + # get_code succeeded with an empty runtime. Reject before the mask + # compare: an artifact-load failure must not turn this into a retry. + # A non-empty runtime we could not compare still falls through. + if code is not None and len(code) == 0: + return PolicyResult("reject", "WrappedCAT bytecode mismatch after immutable mask") return validate_chia_cat_wrap( bridging_coin, parent_spend, @@ -640,6 +744,8 @@ async def evaluate_chia_message_policy( view_portal=view_portal, view_other_chain=view_other, view_locker_puzzle_hash=view_locker, + view_tip=view_tip, + view_mojo_to_token_ratio=view_ratio, ) return PolicyResult("reject", "parent is neither burner nor locker") @@ -880,6 +986,12 @@ async def _evaluate_evm_cat_unwrap( ) if code is None: return PolicyResult("retry", "missing WrappedCAT runtime code") + # get_code succeeded. Empty or non-WrappedCAT is final: do not trace the + # transaction or read balances. Artifact-load failure returns False from + # _bytecode_is_not_wrapped_cat, so a non-empty runtime we could not compare + # still falls through to retry. + if len(code) == 0 or _bytecode_is_not_wrapped_cat(code): + return PolicyResult("reject", "WrappedCAT bytecode mismatch after immutable mask") raw_input = bytes(tx.input) gross_mojo_amount: Optional[int] = None diff --git a/commands/spend_policy.py b/commands/spend_policy.py index 7cf5786..a1bbf99 100644 --- a/commands/spend_policy.py +++ b/commands/spend_policy.py @@ -1,10 +1,9 @@ from __future__ import annotations import json -import os from dataclasses import dataclass -from glob import glob -from typing import List, Literal, Optional, Sequence, Tuple, Union +from pathlib import Path +from typing import Dict, List, Literal, Optional, Sequence, Tuple, Union from chia.types.blockchain_format.coin import Coin from chia.types.blockchain_format.program import INFINITE_COST, Program @@ -232,6 +231,30 @@ def compare_portal_impl(actual_codehash: str, expected_codehash: str) -> PolicyR return PolicyResult("accept", "portal impl matches") +# solc 0.8 inlines every immutable as a PUSH32 word. bytesN is left-aligned in +# that word; addresses and integers are right-aligned. A different width is not +# masked, because the value check would not know which bytes are the value. +_IMMUTABLE_WORD = 32 +_WRAPPED_CAT_IMMUTABLES = ("portal", "tip", "mojoToTokenRatio", "otherChain") + + +@dataclass(frozen=True) +class WrappedCatImmutable: + """One AST id's occurrences. Spans are not flattened across ids.""" + + ast_id: str + name: str + spans: Tuple[Tuple[int, int], ...] + contract: str + + +@dataclass(frozen=True) +class WrappedCatArtifact: + bytecode: bytes + immutables: Tuple[WrappedCatImmutable, ...] + base_contracts: Tuple[str, ...] + + def mask_immutable_bytecode(code: bytes, spans: Sequence[Tuple[int, int]]) -> bytes: buf = bytearray(code) for start, length in spans: @@ -242,7 +265,29 @@ def mask_immutable_bytecode(code: bytes, spans: Sequence[Tuple[int, int]]) -> by return bytes(buf) -def _wrapped_cat_from_build_info(data: dict) -> Optional[Tuple[bytes, List[Tuple[int, int]]]]: +def _walk_immutable_ast(node, contract_id, contracts, variables, wrapped_bases) -> None: + if isinstance(node, dict): + next_contract = contract_id + node_type = node.get("nodeType") + if node_type == "ContractDefinition": + next_contract = node.get("id") + contracts[next_contract] = str(node.get("name") or "") + if node.get("name") == "WrappedCAT": + wrapped_bases.extend(node.get("linearizedBaseContracts") or []) + elif ( + node_type == "VariableDeclaration" + and node.get("stateVariable") + and node.get("mutability") == "immutable" + ): + variables[str(node.get("id"))] = (next_contract, str(node.get("name") or "")) + for value in node.values(): + _walk_immutable_ast(value, next_contract, contracts, variables, wrapped_bases) + elif isinstance(node, list): + for value in node: + _walk_immutable_ast(value, contract_id, contracts, variables, wrapped_bases) + + +def _wrapped_cat_from_build_info(data: dict) -> Optional[WrappedCatArtifact]: try: contract = data["output"]["contracts"]["contracts/WrappedCAT.sol"]["WrappedCAT"] except KeyError: @@ -253,52 +298,261 @@ def _wrapped_cat_from_build_info(data: dict) -> Optional[Tuple[bytes, List[Tuple return None bytecode = bytes.fromhex(str(bytecode_hex).replace("0x", "")) refs = deployed.get("immutableReferences") or {} - spans: List[Tuple[int, int]] = [] - for entries in refs.values(): - for entry in entries: - spans.append((int(entry["start"]), int(entry["length"]))) - return bytecode, spans + if not isinstance(refs, dict): + return None + + contracts: Dict[object, str] = {} + variables: Dict[str, Tuple[object, str]] = {} + wrapped_bases: List[object] = [] + for source in data.get("output", {}).get("sources", {}).values(): + ast = source.get("ast") if isinstance(source, dict) else None + if ast is not None: + _walk_immutable_ast(ast, None, contracts, variables, wrapped_bases) + if not wrapped_bases: + return None + base_names: List[str] = [] + for base_id in wrapped_bases: + base_name = contracts.get(base_id) + if not base_name: + return None + base_names.append(base_name) + + immutables: List[WrappedCatImmutable] = [] + for ast_id, entries in refs.items(): + if not isinstance(entries, list): + return None + contract_id, var_name = variables.get(str(ast_id), (None, "")) + contract_name = contracts.get(contract_id, "") if contract_id is not None else "" + try: + spans = tuple( + sorted((int(entry["start"]), int(entry["length"])) for entry in entries) + ) + except (KeyError, TypeError, ValueError): + return None + immutables.append( + WrappedCatImmutable(str(ast_id), var_name, spans, contract_name) + ) + immutables.sort(key=lambda item: item.ast_id) + return WrappedCatArtifact(bytecode, tuple(immutables), tuple(base_names)) -def load_wrapped_cat_build_info() -> Tuple[bytes, List[Tuple[int, int]]]: - matches = glob(os.path.join("artifacts", "build-info", "*.json")) - found: Optional[Tuple[bytes, Tuple[Tuple[int, int], ...]]] = None +def load_wrapped_cat_build_info() -> WrappedCatArtifact: + build_info_dir = Path(__file__).resolve().parents[1] / "artifacts" / "build-info" + matches = build_info_dir.glob("*.json") + found: Optional[WrappedCatArtifact] = None for path in matches: - data = json.loads(open(path, "r").read()) + with open(path, "r", encoding="utf-8") as handle: + data = json.loads(handle.read()) extracted = _wrapped_cat_from_build_info(data) if extracted is None: continue - bytecode, spans = extracted - normalized = (bytecode, tuple(sorted(spans))) if found is None: - found = normalized - elif found != normalized: + found = extracted + elif found != extracted: raise ValueError("WrappedCAT build-info artifacts disagree") if found is None: raise FileNotFoundError("no WrappedCAT in artifacts/build-info") - bytecode, spans_t = found - return bytecode, list(spans_t) + return found def load_wrapped_cat_artifact_bytecode() -> bytes: - bytecode, _ = load_wrapped_cat_build_info() - return bytecode + return load_wrapped_cat_build_info().bytecode -def load_wrapped_cat_immutable_spans() -> List[Tuple[int, int]]: - _, spans = load_wrapped_cat_build_info() - return spans +def load_wrapped_cat_immutable_spans() -> Tuple[WrappedCatImmutable, ...]: + """Per-AST-id immutable occurrences. Spans of different ids stay grouped.""" + return load_wrapped_cat_build_info().immutables + + +def _span_is_word(span: Tuple[int, int], code_len: int) -> bool: + start, length = span + return length == _IMMUTABLE_WORD and 0 <= start and start + length <= code_len + + +def _classify_wrapped_cat_immutables( + artifact: WrappedCatArtifact, +) -> Optional[Tuple[List[Tuple[int, int]], List[WrappedCatImmutable], List[WrappedCatImmutable]]]: + """Maskable spans, WrappedCAT value immutables, and inherited immutables. + + An unknown name or a width other than the compiler's 32-byte word fails the + classification. Those spans are left out of the mask list. + """ + bases = set(artifact.base_contracts) + mask_spans: List[Tuple[int, int]] = [] + values: List[WrappedCatImmutable] = [] + inherited: List[WrappedCatImmutable] = [] + found = set() + for immutable in artifact.immutables: + if not immutable.spans or not immutable.name or immutable.contract not in bases: + return None + if any(not _span_is_word(span, len(artifact.bytecode)) for span in immutable.spans): + return None + if immutable.contract == "WrappedCAT": + if immutable.name not in _WRAPPED_CAT_IMMUTABLES: + return None + values.append(immutable) + found.add(immutable.name) + else: + inherited.append(immutable) + mask_spans.extend(immutable.spans) + if found != set(_WRAPPED_CAT_IMMUTABLES): + return None + return mask_spans, values, inherited + + +def _uniform_span_bytes(code: bytes, spans: Sequence[Tuple[int, int]]) -> Optional[bytes]: + if not spans: + return None + first: Optional[bytes] = None + for start, length in spans: + end = start + length + if start < 0 or length < 0 or end > len(code): + return None + chunk = code[start:end] + if first is None: + first = chunk + elif chunk != first: + return None + return first + + +def _portal_word(portal: bytes) -> bytes: + if len(portal) == 20: + address = portal + else: + address = evm_address_from_bytes32(portal) + if len(address) != 20: + raise ValueError("portal") + return b"\x00" * 12 + address + + +def _tip_word(tip: int) -> bytes: + if isinstance(tip, bool) or not isinstance(tip, int) or tip < 1 or tip > 1000: + raise ValueError("tip") + return tip.to_bytes(_IMMUTABLE_WORD, "big") + + +def _ratio_word(mojo_to_token_ratio: int) -> bytes: + if ( + isinstance(mojo_to_token_ratio, bool) + or not isinstance(mojo_to_token_ratio, int) + or mojo_to_token_ratio < 0 + or mojo_to_token_ratio >= 2**64 + ): + raise ValueError("mojoToTokenRatio") + return mojo_to_token_ratio.to_bytes(_IMMUTABLE_WORD, "big") + + +def _other_chain_word(other_chain: bytes) -> bytes: + if bytes(other_chain) != b"xch": + raise ValueError("otherChain") + # bytes3 is left-aligned in the PUSH32 word. + return b"xch" + b"\x00" * (_IMMUTABLE_WORD - 3) + + +def _wrapped_cat_value_words( + *, + portal: bytes, + other_chain: bytes, + tip: int, + mojo_to_token_ratio: int, +) -> Dict[str, bytes]: + return { + "portal": _portal_word(portal), + "otherChain": _other_chain_word(other_chain), + "tip": _tip_word(tip), + "mojoToTokenRatio": _ratio_word(mojo_to_token_ratio), + } + + +def stamp_wrapped_cat_immutables( + runtime: bytes, + immutables: Sequence[WrappedCatImmutable], + *, + portal: bytes, + other_chain: bytes, + tip: int, + mojo_to_token_ratio: int, +) -> bytes: + """Write portal, xch, tip, and ratio into every span of those ids.""" + words = _wrapped_cat_value_words( + portal=portal, + other_chain=other_chain, + tip=tip, + mojo_to_token_ratio=mojo_to_token_ratio, + ) + buf = bytearray(runtime) + for immutable in immutables: + word = words.get(immutable.name) + if word is None or immutable.contract != "WrappedCAT": + continue + for start, length in immutable.spans: + if length != len(word): + raise ValueError("unexpected immutable width") + end = start + length + if start < 0 or end > len(buf): + raise ValueError("immutable span out of range") + buf[start:end] = word + return bytes(buf) def match_wrapped_cat_bytecode(runtime_code: bytes) -> bool: - expected_runtime_code, immutable_spans = load_wrapped_cat_build_info() - if len(runtime_code) != len(expected_runtime_code): + """Masked structural compare. Does not accept immutable values. + + Unknown names and unexpected widths are not masked; they fail the compare. + """ + artifact = load_wrapped_cat_build_info() + classified = _classify_wrapped_cat_immutables(artifact) + if classified is None or len(runtime_code) != len(artifact.bytecode): return False - return mask_immutable_bytecode(runtime_code, immutable_spans) == mask_immutable_bytecode( - expected_runtime_code, immutable_spans + mask_spans, _, _ = classified + return mask_immutable_bytecode(runtime_code, mask_spans) == mask_immutable_bytecode( + artifact.bytecode, mask_spans ) +def wrapped_cat_immutable_values_match( + runtime_code: bytes, + *, + portal: bytes, + other_chain: bytes, + tip: int, + mojo_to_token_ratio: int, +) -> bool: + """Require each AST id to be one value, and WrappedCAT's four to match. + + ``tip`` and ``mojo_to_token_ratio`` are the integers fee math uses. Inherited + immutables must be uniform across their spans but are not pinned: EIP-712 + caches depend on the token name, chain id, and deploy address. + """ + artifact = load_wrapped_cat_build_info() + classified = _classify_wrapped_cat_immutables(artifact) + if classified is None or len(runtime_code) != len(artifact.bytecode): + return False + _, values, inherited = classified + try: + expected = _wrapped_cat_value_words( + portal=portal, + other_chain=other_chain, + tip=tip, + mojo_to_token_ratio=mojo_to_token_ratio, + ) + except ValueError: + return False + seen = set() + for immutable in values: + word = _uniform_span_bytes(runtime_code, immutable.spans) + if word is None or word != expected[immutable.name]: + return False + seen.add(immutable.name) + if seen != set(expected): + return False + for immutable in inherited: + if _uniform_span_bytes(runtime_code, immutable.spans) is None: + return False + return True + + def compute_bridge_tip(amount: int, tip_bps: int) -> int: if tip_bps < 1 or tip_bps > 1000: raise ValueError("tip_bps out of range") @@ -513,6 +767,8 @@ def validate_chia_cat_wrap( view_portal: Optional[bytes], view_other_chain: Optional[bytes], view_locker_puzzle_hash: Optional[bytes], + view_tip: Optional[int] = None, + view_mojo_to_token_ratio: Optional[int] = None, ) -> PolicyResult: try: locker_puzzle = _as_program(locker_spend.puzzle_reveal) @@ -608,6 +864,20 @@ def validate_chia_cat_wrap( except Exception: return PolicyResult("retry", "could not load WrappedCAT artifact for mask compare") + if view_tip is None or view_mojo_to_token_ratio is None: + return PolicyResult("retry", "missing WrappedCAT bytecode or views") + try: + if not wrapped_cat_immutable_values_match( + wrapped_cat_runtime_code, + portal=route.portal_address, + other_chain=b"xch", + tip=view_tip, + mojo_to_token_ratio=view_mojo_to_token_ratio, + ): + return PolicyResult("reject", "WrappedCAT immutable occurrence mismatch") + except Exception: + return PolicyResult("retry", "could not load WrappedCAT artifact for mask compare") + if ( view_portal is None or view_other_chain is None @@ -789,6 +1059,27 @@ def validate_evm_cat_unwrap( except Exception: return PolicyResult("retry", "could not load WrappedCAT artifact for mask compare") + if tip_bps is not None and mojo_to_token_ratio is not None: + if isinstance(tip_bps, bool) or not isinstance(tip_bps, int) or tip_bps < 1 or tip_bps > 1000: + return PolicyResult("reject", "tip_bps out of range") + if ( + isinstance(mojo_to_token_ratio, bool) + or not isinstance(mojo_to_token_ratio, int) + or mojo_to_token_ratio <= 0 + ): + return PolicyResult("reject", "invalid ratio or gross amount") + try: + if not wrapped_cat_immutable_values_match( + wrapped_cat_runtime_code, + portal=route.portal_address, + other_chain=b"xch", + tip=tip_bps, + mojo_to_token_ratio=mojo_to_token_ratio, + ): + return PolicyResult("reject", "WrappedCAT immutable occurrence mismatch") + except Exception: + return PolicyResult("retry", "could not load WrappedCAT artifact for mask compare") + if view_portal is None or view_other_chain is None or unlocker_puzzle_hash is None: return PolicyResult("retry", "missing WrappedCAT views") if evm_address_from_bytes32(left_pad_32(view_portal)) != evm_address_from_bytes32( diff --git a/tests/test_control_channel.py b/tests/test_control_channel.py index 4b21d47..404b756 100644 --- a/tests/test_control_channel.py +++ b/tests/test_control_channel.py @@ -1,13 +1,18 @@ -"""Pure control-channel tests: no network, no nostr I/O.""" +"""Control-channel decision, fetch, and publish tests. No live relays.""" from __future__ import annotations +import logging + import pytest from commands.control_channel import ( CLOCK_SKEW_SECONDS, + ControlConfig, ControlEvent, decide_control, + fetch_control_events, + future_stop_author_notes, load_control_config, ) @@ -24,14 +29,12 @@ def _ev( author: str, content: str, created_at: int, - e_tags: tuple[str, ...] = (), ) -> ControlEvent: return ControlEvent( id=id, author=author, content=content, created_at=created_at, - e_tag_ids=e_tags, ) @@ -45,6 +48,42 @@ def _nostr_config(stop=None, start=None, relays=None): } +class _Hex: + def __init__(self, value: str): + self._value = value + + def to_hex(self) -> str: + return self._value + + +class _Secs: + def __init__(self, value: int): + self._value = value + + def as_secs(self) -> int: + return self._value + + +class _RawEvent: + def __init__(self, id: str, author: str, content: str, created_at: int): + self._id = id + self._author = author + self._content = content + self._created_at = created_at + + def id(self): + return _Hex(self._id) + + def author(self): + return _Hex(self._author) + + def content(self) -> str: + return self._content + + def created_at(self): + return _Secs(self._created_at) + + class TestLoadControlConfig: def test_overlapping_authors_fail_load(self): overlap = "aa" * 32 @@ -62,86 +101,359 @@ def test_loads_disjoint_authors(self): class TestDecideControl: def test_stop_key_start_ignored(self): events = [ - _ev(id="a1", author=STOP_AUTHOR, content="start", created_at=NOW - 10), + _ev(id="a1", author=STOP_AUTHOR, content="start a", created_at=NOW - 10), ] - assert ( - decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" - ) + assert decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" def test_start_key_stop_ignored(self): events = [ - _ev(id="b1", author=START_AUTHOR, content="stop", created_at=NOW - 10), + _ev(id="b1", author=START_AUTHOR, content="stop a", created_at=NOW - 10), ] - # No valid stop and no valid start → stop - assert ( - decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" - ) + assert decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" - def test_start_without_tagging_latest_stop_does_not_resume(self): + def test_no_events_is_stop(self): + assert decide_control([], NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + + def test_only_start_stays_stopped(self): events = [ - _ev(id="stop1", author=STOP_AUTHOR, content="stop", created_at=NOW - 100), - _ev( - id="start1", - author=START_AUTHOR, - content="start", - created_at=NOW - 50, - e_tags=("deadbeef" + "00" * 28,), # not stop1 - ), + _ev(id="s1", author=START_AUTHOR, content="start a", created_at=NOW - 10), ] - assert ( - decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" - ) + assert decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" - def test_start_tagging_stop_resumes(self): - stop_id = "ab" * 32 + def test_stop_without_start_stays_stopped(self): events = [ - _ev(id=stop_id, author=STOP_AUTHOR, content="stop", created_at=NOW - 100), - _ev( - id="cd" * 32, - author=START_AUTHOR, - content="start", - created_at=NOW - 50, - e_tags=(stop_id,), - ), + _ev(id="s1", author=STOP_AUTHOR, content="stop a", created_at=NOW - 10), ] - assert ( - decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "start" - ) + assert decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + + def test_matching_stop_and_start_signs(self): + events = [ + _ev(id="1", author=STOP_AUTHOR, content="stop AbC", created_at=NOW - 100), + _ev(id="2", author=START_AUTHOR, content="start abc", created_at=NOW - 50), + ] + assert decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "start" + + def test_extra_stop_id_stays_stopped(self): + events = [ + _ev(id="1", author=STOP_AUTHOR, content="stop a", created_at=NOW - 100), + _ev(id="2", author=START_AUTHOR, content="start a", created_at=NOW - 50), + _ev(id="3", author=STOP_AUTHOR, content="stop b", created_at=NOW - 40), + ] + assert decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + + def test_different_ids_stay_stopped(self): + events = [ + _ev(id="1", author=STOP_AUTHOR, content="stop a", created_at=NOW - 100), + _ev(id="2", author=START_AUTHOR, content="start b", created_at=NOW - 50), + ] + assert decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + + def test_extra_start_id_stays_stopped(self): + events = [ + _ev(id="1", author=STOP_AUTHOR, content="stop a", created_at=NOW - 100), + _ev(id="2", author=START_AUTHOR, content="start a", created_at=NOW - 50), + _ev(id="3", author=START_AUTHOR, content="start b", created_at=NOW - 40), + ] + assert decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + + def test_start_timestamped_before_stop_does_not_cancel(self): + stop_at = NOW - 10 + too_early = stop_at - CLOCK_SKEW_SECONDS - 1 + events = [ + _ev(id="1", author=STOP_AUTHOR, content="stop a", created_at=stop_at), + _ev(id="2", author=START_AUTHOR, content="start a", created_at=too_early), + ] + assert decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + + on_time = stop_at - CLOCK_SKEW_SECONDS + cancelled = [ + _ev(id="1", author=STOP_AUTHOR, content="stop a", created_at=stop_at), + _ev(id="2", author=START_AUTHOR, content="start a", created_at=on_time), + ] + assert decide_control(cancelled, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "start" + + def test_earlier_start_does_not_pre_cancel_a_later_stop(self): + events = [ + _ev(id="1", author=STOP_AUTHOR, content="stop a", created_at=NOW - 300), + _ev(id="2", author=START_AUTHOR, content="start a", created_at=NOW - 200), + _ev(id="3", author=STOP_AUTHOR, content="stop a", created_at=NOW - 50), + ] + assert decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + + resumed = events + [ + _ev(id="4", author=START_AUTHOR, content="start a", created_at=NOW - 40), + ] + assert decide_control(resumed, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "start" def test_future_dated_start_does_not_override_stop(self): - stop_id = "ab" * 32 events = [ - _ev(id=stop_id, author=STOP_AUTHOR, content="stop", created_at=NOW - 10), + _ev(id="stop1", author=STOP_AUTHOR, content="stop a", created_at=NOW - 10), _ev( - id="cd" * 32, + id="start1", author=START_AUTHOR, - content="start", + content="start a", created_at=NOW + CLOCK_SKEW_SECONDS + 1, - e_tags=(stop_id,), ), ] - assert ( - decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" - ) + assert decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" - def test_no_events_is_stop(self): - assert decide_control([], NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + def test_future_stop_is_used_once_the_clock_catches_up(self): + created = NOW + CLOCK_SKEW_SECONDS + 30 + events = [ + _ev(id="s1", author=STOP_AUTHOR, content="stop a", created_at=created), + _ev(id="t1", author=START_AUTHOR, content="start a", created_at=created), + ] + assert decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + assert future_stop_author_notes(events, NOW, {STOP_AUTHOR}) == [events[0]] + assert decide_control(events, created, {STOP_AUTHOR}, {START_AUTHOR}) == "start" + assert future_stop_author_notes(events, created, {STOP_AUTHOR}) == [] def test_other_note_text_ignored(self): events = [ _ev(id="x1", author=STOP_AUTHOR, content="halt", created_at=NOW - 20), _ev(id="x2", author=START_AUTHOR, content="go", created_at=NOW - 10), - _ev(id="x3", author=OTHER_AUTHOR, content="stop", created_at=NOW - 5), - _ev(id="x4", author=OTHER_AUTHOR, content="start", created_at=NOW - 1), + _ev(id="x3", author=OTHER_AUTHOR, content="stop a", created_at=NOW - 5), + _ev(id="x4", author=OTHER_AUTHOR, content="start a", created_at=NOW - 1), ] - assert ( - decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + assert decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + + +def _valid_pubkeys() -> tuple[str, str]: + from nostr_sdk import Keys + + return ( + Keys.generate().public_key().to_hex(), + Keys.generate().public_key().to_hex(), + ) + + +class TestFetchControlEvents: + @pytest.mark.asyncio + async def test_partial_relay_failure_is_not_a_successful_fetch(self, monkeypatch): + stop_author, start_author = _valid_pubkeys() + + async def fake_fetch(relay, filt, timeout): + if "bad" in relay: + raise RuntimeError("timeout") + return [ + _RawEvent("aa" * 32, stop_author, "stop a", NOW - 100), + _RawEvent("bb" * 32, start_author, "start a", NOW - 50), + ] + + monkeypatch.setattr( + "commands.control_channel._fetch_events_from_relay", fake_fetch ) + with pytest.raises(RuntimeError, match="incomplete"): + await fetch_control_events( + ["wss://good.example", "wss://bad.example"], + {stop_author}, + {start_author}, + ) - def test_only_valid_starts_without_stops_is_start(self): - events = [ - _ev(id="s1", author=START_AUTHOR, content="start", created_at=NOW - 10), - ] - assert ( - decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "start" + @pytest.mark.asyncio + async def test_every_relay_must_answer_before_union(self, monkeypatch): + stop_author, start_author = _valid_pubkeys() + + async def fake_fetch(relay, filt, timeout): + if relay.endswith("a"): + return [_RawEvent("aa" * 32, stop_author, "stop a", NOW - 100)] + return [ + _RawEvent("aa" * 32, stop_author, "stop a", NOW - 100), + _RawEvent("bb" * 32, start_author, "start a", NOW - 50), + ] + + monkeypatch.setattr( + "commands.control_channel._fetch_events_from_relay", fake_fetch + ) + events = await fetch_control_events( + ["wss://relay-a", "wss://relay-b"], + {stop_author}, + {start_author}, + ) + assert {ev.id for ev in events} == {"aa" * 32, "bb" * 32} + + +class TestPublish: + @pytest.mark.asyncio + async def test_zero_accepts_exits_nonzero(self, monkeypatch, capsys): + from commands.control import _publish_to_relays + + class _Client: + def __init__(self, signer): + self.relay = "" + + async def add_relay(self, relay): + self.relay = relay + + async def connect(self): + return None + + async def send_event(self, event): + raise RuntimeError("rejected") + + async def disconnect(self): + return None + + monkeypatch.setattr("commands.control.Client", _Client) + with pytest.raises(SystemExit) as exc: + await _publish_to_relays(["wss://a.example", "wss://b.example"], object()) + assert exc.value.code not in (0, None) + out = capsys.readouterr().out + assert "ACCEPTED" not in out + assert "FAILED" in out + + @pytest.mark.asyncio + async def test_one_accept_exits_zero(self, monkeypatch, capsys): + from commands.control import _publish_to_relays + + class _Eid: + def to_hex(self): + return "ab" * 32 + + class _Client: + def __init__(self, signer): + self.relay = "" + + async def add_relay(self, relay): + self.relay = relay + + async def connect(self): + return None + + async def send_event(self, event): + if "bad" in self.relay: + raise RuntimeError("rejected") + return _Eid() + + async def disconnect(self): + return None + + monkeypatch.setattr("commands.control.Client", _Client) + await _publish_to_relays(["wss://bad.example", "wss://good.example"], object()) + out = capsys.readouterr().out + assert "ACCEPTED wss://good.example" in out + assert "REJECTED wss://bad.example" in out + + @pytest.mark.asyncio + async def test_stop_and_start_publish_id_text_without_tags(self, monkeypatch): + import commands.control as control_mod + + seen = {} + + def fake_text_note(content, tags): + seen["content"] = content + seen["tags"] = list(tags) + + class _Author: + def to_hex(self): + return seen["pubkey"] + + class _Created: + def as_secs(self): + return NOW + + class _Event: + def content(self): + return content + + def author(self): + return _Author() + + def tags(self): + return [] + + def created_at(self): + return _Created() + + class _Builder: + def to_event(self, keys): + return _Event() + + return _Builder() + + class _Pub: + def __init__(self, pubkey): + self._pubkey = pubkey + + def to_hex(self): + return self._pubkey + + class _Keys: + def __init__(self, pubkey): + self._pubkey = pubkey + + def public_key(self): + return _Pub(self._pubkey) + + async def fake_publish(relays, event): + seen["published"] = event.content() + + async def fail_fetch(*args, **kwargs): + raise AssertionError("control publish must not query relays") + + monkeypatch.setattr(control_mod.EventBuilder, "text_note", fake_text_note) + monkeypatch.setattr(control_mod, "_publish_to_relays", fake_publish) + monkeypatch.setattr( + "commands.control_channel.fetch_control_events", fail_fetch + ) + monkeypatch.setattr( + control_mod, + "load_control_config", + lambda: ControlConfig( + stop_authors={STOP_AUTHOR}, + start_authors={START_AUTHOR}, + relays=["wss://relay.example"], + ), + ) + + def unwrap(command): + callback = command.callback + while hasattr(callback, "__wrapped__"): + callback = callback.__wrapped__ + return callback + + seen["pubkey"] = STOP_AUTHOR + monkeypatch.setattr(control_mod, "_prompt_keys", lambda: _Keys(STOP_AUTHOR)) + await unwrap(control_mod.stop)("incident-7") + assert seen["content"] == "stop incident-7" + assert seen["tags"] == [] + assert seen["published"] == "stop incident-7" + + seen["pubkey"] = START_AUTHOR + monkeypatch.setattr(control_mod, "_prompt_keys", lambda: _Keys(START_AUTHOR)) + await unwrap(control_mod.start_cmd)("incident-7") + assert seen["content"] == "start incident-7" + assert seen["tags"] == [] + assert seen["published"] == "start incident-7" + + +@pytest.mark.asyncio +async def test_future_stop_author_drop_is_logged(monkeypatch, caplog): + import commands.policy_runtime as policy_runtime + from commands.policy_runtime import control_allows_start + + created = NOW + CLOCK_SKEW_SECONDS + 5 + events = [ + _ev(id="futurestop", author=STOP_AUTHOR, content="stop a", created_at=created), + _ev(id="starta", author=START_AUTHOR, content="start a", created_at=NOW), + ] + + async def fake_fetch(*args, **kwargs): + return events + + monkeypatch.setattr(policy_runtime, "fetch_control_events", fake_fetch) + monkeypatch.setattr(policy_runtime.time, "time", lambda: NOW) + previous = policy_runtime._last_control_command + policy_runtime._last_control_command = None + try: + cfg = ControlConfig( + stop_authors={STOP_AUTHOR}, + start_authors={START_AUTHOR}, + relays=["wss://relay.example"], ) + with caplog.at_level(logging.WARNING): + assert await control_allows_start(cfg) is False + assert "stop a" in caplog.text + assert "clock catches up" in caplog.text + finally: + policy_runtime._last_control_command = previous diff --git a/tests/test_monitoring.py b/tests/test_monitoring.py new file mode 100644 index 0000000..0c2059d --- /dev/null +++ b/tests/test_monitoring.py @@ -0,0 +1,360 @@ +"""Height-monitor pings. No live chain: node, Ethereum RPC, and HTTP are faked.""" + +from __future__ import annotations + +import asyncio +import logging + +import pytest + +from commands.monitoring import ( + ETH_INTERVAL_SECONDS, + XCH_INTERVAL_SECONDS, + HeightMonitor, + default_http_get, + monitors_for_config, + start_height_monitors, +) + + +class _Peak: + def __init__(self, height: int): + self.height = height + + +class _Node: + def __init__(self, heights: list[int]): + self.heights = list(heights) + self.closed = 0 + + async def get_blockchain_state(self): + return {"peak": _Peak(self.heights.pop(0))} + + def close(self): + self.closed += 1 + + async def await_closed(self): + return None + + +class _Response: + def __init__(self, payload=None, status: int = 200): + self.status = status + self._payload = {} if payload is None else payload + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def json(self): + return self._payload + + async def read(self): + return b"" + + def raise_for_status(self): + if self.status >= 400: + raise RuntimeError(f"HTTP {self.status}") + + +class _Session: + posts: list[tuple[str, dict]] = [] + gets: list[str] = [] + eth_results: list[str] = [] + get_error: BaseException | None = None + + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + def post(self, url, json=None, **kwargs): + _Session.posts.append((url, json)) + result = _Session.eth_results.pop(0) + return _Response({"result": result}) + + def get(self, url, **kwargs): + if _Session.get_error is not None: + raise _Session.get_error + _Session.gets.append(url) + return _Response() + + +def _reset_session(): + _Session.posts = [] + _Session.gets = [] + _Session.eth_results = [] + _Session.get_error = None + + +def test_missing_or_empty_monitoring_url_leaves_chain_off(): + missing = monitors_for_config({}) + assert [(m.chain, m.url, m.interval) for m in missing] == [ + ("xch", None, XCH_INTERVAL_SECONDS), + ("eth", None, ETH_INTERVAL_SECONDS), + ] + + empty = monitors_for_config( + { + "xch": {"monitoring_url": ""}, + "eth": {"monitoring_url": " "}, + "bse": {"monitoring_url": "https://hc.example/bse"}, + } + ) + assert [m.chain for m in empty] == ["xch", "eth"] + assert [m.url for m in empty] == [None, None] + + +def test_monitoring_url_does_not_use_get_config_item(monkeypatch): + def boom(path): + raise AssertionError(f"get_config_item called with {path}") + + monkeypatch.setattr("commands.config.get_config_item", boom) + monitors = monitors_for_config( + { + "xch": {"monitoring_url": "https://hc.example/chia"}, + "eth": { + "monitoring_url": "https://hc.example/eth", + "rpc_url": "http://127.0.0.1:8545", + }, + } + ) + assert monitors[0].url == "https://hc.example/chia" + assert monitors[1].url == "https://hc.example/eth" + + +@pytest.mark.asyncio +async def test_chia_changed_height_pings_once_and_first_height_pings(): + node = _Node([100, 100, 101]) + + async def open_node(): + return node + + pings: list[str] = [] + + async def http_get(url: str) -> None: + pings.append(url) + + monitor = monitors_for_config( + {"xch": {"monitoring_url": "https://hc.example/chia"}}, + open_chia_node=open_node, + )[0] + + await monitor.tick(http_get) + await monitor.tick(http_get) + await monitor.tick(http_get) + + assert pings == [ + "https://hc.example/chia?status=up&ping=&msg=100", + "https://hc.example/chia?status=up&ping=&msg=101", + ] + assert node.closed == 3 + + +@pytest.mark.asyncio +async def test_eth_changed_height_pings_once_and_first_height_pings(monkeypatch): + _reset_session() + _Session.eth_results = ["0x64", "0x64", "0x65"] + monkeypatch.setattr("commands.monitoring.aiohttp.ClientSession", _Session) + + eth = monitors_for_config( + { + "eth": { + "monitoring_url": "https://hc.example/eth", + "rpc_url": "http://127.0.0.1:8545", + }, + "bse": { + "monitoring_url": "https://hc.example/bse", + "rpc_url": "http://127.0.0.1:8546", + }, + } + )[1] + assert eth.chain == "eth" + assert eth.interval == ETH_INTERVAL_SECONDS + + await eth.tick(default_http_get) + await eth.tick(default_http_get) + await eth.tick(default_http_get) + + assert _Session.gets == [ + "https://hc.example/eth?status=up&ping=&msg=100", + "https://hc.example/eth?status=up&ping=&msg=101", + ] + assert [url for url, _payload in _Session.posts] == ["http://127.0.0.1:8545"] * 3 + assert all(payload["method"] == "eth_blockNumber" for _url, payload in _Session.posts) + assert all(payload["params"] == [] for _url, payload in _Session.posts) + assert all("bse" not in url and "8546" not in url for url in _Session.gets) + + +@pytest.mark.asyncio +async def test_no_monitoring_url_sends_no_ping(): + pings: list[str] = [] + + async def http_get(url: str) -> None: + pings.append(url) + + async def read_height(): + raise AssertionError("height should not be read when the chain is off") + + for chain, interval in (("xch", XCH_INTERVAL_SECONDS), ("eth", ETH_INTERVAL_SECONDS)): + monitor = HeightMonitor(chain, None, interval, read_height) + await monitor.tick(http_get) + await monitor.run(http_get=http_get) + + for monitor in monitors_for_config( + {"xch": {"monitoring_url": ""}, "eth": {}, "bse": {"monitoring_url": "https://hc.example/bse"}} + ): + await monitor.tick(http_get) + await monitor.run(http_get=http_get) + + assert pings == [] + + +@pytest.mark.asyncio +async def test_base_is_not_pinged(): + node = _Node([4]) + posts: list[str] = [] + pings: list[str] = [] + + async def open_node(): + return node + + async def eth_rpc_post(url: str, payload: dict) -> dict: + posts.append(url) + assert payload["method"] == "eth_blockNumber" + return {"result": "0xa"} + + async def http_get(url: str) -> None: + pings.append(url) + + monitors = monitors_for_config( + { + "xch": {"monitoring_url": "https://hc.example/chia"}, + "eth": { + "monitoring_url": "https://hc.example/eth", + "rpc_url": "http://127.0.0.1:8545", + }, + "bse": { + "monitoring_url": "https://hc.example/bse", + "rpc_url": "http://127.0.0.1:8546", + }, + }, + open_chia_node=open_node, + eth_rpc_post=eth_rpc_post, + ) + assert [m.chain for m in monitors] == ["xch", "eth"] + + for monitor in monitors: + await monitor.tick(http_get) + + assert pings == [ + "https://hc.example/chia?status=up&ping=&msg=4", + "https://hc.example/eth?status=up&ping=&msg=10", + ] + assert posts == ["http://127.0.0.1:8545"] + assert all("bse" not in url and "8546" not in url for url in pings + posts) + + +@pytest.mark.asyncio +async def test_non_positive_height_does_not_ping(): + node = _Node([0, 8]) + pings: list[str] = [] + + async def open_node(): + return node + + async def http_get(url: str) -> None: + pings.append(url) + + monitor = monitors_for_config( + {"xch": {"monitoring_url": "https://hc.example/chia"}}, + open_chia_node=open_node, + )[0] + await monitor.tick(http_get) + await monitor.tick(http_get) + assert pings == ["https://hc.example/chia?status=up&ping=&msg=8"] + + +@pytest.mark.asyncio +async def test_failed_ping_is_logged_and_does_not_stop(caplog): + async def read_height(): + return 7 + + async def http_get(url: str) -> None: + raise OSError("connection refused") + + monitor = HeightMonitor("eth", "https://hc.example/eth", ETH_INTERVAL_SECONDS, read_height) + slept: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + slept.append(seconds) + raise asyncio.CancelledError + + with caplog.at_level(logging.ERROR): + with pytest.raises(asyncio.CancelledError): + await monitor.run(http_get=http_get, sleep=fake_sleep) + + assert slept == [ETH_INTERVAL_SECONDS] + assert any("ping failed" in rec.message for rec in caplog.records) + + +@pytest.mark.asyncio +async def test_monitor_intervals_use_a_fake_clock(): + heights = {"xch": 3, "eth": 9} + pings: list[str] = [] + + async def http_get(url: str) -> None: + pings.append(url) + + slept: list[tuple[str, float]] = [] + + async def sleep_for(chain: str): + async def fake_sleep(seconds: float) -> None: + slept.append((chain, seconds)) + raise asyncio.CancelledError + + return fake_sleep + + for chain, interval, url in ( + ("xch", XCH_INTERVAL_SECONDS, "https://hc.example/chia"), + ("eth", ETH_INTERVAL_SECONDS, "https://hc.example/eth"), + ): + async def read_height(chain=chain): + return heights[chain] + + monitor = HeightMonitor(chain, url, interval, read_height) + with pytest.raises(asyncio.CancelledError): + await monitor.run(http_get=http_get, sleep=await sleep_for(chain)) + + assert slept == [("xch", 120), ("eth", 60)] + assert pings == [ + "https://hc.example/chia?status=up&ping=&msg=3", + "https://hc.example/eth?status=up&ping=&msg=9", + ] + + +@pytest.mark.asyncio +async def test_start_height_monitors_missing_keys_does_not_ping(monkeypatch): + async def fail_node(*args, **kwargs): + raise AssertionError("node contacted") + + monkeypatch.setattr("commands.cli_wrappers.get_node_client", fail_node) + + class BoomSession: + def __init__(self, *args, **kwargs): + raise AssertionError("http session opened") + + monkeypatch.setattr("commands.monitoring.aiohttp.ClientSession", BoomSession) + + tasks = start_height_monitors( + asyncio.get_running_loop(), + {"bse": {"monitoring_url": "https://hc.example/bse", "rpc_url": "http://127.0.0.1:8546"}}, + ) + assert len(tasks) == 2 + await asyncio.wait_for(asyncio.gather(*tasks), timeout=1) diff --git a/tests/test_signer_retry.py b/tests/test_signer_retry.py new file mode 100644 index 0000000..7670a5c --- /dev/null +++ b/tests/test_signer_retry.py @@ -0,0 +1,112 @@ +"""Signer retry batching. Deadlines stay in memory; no database.""" + +from __future__ import annotations + +import pytest + +from commands.policy_runtime import ( + SIGNER_BATCH_CAP, + SIGNER_RETRY_INITIAL_SECONDS, + SIGNER_RETRY_MAX_SECONDS, + SignerRetryDeadlines, + signer_retry_delay_seconds, + sign_due_unsigned_messages, +) +from commands.spend_policy import REJECTED_SIG + + +class _Row: + def __init__(self, n: int): + self.source_chain = b"eth" + self.nonce = n.to_bytes(32, "big") + self.sig = b"" + + +@pytest.mark.asyncio +async def test_signer_pass_caps_batch_and_skips_until_deadline(): + messages = [_Row(n) for n in range(SIGNER_BATCH_CAP + 4)] + deadlines = SignerRetryDeadlines() + attempted: list[_Row] = [] + + async def sign_retry(message: _Row): + attempted.append(message) + + now = 10_000.0 + await sign_due_unsigned_messages(messages, deadlines, sign_retry, now) + assert len(attempted) == SIGNER_BATCH_CAP + first_wave = list(attempted) + + attempted.clear() + await sign_due_unsigned_messages(messages, deadlines, sign_retry, now) + assert len(attempted) == 4 + assert not set(map(id, first_wave)) & set(map(id, attempted)) + + retried = first_wave[0] + attempted.clear() + await sign_due_unsigned_messages( + [retried], + deadlines, + sign_retry, + now + SIGNER_RETRY_INITIAL_SECONDS - 0.001, + ) + assert attempted == [] + + await sign_due_unsigned_messages( + [retried], + deadlines, + sign_retry, + now + SIGNER_RETRY_INITIAL_SECONDS, + ) + assert attempted == [retried] + + second_at = now + SIGNER_RETRY_INITIAL_SECONDS + attempted.clear() + await sign_due_unsigned_messages( + [retried], + deadlines, + sign_retry, + second_at + SIGNER_RETRY_INITIAL_SECONDS, + ) + assert attempted == [] + await sign_due_unsigned_messages( + [retried], + deadlines, + sign_retry, + second_at + signer_retry_delay_seconds(2), + ) + assert attempted == [retried] + + fresh = SignerRetryDeadlines() + attempted.clear() + await sign_due_unsigned_messages([retried], fresh, sign_retry, now) + assert attempted == [retried] + + +@pytest.mark.asyncio +async def test_reject_leaves_the_unsigned_set(): + message = _Row(1) + deadlines = SignerRetryDeadlines() + + async def sign_reject(row: _Row): + row.sig = REJECTED_SIG + + await sign_due_unsigned_messages([message], deadlines, sign_reject, 0.0) + assert message.sig == REJECTED_SIG + unsigned = [row for row in [message] if row.sig == b""] + assert unsigned == [] + + attempted: list[_Row] = [] + + async def sign_again(row: _Row): + attempted.append(row) + + await sign_due_unsigned_messages(unsigned, deadlines, sign_again, 0.0) + assert attempted == [] + + +def test_retry_delay_starts_at_a_few_seconds_and_caps_at_five_minutes(): + assert signer_retry_delay_seconds(1) == SIGNER_RETRY_INITIAL_SECONDS + assert SIGNER_RETRY_INITIAL_SECONDS < 30 + assert signer_retry_delay_seconds(2) == SIGNER_RETRY_INITIAL_SECONDS * 2 + assert signer_retry_delay_seconds(20) == SIGNER_RETRY_MAX_SECONDS + assert SIGNER_RETRY_MAX_SECONDS == 300 diff --git a/tests/test_spend_policy.py b/tests/test_spend_policy.py index cd7d8de..8233baa 100644 --- a/tests/test_spend_policy.py +++ b/tests/test_spend_policy.py @@ -25,6 +25,8 @@ from commands.policy_runtime import evaluate_chia_message_policy from commands.spend_policy import ( BridgeRoutes, + WrappedCatArtifact, + WrappedCatImmutable, chia_row_matches_verified, compute_bridge_tip, decode_message_sent_fields, @@ -32,11 +34,15 @@ evm_log_matches_row, left_pad_32, load_wrapped_cat_artifact_bytecode, + load_wrapped_cat_build_info, + stamp_wrapped_cat_immutables, VerifiedChiaMessage, signed_destination_address, validate_chia_cat_wrap, validate_chia_erc20_unwrap, + validate_evm_cat_unwrap, validate_evm_erc20_wrap, + wrapped_cat_immutable_values_match, ) from drivers.portal import BRIDGING_PUZZLE_HASH from drivers.utils import raw_hash @@ -70,6 +76,8 @@ BRIDGING_TOLL = 10**9 ASSET_AMOUNT = 1_337_000 CHANGE_AMOUNT = 10_000 +WRAP_TIP_BPS = 30 +WRAP_MOJO_TO_TOKEN_RATIO = 10**12 Q_TAIL = Program.from_bytes(bytes.fromhex("ff0180")) @@ -275,19 +283,73 @@ def _build_lock( return bridging, locker_spend, other +def _wrap_stamp_kwargs(): + return dict( + portal=ETH_PORTAL, + other_chain=b"xch", + tip=WRAP_TIP_BPS, + mojo_to_token_ratio=WRAP_MOJO_TO_TOKEN_RATIO, + ) + + def _wrap_views(locker_ph: bytes32): try: - code = load_wrapped_cat_artifact_bytecode() + artifact = load_wrapped_cat_build_info() except FileNotFoundError: return None + code = stamp_wrapped_cat_immutables( + artifact.bytecode, + artifact.immutables, + **_wrap_stamp_kwargs(), + ) return dict( wrapped_cat_runtime_code=code, view_portal=left_pad_32(ETH_PORTAL), view_other_chain=b"xch", view_locker_puzzle_hash=locker_ph, + view_tip=WRAP_TIP_BPS, + view_mojo_to_token_ratio=WRAP_MOJO_TO_TOKEN_RATIO, ) +def _synthetic_wrapped_cat_artifact( + *, + portal_width: int = 32, + extra: Optional[WrappedCatImmutable] = None, +) -> WrappedCatArtifact: + """Standalone bytecode and per-id spans, used when build-info is absent.""" + portal_spans = ((4, portal_width), (4 + portal_width + 1, portal_width)) + immutables = [ + WrappedCatImmutable("1", "portal", portal_spans, "WrappedCAT"), + WrappedCatImmutable("2", "tip", ((80, 32),), "WrappedCAT"), + WrappedCatImmutable("3", "mojoToTokenRatio", ((120, 32),), "WrappedCAT"), + WrappedCatImmutable("4", "otherChain", ((160, 32),), "WrappedCAT"), + ] + if extra is not None: + immutables.append(extra) + length = 240 + code = bytearray(b"\x5b" * length) + for immutable in immutables: + for start, span_length in immutable.spans: + code[start:start + span_length] = b"\x00" * span_length + immutables.sort(key=lambda item: item.ast_id) + return WrappedCatArtifact(bytes(code), tuple(immutables), ("WrappedCAT",)) + + +def _corrupt_portal_span(code: bytes, artifact: WrappedCatArtifact) -> bytes: + portal = next(item for item in artifact.immutables if item.name == "portal") + assert len(portal.spans) >= 2 + getter_start, getter_length = portal.spans[0] + other_start, other_length = portal.spans[1] + forged = bytearray(code) + getter = bytes(forged[getter_start:getter_start + getter_length]) + attacker = b"\x00" * (getter_length - 20) + bytes.fromhex("44" * 20) + assert getter != attacker + forged[other_start:other_start + other_length] = attacker + assert bytes(forged[getter_start:getter_start + getter_length]) == getter + return bytes(forged) + + # --------------------------------------------------------------------------- # 1. Chia ERC-20 unwrap (burn) # --------------------------------------------------------------------------- @@ -578,6 +640,13 @@ def test_parent_not_locker_rejects(self): assert result.kind == "reject" assert "LOCKER_MOD" in result.reason or "parent" in result.reason + def test_load_wrapped_cat_build_info_from_other_cwd(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + artifact = load_wrapped_cat_build_info() + assert isinstance(artifact, WrappedCatArtifact) + assert artifact.bytecode + assert artifact.immutables + def test_bytecode_mismatch_rejects_when_artifacts_exist(self): try: code = load_wrapped_cat_artifact_bytecode() @@ -631,6 +700,99 @@ def test_unloaded_runtime_still_retries(self): assert result.kind == "retry" assert "WrappedCAT" in result.reason + def test_divergent_portal_span_rejects_while_getter_stays(self): + bridging, locker, other = _build_lock(asset_id=None) + views = _wrap_views(locker.coin.puzzle_hash) + if views is None: + artifact = _synthetic_wrapped_cat_artifact() + code = stamp_wrapped_cat_immutables( + artifact.bytecode, artifact.immutables, **_wrap_stamp_kwargs() + ) + views = dict( + wrapped_cat_runtime_code=code, + view_portal=left_pad_32(ETH_PORTAL), + view_other_chain=b"xch", + view_locker_puzzle_hash=locker.coin.puzzle_hash, + view_tip=WRAP_TIP_BPS, + view_mojo_to_token_ratio=WRAP_MOJO_TO_TOKEN_RATIO, + ) + patcher = patch.object( + spend_policy, "load_wrapped_cat_build_info", return_value=artifact + ) + else: + artifact = load_wrapped_cat_build_info() + code = views["wrapped_cat_runtime_code"] + patcher = None + forged = _corrupt_portal_span(code, artifact) + forged_views = dict(views) + forged_views["wrapped_cat_runtime_code"] = forged + forged_views["view_portal"] = left_pad_32(ETH_PORTAL) + + def run(): + assert wrapped_cat_immutable_values_match(code, **_wrap_stamp_kwargs()) + result = validate_chia_cat_wrap( + bridging, locker, other, _routes(), **forged_views + ) + assert result.kind == "reject" + assert result.reason == "WrappedCAT immutable occurrence mismatch" + + if patcher is None: + run() + else: + with patcher: + run() + + +class TestWrappedCatImmutableOccurrences: + def test_unknown_name_is_not_masked_away(self): + extra = WrappedCatImmutable("9", "mintAuthority", ((200, 32),), "WrappedCAT") + artifact = _synthetic_wrapped_cat_artifact(extra=extra) + mutated = bytearray(artifact.bytecode) + mutated[200:232] = b"\x11" * 32 + with patch.object(spend_policy, "load_wrapped_cat_build_info", return_value=artifact): + assert spend_policy.match_wrapped_cat_bytecode(artifact.bytecode) is False + assert spend_policy.match_wrapped_cat_bytecode(bytes(mutated)) is False + + def test_unexpected_width_is_not_masked_away(self): + artifact = _synthetic_wrapped_cat_artifact(portal_width=20) + mutated = bytearray(artifact.bytecode) + start, length = next( + item for item in artifact.immutables if item.name == "portal" + ).spans[1] + mutated[start:start + length] = b"\x22" * length + with patch.object(spend_policy, "load_wrapped_cat_build_info", return_value=artifact): + assert spend_policy.match_wrapped_cat_bytecode(artifact.bytecode) is False + assert spend_policy.match_wrapped_cat_bytecode(bytes(mutated)) is False + + def test_evm_unwrap_rejects_divergent_portal_span(self): + artifact = _synthetic_wrapped_cat_artifact() + code = stamp_wrapped_cat_immutables( + artifact.bytecode, artifact.immutables, **_wrap_stamp_kwargs() + ) + forged = _corrupt_portal_span(code, artifact) + unlocker = b"\x24" * 32 + with patch.object(spend_policy, "load_wrapped_cat_build_info", return_value=artifact): + result = validate_evm_cat_unwrap( + routes=_routes(), + source_chain=b"eth", + source=left_pad_32(WRAPPED_CAT_ADDR), + destination=unlocker, + destination_chain=b"xch", + contents=[left_pad_32(RECEIVER), (100).to_bytes(32, "big")], + tx_status=1, + tip_bps=WRAP_TIP_BPS, + mojo_to_token_ratio=WRAP_MOJO_TO_TOKEN_RATIO, + gross_mojo_amount=10_000, + sender_balance_diff=0, + portal_balance_diff=0, + view_portal=left_pad_32(ETH_PORTAL), + view_other_chain=b"xch", + unlocker_puzzle_hash=unlocker, + wrapped_cat_runtime_code=forged, + ) + assert result.kind == "reject" + assert result.reason == "WrappedCAT immutable occurrence mismatch" + # --------------------------------------------------------------------------- # 3. EVM ERC-20 wrap tip / balance math @@ -832,16 +994,20 @@ def test_identical_log_matches_and_receiver_mismatch_does_not(self): class _PortalEth: def __init__(self): + self.storage_address = None self.code_address = None - async def get_storage_at(self, _portal, _slot, block_identifier=None): + async def get_storage_at(self, portal, _slot, block_identifier=None): from hexbytes import HexBytes + + self.storage_address = portal impl = bytes.fromhex("5d89db6cc9295c8e802d96c521c8209353ce3215") return HexBytes(b"\x00" * 12 + impl) async def get_code(self, address, block_identifier=None): - self.code_address = address from hexbytes import HexBytes + + self.code_address = address return HexBytes(b"\x60\x00") @@ -850,13 +1016,172 @@ def __init__(self): self.eth = _PortalEth() +class TestStorageKey: + def test_single_prefix_hex_quantity(self): + import re + + from hexbytes import HexBytes + from web3 import Web3 + + from commands.policy_runtime import _storage_key + + holder = HexBytes(bytes.fromhex("22" * 20)) + digest = Web3.keccak(left_pad_32(bytes(holder)) + left_pad_32((3).to_bytes(32, "big"))) + assert isinstance(digest, HexBytes) + assert digest.hex().startswith("0x") + key = _storage_key(holder, 3) + assert re.fullmatch(r"0x[0-9a-fA-F]{64}", key) + assert "0x0x" not in key + assert Web3.to_bytes(hexstr=key) == bytes(digest) + + +class TestAccountKeyVariants: + def test_hexbytes_address_is_single_prefixed(self): + from hexbytes import HexBytes + from web3 import Web3 + + from commands.policy_runtime import _account_key_variants + + raw = bytes.fromhex("5d89db6cc9295c8e802d96c521c8209353ce3215") + address = HexBytes(b"\x00" * 12 + raw) + assert address.hex().startswith("0x") + variants = _account_key_variants(address) + checksum = Web3.to_checksum_address(raw) + assert checksum in variants + assert checksum.lower() in variants + assert raw.hex() in variants + assert all("0x0x" not in variant.lower() for variant in variants) + + +class TestPortalViewHex: + def test_hexbytes_portal_decodes_without_fromhex_on_prefixed_hex(self): + from hexbytes import HexBytes + + from commands.policy_runtime import _decode_hex_or_bytes + + raw = bytes.fromhex("ab" * 20) + portal = HexBytes(b"\x00" * 12 + raw) + assert portal.hex().startswith("0x") + with pytest.raises(ValueError): + bytes.fromhex(portal.hex()) + assert _decode_hex_or_bytes(portal) == bytes(portal) + assert _decode_hex_or_bytes(portal.hex()) == bytes(portal) + assert left_pad_32(_decode_hex_or_bytes(portal)) == bytes(portal) + + class TestPortalImplCodehash: @pytest.mark.asyncio async def test_impl_address_is_not_double_prefixed(self): + from hexbytes import HexBytes + from web3 import Web3 + from commands.policy_runtime import portal_impl_codehash web3 = _PortalWeb3() - codehash = await portal_impl_codehash(web3, bytes.fromhex("11" * 20), 1) - assert web3.eth.code_address.lower() == "0x5d89db6cc9295c8e802d96c521c8209353ce3215" + portal = HexBytes(bytes.fromhex("11" * 20)) + assert isinstance(portal, HexBytes) + assert portal.hex().startswith("0x") + codehash = await portal_impl_codehash(web3, portal, 1) + storage_address = web3.eth.storage_address + impl_address = web3.eth.code_address + assert storage_address == Web3.to_checksum_address(bytes(portal)) + assert impl_address == Web3.to_checksum_address( + bytes.fromhex("5d89db6cc9295c8e802d96c521c8209353ce3215") + ) + assert not storage_address.lower().startswith("0x0x") + assert not impl_address.lower().startswith("0x0x") assert codehash is not None assert not codehash.lower().startswith("0x0x") + + +class _RuntimeEth: + def __init__(self, code: bytes, *, fail: bool = False): + self._code = code + self._fail = fail + + async def get_code(self, address, block_identifier=None): + if self._fail: + raise RuntimeError("code read failed") + return self._code + + +class _RuntimeWeb3: + def __init__(self, code: bytes, *, fail: bool = False): + self.eth = _RuntimeEth(code, fail=fail) + + +class TestUnsupportedUnwrapSource: + async def _evaluate(self, web3): + from commands.policy_runtime import _evaluate_evm_cat_unwrap + + async def _balance_diff(*_args, **_kwargs): + raise AssertionError("fetch_token_balance_diff should not run") + + with patch("commands.policy_runtime.fetch_token_balance_diff", new=_balance_diff): + return await _evaluate_evm_cat_unwrap( + web3=web3, + routes=_routes(), + source_chain=b"eth", + source=left_pad_32(WRAPPED_CAT_ADDR), + destination=b"\x24" * 32, + destination_chain=b"xch", + contents=[left_pad_32(RECEIVER), (1).to_bytes(32, "big")], + block_number=1, + tx=object(), + tx_hash="0x" + "11" * 32, + tx_status=1, + ) + + @pytest.mark.asyncio + async def test_eoa_and_nonmatching_runtime_reject_without_balance_diff(self): + eoa = await self._evaluate(_RuntimeWeb3(b"")) + assert eoa.kind == "reject" + assert eoa.reason == "WrappedCAT bytecode mismatch after immutable mask" + + other = await self._evaluate(_RuntimeWeb3(b"\x60\x00")) + assert other.kind == "reject" + assert other.reason == "WrappedCAT bytecode mismatch after immutable mask" + + @pytest.mark.asyncio + async def test_code_read_failure_retries_without_balance_diff(self): + result = await self._evaluate(_RuntimeWeb3(b"", fail=True)) + assert result.kind == "retry" + assert result.reason == "missing WrappedCAT runtime code" + + @pytest.mark.asyncio + async def test_uncomparable_runtime_retries_but_empty_code_still_rejects(self): + with patch( + "commands.policy_runtime.match_wrapped_cat_bytecode", + side_effect=OSError("WrappedCAT artifact missing"), + ): + empty = await self._evaluate(_RuntimeWeb3(b"")) + other = await self._evaluate(_RuntimeWeb3(b"\x60\x00")) + assert empty.kind == "reject" + assert other.kind == "retry" + assert other.reason == "missing WrappedCAT runtime code" + + +class TestChiaWrapEmptyCode: + async def _evaluate(self, web3): + bridging, locker, other = _build_lock(asset_id=None) + return await evaluate_chia_message_policy( + bridging_coin=bridging, + parent_spend=locker, + routes=_routes(), + node=None, + block_spends=other, + web3_for_chain=lambda _chain: web3, + evm_block_number=1, + ) + + @pytest.mark.asyncio + async def test_empty_code_rejects_when_artifact_loader_raises(self): + with patch( + "commands.spend_policy.load_wrapped_cat_build_info", + side_effect=OSError("WrappedCAT artifact missing"), + ): + empty = await self._evaluate(_RuntimeWeb3(b"")) + other = await self._evaluate(_RuntimeWeb3(b"\x60\x00")) + assert empty.kind == "reject" + assert empty.reason == "WrappedCAT bytecode mismatch after immutable mask" + assert other.kind == "retry" diff --git a/tests/test_xch_process_coin_record.py b/tests/test_xch_process_coin_record.py new file mode 100644 index 0000000..d5f073f --- /dev/null +++ b/tests/test_xch_process_coin_record.py @@ -0,0 +1,252 @@ +"""Bridging-coin parent fetch stops when the child coin reorgs. No full node.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from chia.types.blockchain_format.coin import Coin +from chia.types.blockchain_format.sized_bytes import bytes32 + +from commands.followers.xch_follower import ChiaFollower + + +def _follower() -> ChiaFollower: + follower = ChiaFollower.__new__(ChiaFollower) + follower.chain = "xch" + follower.chain_id = b"xch" + return follower + + +def _coins(): + parent = Coin(bytes32(b"\x01" * 32), bytes32(b"\x02" * 32), 1) + bridging = Coin(parent.name(), bytes32(b"\xaa" * 32), 5) + coin_record = SimpleNamespace(coin=bridging, confirmed_block_index=10) + return parent, bridging, coin_record + + +@pytest.mark.parametrize("case", ["gone", "height"]) +def test_disappeared_child_stops_parent_fetch(case: str): + asyncio.run(_disappeared_child_stops_parent_fetch(case)) + + +async def _disappeared_child_stops_parent_fetch(case: str): + parent, bridging, coin_record = _coins() + follower = _follower() + child_id = bridging.name() + parent_id = parent.name() + + class Node: + def __init__(self): + self.calls: list[bytes] = [] + self.child_seen = 0 + + async def get_coin_record_by_name(self, name): + self.calls.append(name) + if name == child_id: + self.child_seen += 1 + if self.child_seen == 1: + return coin_record + if case == "height": + return SimpleNamespace( + coin=bridging, + confirmed_block_index=coin_record.confirmed_block_index + 1, + ) + return None + return None + + async def get_puzzle_and_solution(self, name, height): + raise AssertionError("parent spend fetched without a parent record") + + node = Node() + + async def fast_sleep(_seconds): + return None + + with patch("commands.followers.xch_follower.asyncio.sleep", fast_sleep): + result = await asyncio.wait_for( + follower.processCoinRecord(None, node, coin_record), + timeout=2, + ) + + assert result is True + assert node.calls[0] == child_id + assert node.child_seen >= 2 + assert node.calls.count(parent_id) == 1 + assert node.calls[-1] == child_id + + +class _StopListener(Exception): + pass + + +def _query(): + class Query: + def __init__(self): + self.ordered = False + + def filter(self, *args, **kwargs): + return self + + def order_by(self, *args, **kwargs): + self.ordered = True + return self + + def first(self): + if self.ordered: + return (1,) + return None + + class Db: + def query(self, *args, **kwargs): + return Query() + + return Db() + + +def _later_coin(): + parent = Coin(bytes32(b"\x03" * 32), bytes32(b"\x04" * 32), 1) + bridging = Coin(parent.name(), bytes32(b"\xbb" * 32), 6) + return bridging, SimpleNamespace(coin=bridging, confirmed_block_index=20) + + +@pytest.mark.parametrize("case", ["gone", "height"]) +def test_listener_reorg_signal_drops_stale_batch(case: str): + asyncio.run(_listener_reorg_signal_drops_stale_batch(case)) + + +async def _listener_reorg_signal_drops_stale_batch(case: str): + _parent, bridging, coin_record = _coins() + later, later_record = _later_coin() + follower = _follower() + follower.per_message_toll = 1 + follower.sign_min_height = 0 + follower.control_config = None + follower.getDb = _query + + class Node: + def __init__(self): + self.batch_fetches = 0 + self.child_seen = 0 + self.later_seen = 0 + self.sleeps: list[int] = [] + + async def get_coin_records_by_puzzle_hash(self, puzzle_hash, include_spent_coins=False, start_height=None): + self.batch_fetches += 1 + if self.batch_fetches == 1: + return [later_record, coin_record] + raise _StopListener() + + async def get_blockchain_state(self): + return {"peak": SimpleNamespace(height=10_000)} + + async def get_coin_record_by_name(self, name): + if name == bridging.name(): + self.child_seen += 1 + if self.child_seen <= 2: + return coin_record + if case == "height": + return SimpleNamespace( + coin=bridging, + confirmed_block_index=coin_record.confirmed_block_index + 1, + ) + return None + if name == later.name(): + self.later_seen += 1 + return later_record + return None + + async def get_puzzle_and_solution(self, name, height): + raise AssertionError("parent spend fetched without a parent record") + + node = Node() + + async def get_node(log: bool = True): + return node + + follower.getNode = get_node + + async def fast_sleep(seconds): + node.sleeps.append(seconds) + + async def allow_start(_config): + return True + + def stop_exit(code=0): + raise _StopListener(code) + + with ( + patch("commands.followers.xch_follower.asyncio.sleep", fast_sleep), + patch("commands.followers.xch_follower.control_allows_start", allow_start), + patch("commands.followers.xch_follower.sys.exit", stop_exit), + ): + with pytest.raises(_StopListener): + await asyncio.wait_for(follower.messageListener(), timeout=2) + + assert node.batch_fetches == 2 + assert node.child_seen >= 3 + assert node.later_seen == 0 + assert 5 in node.sleeps + assert 30 not in node.sleeps + + +def test_listener_normal_return_skips_coin_and_waits(): + asyncio.run(_listener_normal_return_skips_coin_and_waits()) + + +async def _listener_normal_return_skips_coin_and_waits(): + _parent, bridging, coin_record = _coins() + follower = _follower() + follower.per_message_toll = 1 + follower.sign_min_height = 0 + follower.control_config = None + follower.getDb = _query + processed: list[bytes] = [] + sleeps: list[int] = [] + fetches = 0 + + class Node: + async def get_coin_records_by_puzzle_hash(self, puzzle_hash, include_spent_coins=False, start_height=None): + nonlocal fetches + fetches += 1 + if fetches > 2: + raise _StopListener() + return [coin_record] + + async def get_blockchain_state(self): + return {"peak": SimpleNamespace(height=10_000)} + + async def get_coin_record_by_name(self, name): + return coin_record + + async def get_node(log: bool = True): + return Node() + + async def process(_db, _node, record): + processed.append(record.coin.name()) + return None + + async def fast_sleep(seconds): + sleeps.append(seconds) + + async def allow_start(_config): + return True + + def stop_exit(code=0): + raise _StopListener(code) + + follower.getNode = get_node + follower.processCoinRecord = process + + with ( + patch("commands.followers.xch_follower.asyncio.sleep", fast_sleep), + patch("commands.followers.xch_follower.control_allows_start", allow_start), + patch("commands.followers.xch_follower.sys.exit", stop_exit), + ): + with pytest.raises(_StopListener): + await asyncio.wait_for(follower.messageListener(), timeout=2) + + assert processed == [bridging.name(), bridging.name()] + assert sleeps == [30, 30] From 081cb17d2e579e67d195491f9aeca8b7d636d5d0 Mon Sep 17 00:00:00 2001 From: Yak Date: Sat, 10 Oct 2026 15:15:59 +0100 Subject: [PATCH 06/19] pr tests/builds --- .github/workflows/build.yml | 9 ++++++++- .github/workflows/tests.yml | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 43db02e..dabde93 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,18 +4,25 @@ on: push: branches: - master + pull_request: + branches: + - master release: types: [created] jobs: publish_containers: runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - name: Checkout uses: actions/checkout@v4 - name: Login to registry + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - name: Docker meta @@ -33,6 +40,6 @@ jobs: with: context: . file: Dockerfile - push: true + push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6e1f2eb..cd5f053 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,9 @@ on: push: branches: - master + pull_request: + branches: + - master jobs: test: From e708ab28cdab43b7deaaa65489eb0c4b5c8c53fd Mon Sep 17 00:00:00 2001 From: Yak Date: Sat, 10 Oct 2026 15:31:45 +0100 Subject: [PATCH 07/19] reorder test workflow --- .github/workflows/tests.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cd5f053..5dfb930 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,10 +41,6 @@ jobs: pip install --extra-index-url https://pypi.chia.net/simple/ chia-dev-tools==1.2.5 pip install -r requirements.txt - - name: Run pytest - run: | - pytest tests/ -s -v --durations 0 -W ignore::DeprecationWarning - - name: Set up Node.js uses: actions/setup-node@v2 with: @@ -53,10 +49,17 @@ jobs: - name: Install npm dependencies run: npm install - - name: Run Hardhat tests + - name: Compile contracts run: | cp hardhat.config.example.ts hardhat.config.ts - npx hardhat test + npx hardhat compile + + - name: Run pytest + run: | + pytest tests/ -s -v --durations 0 -W ignore::DeprecationWarning + + - name: Run Hardhat tests + run: npx hardhat test - name: Check test coverage for Solidity contracts run: | From d95007f1e301fc6fff59f39d4b99f9f91c97799c Mon Sep 17 00:00:00 2001 From: Yak Date: Sat, 10 Oct 2026 17:20:05 +0100 Subject: [PATCH 08/19] stop file --- README.md | 4 +- commands/policy_runtime.py | 189 +++++++++++++++++++++++++++--- tests/test_control_channel.py | 215 ++++++++++++++++++++++++++++++---- 3 files changed, 371 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index b43bf36..550765c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ services: volumes: - ./config.json:/app/config.json - /mnt/disk/warp-cli/data.db:/app/data.db + - /mnt/disk/warp-cli/stops:/app/stops command: listen ``` @@ -45,7 +46,8 @@ Clone this repository and enter the `cli` directory by running: docker build . -t cli echo '{}' > config.json touch data.db - docker run -it -v "$(pwd)"/config.json:/app/config.json -v "$(pwd)"/data.db:/app/data.db cli --help + touch stops + docker run -it -v "$(pwd)"/config.json:/app/config.json -v "$(pwd)"/data.db:/app/data.db -v "$(pwd)"/stops:/app/stops cli --help ``` ### Build from source diff --git a/commands/policy_runtime.py b/commands/policy_runtime.py index 6ab795c..9ee4a14 100644 --- a/commands/policy_runtime.py +++ b/commands/policy_runtime.py @@ -1,8 +1,10 @@ from __future__ import annotations import binascii +import json import logging import time +from pathlib import Path from typing import List, Optional, Sequence, Tuple from chia.types.blockchain_format.coin import Coin @@ -15,9 +17,12 @@ from commands.control_channel import ( ControlConfig, + ControlEvent, + _command_and_id, decide_control, fetch_control_events, future_stop_author_notes, + normalize_hex, ) from commands.spend_policy import ( REJECTED_SIG, @@ -241,6 +246,18 @@ async def sign_due_unsigned_messages( _last_control_command: str | None = None +# App folder, same working directory as config.json and data.db (/app in Docker). +STOPS_FILE = Path("stops") +_MISSING_STOPS_WARNING = ( + "WARNING: Could not find the 'stops' file in the app's folder; please create an empty one" +) +_seen_stops: dict[str, ControlEvent] = {} +_missing_stops_warned = False + + +class StopsFileError(Exception): + """The stops file exists but is not a JSON object of stops.""" + def _note_control_command(command: str) -> None: global _last_control_command @@ -253,6 +270,133 @@ def _note_control_command(command: str) -> None: _last_control_command = command +def _is_remembered_stop(event: ControlEvent, stop_authors: set[str]) -> bool: + """A stop note from a stop author, including one dated in the future.""" + parsed = _command_and_id(event.content) + if parsed is None: + return False + command, _note_id = parsed + return command == "stop" and normalize_hex(event.author) in stop_authors + + +def _stops_from_json(data: object) -> dict[str, ControlEvent]: + if not isinstance(data, dict): + raise StopsFileError("stops file is not a JSON object of stops") + loaded: dict[str, ControlEvent] = {} + for event_id, record in data.items(): + if not isinstance(event_id, str) or not isinstance(record, dict): + raise StopsFileError("stops file is not a JSON object of stops") + author = record.get("author") + content = record.get("content") + created_at = record.get("created_at") + if ( + not isinstance(author, str) + or not isinstance(content, str) + or isinstance(created_at, bool) + or not isinstance(created_at, int) + ): + raise StopsFileError("stops file is not a JSON object of stops") + parsed = _command_and_id(content) + if parsed is None or parsed[0] != "stop": + raise StopsFileError("stops file is not a JSON object of stops") + loaded[event_id] = ControlEvent( + id=event_id, + author=author, + content=content, + created_at=created_at, + ) + return loaded + + +def _read_stops_file(path: Path) -> dict[str, ControlEvent] | None: + """Load stops. None means the file is missing. An empty file is {}.""" + try: + raw = path.read_text() + except FileNotFoundError: + return None + except (OSError, UnicodeDecodeError) as e: + raise StopsFileError("stops file is not a JSON object of stops") from e + if raw.strip() == "": + return {} + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + raise StopsFileError("stops file is not a JSON object of stops") from e + return _stops_from_json(data) + + +def _write_stops_file(path: Path, stops: dict[str, ControlEvent]) -> None: + """Overwrite the existing file in place so a bind-mounted inode stays put.""" + payload = { + event_id: { + "author": event.author, + "content": event.content, + "created_at": event.created_at, + } + for event_id, event in stops.items() + } + body = json.dumps(payload, indent=2, sort_keys=True) + "\n" + with path.open("r+") as handle: + handle.seek(0) + handle.write(body) + handle.truncate() + + +def _absorb_fetch_stops( + events: Sequence[ControlEvent], stop_authors: set[str] +) -> None: + for event in events: + if _is_remembered_stop(event, stop_authors): + _seen_stops[event.id] = event + + +def _remember_stops( + events: Sequence[ControlEvent], stop_authors: set[str] +) -> dict[str, ControlEvent]: + """Merge the stops file with stops in this fetch. + + A missing file logs the warning and leaves new stops in this process only. + A file that is not a JSON object of stops raises StopsFileError after the + stops in this fetch are remembered in memory. Those are not written back. + """ + global _missing_stops_warned + path = STOPS_FILE + try: + disk = _read_stops_file(path) + except StopsFileError: + _absorb_fetch_stops(events, stop_authors) + raise + if disk is None: + if not _missing_stops_warned: + logging.warning(_MISSING_STOPS_WARNING) + _missing_stops_warned = True + disk = {} + file_missing = True + else: + _missing_stops_warned = False + file_missing = False + + for event in disk.values(): + _seen_stops.setdefault(event.id, event) + _absorb_fetch_stops(events, stop_authors) + + if not file_missing and set(_seen_stops) - set(disk): + try: + _write_stops_file(path, _seen_stops) + except OSError: + logging.error("failed to write stops file", exc_info=True) + raise + return dict(_seen_stops) + + +def _with_remembered_stops( + events: Sequence[ControlEvent], remembered: dict[str, ControlEvent] +) -> List[ControlEvent]: + present = {event.id for event in events} + extra = [event for event_id, event in remembered.items() if event_id not in present] + return list(events) + extra + + async def control_allows_start(control_config: ControlConfig) -> bool: global _last_control_command try: @@ -261,28 +405,41 @@ async def control_allows_start(control_config: ControlConfig) -> bool: control_config.stop_authors, control_config.start_authors, ) - now_unix = int(time.time()) - for ev in future_stop_author_notes( - events, now_unix, control_config.stop_authors - ): - logging.warning( - f"ignoring future-dated stop-author note {ev.content!r} " - f"created_at={ev.created_at} until the clock catches up" - ) - command = decide_control( - events, - now_unix, - control_config.stop_authors, - control_config.start_authors, - ) - _note_control_command(command) - return command == "start" except Exception: if _last_control_command != "stop": logging.warning("NOSTR STOP: control relay fetch failed", exc_info=True) _last_control_command = "stop" return False + stop_authors = {normalize_hex(author) for author in control_config.stop_authors} + try: + remembered = _remember_stops(events, stop_authors) + except StopsFileError: + logging.error("stops file is not a JSON object of stops", exc_info=True) + _note_control_command("stop") + return False + except OSError: + _note_control_command("stop") + return False + + now_unix = int(time.time()) + combined = _with_remembered_stops(events, remembered) + for ev in future_stop_author_notes( + combined, now_unix, control_config.stop_authors + ): + logging.warning( + f"ignoring future-dated stop-author note {ev.content!r} " + f"created_at={ev.created_at} until the clock catches up" + ) + command = decide_control( + combined, + now_unix, + control_config.stop_authors, + control_config.start_authors, + ) + _note_control_command(command) + return command == "start" + def _checksum(addr: bytes) -> str: # evm_address_from_bytes32 returns a 20-byte HexBytes unchanged. Copy to diff --git a/tests/test_control_channel.py b/tests/test_control_channel.py index 404b756..e3a1ce9 100644 --- a/tests/test_control_channel.py +++ b/tests/test_control_channel.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging import pytest @@ -427,9 +428,51 @@ def unwrap(command): assert seen["published"] == "start incident-7" -@pytest.mark.asyncio -async def test_future_stop_author_drop_is_logged(monkeypatch, caplog): +def _control_config(): + return ControlConfig( + stop_authors={STOP_AUTHOR}, + start_authors={START_AUTHOR}, + relays=["wss://relay.example"], + ) + + +@pytest.fixture +def stops_file(tmp_path, monkeypatch): + """Point the stops file at a temp path and reset process memory.""" + import commands.policy_runtime as policy_runtime + + path = tmp_path / "stops" + monkeypatch.setattr(policy_runtime, "STOPS_FILE", path) + saved_stops = dict(policy_runtime._seen_stops) + saved_warned = policy_runtime._missing_stops_warned + saved_command = policy_runtime._last_control_command + policy_runtime._seen_stops.clear() + policy_runtime._missing_stops_warned = False + policy_runtime._last_control_command = None + yield path + policy_runtime._seen_stops.clear() + policy_runtime._seen_stops.update(saved_stops) + policy_runtime._missing_stops_warned = saved_warned + policy_runtime._last_control_command = saved_command + + +def _install_fetch(monkeypatch, responses): import commands.policy_runtime as policy_runtime + + pending = list(responses) + + async def fake_fetch(*args, **kwargs): + nxt = pending.pop(0) + if isinstance(nxt, Exception): + raise nxt + return nxt + + monkeypatch.setattr(policy_runtime, "fetch_control_events", fake_fetch) + monkeypatch.setattr(policy_runtime.time, "time", lambda: NOW) + + +@pytest.mark.asyncio +async def test_future_stop_author_drop_is_logged(monkeypatch, caplog, stops_file): from commands.policy_runtime import control_allows_start created = NOW + CLOCK_SKEW_SECONDS + 5 @@ -437,23 +480,155 @@ async def test_future_stop_author_drop_is_logged(monkeypatch, caplog): _ev(id="futurestop", author=STOP_AUTHOR, content="stop a", created_at=created), _ev(id="starta", author=START_AUTHOR, content="start a", created_at=NOW), ] + _install_fetch(monkeypatch, [events]) + stops_file.write_text("") + with caplog.at_level(logging.WARNING): + assert await control_allows_start(_control_config()) is False + assert "stop a" in caplog.text + assert "clock catches up" in caplog.text + assert "futurestop" in json.loads(stops_file.read_text()) - async def fake_fetch(*args, **kwargs): - return events - monkeypatch.setattr(policy_runtime, "fetch_control_events", fake_fetch) - monkeypatch.setattr(policy_runtime.time, "time", lambda: NOW) - previous = policy_runtime._last_control_command - policy_runtime._last_control_command = None - try: - cfg = ControlConfig( - stop_authors={STOP_AUTHOR}, - start_authors={START_AUTHOR}, - relays=["wss://relay.example"], - ) - with caplog.at_level(logging.WARNING): - assert await control_allows_start(cfg) is False - assert "stop a" in caplog.text - assert "clock catches up" in caplog.text - finally: - policy_runtime._last_control_command = previous +@pytest.mark.asyncio +async def test_missing_stops_file_warns_and_stays_in_memory(monkeypatch, caplog, stops_file): + from commands.policy_runtime import control_allows_start + + assert not stops_file.exists() + old_stop = _ev(id="old-stop", author=STOP_AUTHOR, content="stop a", created_at=NOW - 100) + old_start = _ev(id="old-start", author=START_AUTHOR, content="start a", created_at=NOW - 50) + new_stop = _ev(id="new-stop", author=STOP_AUTHOR, content="stop b", created_at=NOW - 40) + _install_fetch( + monkeypatch, + [ + [old_stop, old_start, new_stop], + [old_stop, old_start], + ], + ) + with caplog.at_level(logging.WARNING): + assert await control_allows_start(_control_config()) is False + assert await control_allows_start(_control_config()) is False + assert caplog.text.count( + "WARNING: Could not find the 'stops' file in the app's folder; please create an empty one" + ) == 1 + assert not stops_file.exists() + + +@pytest.mark.asyncio +async def test_seen_stops_survive_a_relay_that_omits_them(monkeypatch, stops_file): + import commands.policy_runtime as policy_runtime + from commands.policy_runtime import control_allows_start + + stops_file.write_text("") + old_stop = _ev(id="old-stop", author=STOP_AUTHOR, content="stop a", created_at=NOW - 100) + old_start = _ev(id="old-start", author=START_AUTHOR, content="start a", created_at=NOW - 50) + new_stop = _ev(id="new-stop", author=STOP_AUTHOR, content="stop b", created_at=NOW - 40) + new_start = _ev(id="new-start", author=START_AUTHOR, content="start b", created_at=NOW - 30) + _install_fetch( + monkeypatch, + [ + [old_stop, old_start], + [old_stop, old_start, new_stop], + [old_stop, old_start], + [old_stop, old_start, new_stop, new_start], + ], + ) + cfg = _control_config() + assert await control_allows_start(cfg) is True + assert set(json.loads(stops_file.read_text())) == {"old-stop"} + + assert await control_allows_start(cfg) is False + saved = json.loads(stops_file.read_text()) + assert set(saved) == {"old-stop", "new-stop"} + assert saved["new-stop"]["content"] == "stop b" + assert "old-start" not in saved + + policy_runtime._seen_stops.clear() + assert await control_allows_start(cfg) is False + assert await control_allows_start(cfg) is True + assert set(json.loads(stops_file.read_text())) == {"old-stop", "new-stop"} + + +@pytest.mark.asyncio +async def test_failed_fetch_does_not_change_stops_file(monkeypatch, stops_file): + import commands.policy_runtime as policy_runtime + from commands.policy_runtime import control_allows_start + + stops_file.write_text("") + old_stop = _ev(id="old-stop", author=STOP_AUTHOR, content="stop a", created_at=NOW - 100) + old_start = _ev(id="old-start", author=START_AUTHOR, content="start a", created_at=NOW - 50) + new_stop = _ev(id="new-stop", author=STOP_AUTHOR, content="stop b", created_at=NOW - 40) + _install_fetch( + monkeypatch, + [ + [old_stop, old_start, new_stop], + RuntimeError("timeout"), + [old_stop, old_start], + ], + ) + cfg = _control_config() + assert await control_allows_start(cfg) is False + saved = stops_file.read_text() + policy_runtime._seen_stops.clear() + assert await control_allows_start(cfg) is False + assert stops_file.read_text() == saved + assert await control_allows_start(cfg) is False + + +@pytest.mark.asyncio +async def test_corrupt_stops_file_stays_halted(monkeypatch, caplog, stops_file): + from commands.policy_runtime import control_allows_start + + stops_file.write_text("{") + old_stop = _ev(id="old-stop", author=STOP_AUTHOR, content="stop a", created_at=NOW - 100) + old_start = _ev(id="old-start", author=START_AUTHOR, content="start a", created_at=NOW - 50) + _install_fetch(monkeypatch, [[old_stop, old_start]]) + with caplog.at_level(logging.ERROR): + assert await control_allows_start(_control_config()) is False + assert stops_file.read_text() == "{" + assert "not a JSON object of stops" in caplog.text + + +@pytest.mark.asyncio +async def test_non_utf8_stops_file_stays_halted(monkeypatch, caplog, stops_file): + from commands.policy_runtime import control_allows_start + + stops_file.write_bytes(b"\xff") + old_stop = _ev(id="old-stop", author=STOP_AUTHOR, content="stop a", created_at=NOW - 100) + old_start = _ev(id="old-start", author=START_AUTHOR, content="start a", created_at=NOW - 50) + _install_fetch(monkeypatch, [[old_stop, old_start]]) + with caplog.at_level(logging.ERROR): + assert await control_allows_start(_control_config()) is False + assert stops_file.read_bytes() == b"\xff" + assert "not a JSON object of stops" in caplog.text + + +@pytest.mark.asyncio +async def test_failed_stops_write_stays_halted(monkeypatch, stops_file): + import commands.policy_runtime as policy_runtime + from commands.policy_runtime import control_allows_start + + stops_file.write_text("") + old_stop = _ev(id="old-stop", author=STOP_AUTHOR, content="stop a", created_at=NOW - 100) + old_start = _ev(id="old-start", author=START_AUTHOR, content="start a", created_at=NOW - 50) + new_stop = _ev(id="new-stop", author=STOP_AUTHOR, content="stop b", created_at=NOW - 40) + _install_fetch( + monkeypatch, + [ + [old_stop, old_start, new_stop], + [old_stop, old_start], + ], + ) + + original_write = policy_runtime._write_stops_file + + def fail_write(path, stops): + raise OSError("read-only") + + monkeypatch.setattr(policy_runtime, "_write_stops_file", fail_write) + cfg = _control_config() + assert await control_allows_start(cfg) is False + assert stops_file.read_text() == "" + + monkeypatch.setattr(policy_runtime, "_write_stops_file", original_write) + assert await control_allows_start(cfg) is False + assert "new-stop" in json.loads(stops_file.read_text()) From 30a2b1e5dc83ac0c1c65e0d0a9242f4042597e28 Mon Sep 17 00:00:00 2001 From: Yak Date: Sat, 10 Oct 2026 17:43:39 +0100 Subject: [PATCH 09/19] moar fixes (so public RPCs work) --- commands/policy_runtime.py | 299 +++++++++++++++++++++++++++++++++---- tests/test_spend_policy.py | 220 +++++++++++++++++++++++++++ 2 files changed, 491 insertions(+), 28 deletions(-) diff --git a/commands/policy_runtime.py b/commands/policy_runtime.py index 9ee4a14..9957390 100644 --- a/commands/policy_runtime.py +++ b/commands/policy_runtime.py @@ -701,48 +701,291 @@ async def _rpc(web3: AsyncWeb3, method: str, params: list): raise RuntimeError("web3 provider does not support make_request") -async def fetch_token_balance_diff( +def _keccak_topic(signature: str) -> str: + digest = bytes(Web3.keccak(text=signature)) + return "0x" + binascii.hexlify(digest).decode("ascii") + + +_TRANSFER_TOPIC = _keccak_topic("Transfer(address,address,uint256)") +_missing_tracer_methods: set[str] = set() + + +def _rpc_error_code(exc: BaseException) -> Optional[int]: + if not exc.args: + return None + err = exc.args[0] + if isinstance(err, dict): + code = err.get("code") + if isinstance(code, int): + return code + return None + + +def _log_tracer_failure(method: str, exc: BaseException) -> None: + if _rpc_error_code(exc) == -32601: + if method not in _missing_tracer_methods: + _missing_tracer_methods.add(method) + logging.info("%s is not available on this RPC", method) + return + logging.info("%s failed", method, exc_info=True) + + +def _as_hex_bytes(value) -> Optional[bytes]: + if isinstance(value, (bytes, bytearray)): + return bytes(value) + if isinstance(value, str): + text = value[2:] if value[:2].lower() == "0x" else value + try: + return bytes.fromhex(text) + except ValueError: + return None + return None + + +def _norm_tx_hash(value) -> Optional[str]: + raw = _as_hex_bytes(value) + if raw is None: + return None + return binascii.hexlify(raw).decode("ascii") + + +def _topic_address(topic) -> Optional[bytes]: + raw = _as_hex_bytes(topic) + if raw is None or len(raw) != 32: + return None + return raw[-20:] + + +def _log_field(log, name: str): + if isinstance(log, dict): + return log.get(name) + return getattr(log, name, None) + + +def _holder_log_nets( + logs, token: bytes, holder: bytes +) -> Optional[dict]: + """Net Transfer amount per tx hash for holder. + + Logs that are not a 3-topic Transfer plus a 32-byte amount are ignored. + A standard Transfer that cannot be tied to a transaction hash fails the + whole sum: dropping it would under-count the block. + """ + try: + holder_addr = evm_address_from_bytes32(left_pad_32(holder)) + token_addr = evm_address_from_bytes32(left_pad_32(token)) + except ValueError: + return None + transfer_topic = bytes.fromhex(_TRANSFER_TOPIC[2:]) + nets: dict = {} + for log in logs: + address = _topic_address(_log_field(log, "address")) + if address is None: + raw_address = _as_hex_bytes(_log_field(log, "address")) + if raw_address is None or len(raw_address) != 20: + return None + address = raw_address + if address != token_addr: + continue + topics = _log_field(log, "topics") or [] + try: + topic_list = list(topics) + except TypeError: + return None + if len(topic_list) != 3: + continue + topic0 = _as_hex_bytes(topic_list[0]) + if topic0 != transfer_topic: + continue + data = _as_hex_bytes(_log_field(log, "data")) + sender = _topic_address(topic_list[1]) + receiver = _topic_address(topic_list[2]) + if data is None or len(data) != 32 or sender is None or receiver is None: + continue + tx_key = _norm_tx_hash(_log_field(log, "transactionHash")) + if tx_key is None: + return None + value = int.from_bytes(data, "big") + delta = 0 + if sender == holder_addr: + delta -= value + if receiver == holder_addr: + delta += value + if delta != 0: + nets[tx_key] = nets.get(tx_key, 0) + delta + return nets + + +async def _read_slot_uint( + web3: AsyncWeb3, token_cs: str, holder: bytes, slot: int, block_number: int +) -> Optional[int]: + try: + raw = await web3.eth.get_storage_at( + token_cs, _storage_key(holder, slot), block_identifier=block_number + ) + return int.from_bytes(bytes(raw), "big") + except Exception: + logging.info("get_storage_at failed during log balance diff", exc_info=True) + return None + + +async def _balance_storage_delta( + web3: AsyncWeb3, token: bytes, holder: bytes, block_number: int +) -> Optional[int]: + """Change in the balanceOf slot from the parent block to this block. + + A zero balance whose mapped slots are unchanged has delta 0. An ambiguous + slot returns None so the caller retries instead of guessing. + """ + if block_number < 1: + return None + token_cs = _checksum(token) + holder_cs = _checksum(holder) + try: + contract = web3.eth.contract(address=token_cs, abi=ERC20_ABI) + balance = int( + await contract.functions.balanceOf(holder_cs).call( + block_identifier=block_number + ) + ) + except Exception: + logging.info("balanceOf failed during log balance diff", exc_info=True) + return None + + matching: List[int] = [] + for slot in range(0, 64): + stored = await _read_slot_uint(web3, token_cs, holder, slot, block_number) + if stored is None: + return None + if stored == balance: + matching.append(slot) + + if balance != 0: + if len(matching) != 1: + return None + pre = await _read_slot_uint( + web3, token_cs, holder, matching[0], block_number - 1 + ) + if pre is None: + return None + return balance - pre + + if not matching: + return None + changed: List[int] = [] + for slot in matching: + pre = await _read_slot_uint(web3, token_cs, holder, slot, block_number - 1) + if pre is None: + return None + if pre != 0: + changed.append(0 - pre) + if len(changed) > 1: + return None + if len(changed) == 1: + return changed[0] + return 0 + + +async def _balance_diff_from_logs( web3: AsyncWeb3, tx_hash: str, token: bytes, holder: bytes, block_number: int, ) -> Optional[int]: - tx_hash_hex = tx_hash if tx_hash.startswith("0x") else "0x" + tx_hash - + storage_delta = await _balance_storage_delta(web3, token, holder, block_number) + if storage_delta is None: + return None try: - result = await _rpc( - web3, - "debug_traceTransaction", - [tx_hash_hex, {"tracer": "prestateTracer", "tracerConfig": {"diffMode": True}}], + logs = await web3.eth.get_logs( + { + "fromBlock": block_number, + "toBlock": block_number, + "address": _checksum(token), + "topics": [_TRANSFER_TOPIC], + } ) - state_diff = _normalize_trace_state_diff(result) - if state_diff is not None: - diff = await _extract_token_balance_diff( - web3, state_diff, token, holder, block_number - ) - if diff is not None: - return diff + log_list = list(logs) except Exception: - logging.info("debug_traceTransaction prestateTracer failed", exc_info=True) + logging.info("eth_getLogs failed during log balance diff", exc_info=True) + return None + nets = _holder_log_nets(log_list, token, holder) + if nets is None: + return None + if sum(nets.values()) != storage_delta: + logging.info( + "token balance logs do not match storage slot change (logs=%s storage=%s)", + sum(nets.values()), + storage_delta, + ) + return None + tx_key = _norm_tx_hash(tx_hash) + if tx_key is None: + return None + return nets.get(tx_key, 0) + + +async def _diff_from_trace_method( + web3: AsyncWeb3, + method: str, + params: list, + token: bytes, + holder: bytes, + block_number: int, +) -> Optional[Tuple[Optional[int]]]: + """(diff,) when this method returned a state diff, else None. + + The diff inside the tuple may be None. That still means a tracer + answered, so the log fallback must not replace it. + """ try: - result = await _rpc( - web3, + result = await _rpc(web3, method, params) + except Exception as exc: + _log_tracer_failure(method, exc) + return None + state_diff = _normalize_trace_state_diff(result) + if state_diff is None: + return None + diff = await _extract_token_balance_diff( + web3, state_diff, token, holder, block_number + ) + return (diff,) + + +async def fetch_token_balance_diff( + web3: AsyncWeb3, + tx_hash: str, + token: bytes, + holder: bytes, + block_number: int, +) -> Optional[int]: + tx_hash_hex = tx_hash if tx_hash.startswith("0x") else "0x" + tx_hash + trace_calls = ( + ( + "debug_traceTransaction", + [tx_hash_hex, {"tracer": "prestateTracer", "tracerConfig": {"diffMode": True}}], + ), + ( "trace_replayTransaction", [tx_hash_hex, ["stateDiff"]], + ), + ) + saw_state_diff = False + for method, params in trace_calls: + traced = await _diff_from_trace_method( + web3, method, params, token, holder, block_number ) - state_diff = _normalize_trace_state_diff(result) - if state_diff is not None: - diff = await _extract_token_balance_diff( - web3, state_diff, token, holder, block_number - ) - if diff is not None: - return diff - except Exception: - logging.info("trace_replayTransaction stateDiff failed", exc_info=True) - - return None + if traced is None: + continue + saw_state_diff = True + if traced[0] is not None: + return traced[0] + if saw_state_diff: + return None + return await _balance_diff_from_logs( + web3, tx_hash_hex, token, holder, block_number + ) def _bytecode_is_not_wrapped_cat(code: bytes) -> bool: diff --git a/tests/test_spend_policy.py b/tests/test_spend_policy.py index 8233baa..e521e1a 100644 --- a/tests/test_spend_policy.py +++ b/tests/test_spend_policy.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import List, Optional, Tuple from unittest.mock import patch @@ -1185,3 +1186,222 @@ async def test_empty_code_rejects_when_artifact_loader_raises(self): assert empty.kind == "reject" assert empty.reason == "WrappedCAT bytecode mismatch after immutable mask" assert other.kind == "retry" + + +def _padded_topic(addr: bytes) -> str: + return "0x" + addr.rjust(32, b"\x00").hex() + + +def _uint_data(value: int) -> str: + return "0x" + value.to_bytes(32, "big").hex() + + +class _TraceProvider: + def __init__(self, responses): + self.responses = responses + self.methods = [] + + async def make_request(self, method, params): + self.methods.append(method) + response = self.responses.get(method) + if response is None: + return { + "error": { + "code": -32601, + "message": "The method does not exist/is not available", + } + } + if isinstance(response, Exception): + raise response + return response + + +class _BalanceCall: + def __init__(self, balance): + self._balance = balance + + async def call(self, block_identifier=None): + return self._balance + + +class _BalanceContract: + def __init__(self, balance): + self._balance = balance + self.functions = self + + def balanceOf(self, _holder): + return _BalanceCall(self._balance) + + +class _BalanceEth: + def __init__(self, balance, slots, logs, provider): + self._balance = balance + self.slots = slots + self.logs = logs + self.provider = provider + self.log_calls = 0 + + def contract(self, address, abi): + return _BalanceContract(self._balance) + + async def get_storage_at(self, token, key, block_identifier=None): + value = self.slots.get((block_identifier, str(key).lower()), 0) + return value.to_bytes(32, "big") + + async def get_logs(self, filt): + self.log_calls += 1 + if isinstance(self.logs, Exception): + raise self.logs + return self.logs + + +class _BalanceWeb3: + def __init__(self, eth): + self.eth = eth + self.provider = eth.provider + + +class TestLogBalanceDiff: + TOKEN = bytes.fromhex("11" * 20) + HOLDER = bytes.fromhex("22" * 20) + OTHER = bytes.fromhex("33" * 20) + TX_A = "aa" * 32 + TX_B = "bb" * 32 + BLOCK = 10 + + def _transfer(self, tx_hash: str, sender: bytes, receiver: bytes, value: int): + from commands.policy_runtime import _TRANSFER_TOPIC + + return { + "address": "0x" + self.TOKEN.hex(), + "topics": [ + _TRANSFER_TOPIC, + _padded_topic(sender), + _padded_topic(receiver), + ], + "data": _uint_data(value), + "transactionHash": "0x" + tx_hash, + } + + def _slot(self, block: int, value: int): + from commands.policy_runtime import _storage_key + + return {(block, _storage_key(self.HOLDER, 0).lower()): value} + + def _web3(self, balance, slots, logs, responses=None): + provider = _TraceProvider(responses or {}) + eth = _BalanceEth(balance, slots, logs, provider) + return _BalanceWeb3(eth) + + def _trace(self, pre: int, post: int): + from commands.policy_runtime import _storage_key + + slot = _storage_key(self.HOLDER, 0) + account = "0x" + self.TOKEN.hex() + return { + "pre": {account: {"storage": {slot: hex(pre)}}}, + "post": {account: {"storage": {slot: hex(post)}}}, + } + + async def _fetch(self, web3, tx_hash: str): + from commands.policy_runtime import fetch_token_balance_diff + + return await fetch_token_balance_diff( + web3, "0x" + tx_hash, self.TOKEN, self.HOLDER, self.BLOCK + ) + + @pytest.mark.asyncio + async def test_two_transfers_attribute_per_transaction(self): + logs = [ + self._transfer(self.TX_A, self.OTHER, self.HOLDER, 50), + self._transfer(self.TX_B, self.OTHER, self.HOLDER, 70), + ] + slots = {**self._slot(self.BLOCK, 120), **self._slot(self.BLOCK - 1, 0)} + web3 = self._web3(120, slots, logs) + assert await self._fetch(web3, self.TX_A) == 50 + assert await self._fetch(web3, self.TX_B) == 70 + + @pytest.mark.asyncio + async def test_log_sum_must_match_storage_delta(self): + logs = [self._transfer(self.TX_A, self.OTHER, self.HOLDER, 50)] + slots = {**self._slot(self.BLOCK, 120), **self._slot(self.BLOCK - 1, 0)} + web3 = self._web3(120, slots, logs) + assert await self._fetch(web3, self.TX_A) is None + + @pytest.mark.asyncio + async def test_unchanged_zero_balance_uses_cancelling_log_net(self): + logs = [ + self._transfer(self.TX_A, self.OTHER, self.HOLDER, 4), + self._transfer(self.TX_B, self.HOLDER, self.OTHER, 4), + ] + web3 = self._web3(0, {}, logs) + assert await self._fetch(web3, self.TX_A) == 4 + assert await self._fetch(web3, self.TX_B) == -4 + + @pytest.mark.asyncio + async def test_missing_tracers_fall_through_and_log_once(self, caplog): + from commands import policy_runtime + + policy_runtime._missing_tracer_methods.clear() + logs = [self._transfer(self.TX_A, self.OTHER, self.HOLDER, 50)] + slots = {**self._slot(self.BLOCK, 50), **self._slot(self.BLOCK - 1, 0)} + web3 = self._web3(50, slots, logs) + with caplog.at_level(logging.INFO): + assert await self._fetch(web3, self.TX_A) == 50 + assert await self._fetch(web3, self.TX_A) == 50 + missing = [ + record + for record in caplog.records + if record.message.endswith("is not available on this RPC") + ] + assert [record.message for record in missing] == [ + "debug_traceTransaction is not available on this RPC", + "trace_replayTransaction is not available on this RPC", + ] + assert all(record.exc_info is None for record in missing) + + @pytest.mark.asyncio + async def test_other_tracer_errors_keep_a_traceback(self, caplog): + logs = [self._transfer(self.TX_A, self.OTHER, self.HOLDER, 50)] + slots = {**self._slot(self.BLOCK, 50), **self._slot(self.BLOCK - 1, 0)} + web3 = self._web3( + 50, + slots, + logs, + {"debug_traceTransaction": {"error": {"code": -32000, "message": "boom"}}}, + ) + with caplog.at_level(logging.INFO): + assert await self._fetch(web3, self.TX_A) == 50 + failed = [ + record + for record in caplog.records + if record.message == "debug_traceTransaction failed" + ] + assert len(failed) == 1 + assert failed[0].exc_info is not None + + @pytest.mark.asyncio + async def test_trace_result_skips_logs(self): + slots = self._slot(self.BLOCK, 25) + web3 = self._web3( + 25, + slots, + AssertionError("logs should not be read"), + {"debug_traceTransaction": {"result": self._trace(10, 25)}}, + ) + assert await self._fetch(web3, self.TX_A) == 15 + assert web3.eth.log_calls == 0 + assert "trace_replayTransaction" not in web3.provider.methods + + @pytest.mark.asyncio + async def test_trace_without_token_does_not_use_logs(self): + logs = [self._transfer(self.TX_A, self.OTHER, self.HOLDER, 50)] + slots = {**self._slot(self.BLOCK, 50), **self._slot(self.BLOCK - 1, 0)} + web3 = self._web3( + 50, + slots, + logs, + {"debug_traceTransaction": {"result": {"pre": {}, "post": {}}}}, + ) + assert await self._fetch(web3, self.TX_A) is None + assert web3.eth.log_calls == 0 From 6765294403a0ba952e25c01dde7e35656be6c5f8 Mon Sep 17 00:00:00 2001 From: Yak Date: Sat, 10 Oct 2026 20:04:14 +0100 Subject: [PATCH 10/19] better rate limits --- commands/policy_runtime.py | 57 ++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/commands/policy_runtime.py b/commands/policy_runtime.py index 9957390..7d26561 100644 --- a/commands/policy_runtime.py +++ b/commands/policy_runtime.py @@ -988,6 +988,33 @@ async def fetch_token_balance_diff( ) +class RpcRateLimited(Exception): + """The RPC refused the call because the endpoint is rate limited. + + The message is fixed so logs do not include the request URL. + """ + + +def is_rpc_rate_limited(exc: BaseException) -> bool: + seen: set[int] = set() + current: Optional[BaseException] = exc + while current is not None and id(current) not in seen: + seen.add(id(current)) + status = getattr(current, "status", None) + if status == 429: + return True + message = getattr(current, "message", None) + if isinstance(message, str) and "too many requests" in message.lower(): + return True + current = getattr(current, "__cause__", None) or getattr(current, "__context__", None) + return False + + +def _raise_if_rate_limited(exc: BaseException) -> None: + if is_rpc_rate_limited(exc): + raise RpcRateLimited("rpc rate limited") from None + + def _bytecode_is_not_wrapped_cat(code: bytes) -> bool: """True when code is present and cannot be the current WrappedCAT. @@ -1005,7 +1032,8 @@ async def read_wrapped_cat_views( ) -> Tuple[Optional[bytes], Optional[bytes], Optional[bytes], Optional[bytes], Optional[bytes], Optional[int], Optional[int]]: try: code = bytes(await web3.eth.get_code(_checksum(address), block_identifier=block_number)) - except Exception: + except Exception as exc: + _raise_if_rate_limited(exc) logging.error("Failed to read WrappedCAT code", exc_info=True) return None, None, None, None, None, None, None @@ -1036,7 +1064,10 @@ async def read_wrapped_cat_views( int(tip), int(ratio), ) - except Exception: + except RpcRateLimited: + raise + except Exception as exc: + _raise_if_rate_limited(exc) logging.error("Failed to read WrappedCAT views", exc_info=True) return None, None, None, None, None, None, None @@ -1124,12 +1155,17 @@ async def evaluate_chia_message_policy( if evm_block_number is None: try: evm_block_number = await web3.eth.block_number - except Exception: + except Exception as exc: + if is_rpc_rate_limited(exc): + return PolicyResult("retry", "rpc rate limited") return PolicyResult("retry", "could not read destination block number") - code, view_portal, view_other, view_locker, _, view_tip, view_ratio = await read_wrapped_cat_views( - web3, dest_addr, evm_block_number - ) + try: + code, view_portal, view_other, view_locker, _, view_tip, view_ratio = await read_wrapped_cat_views( + web3, dest_addr, evm_block_number + ) + except RpcRateLimited: + return PolicyResult("retry", "rpc rate limited") # get_code succeeded with an empty runtime. Reject before the mask # compare: an artifact-load failure must not turn this into a retry. # A non-empty runtime we could not compare still falls through. @@ -1381,9 +1417,12 @@ async def _evaluate_evm_cat_unwrap( tx_hash: str, tx_status: int, ) -> PolicyResult: - code, view_portal, view_other, _, unlocker, tip_bps, mojo_to_token_ratio = await read_wrapped_cat_views( - web3, source, block_number - ) + try: + code, view_portal, view_other, _, unlocker, tip_bps, mojo_to_token_ratio = await read_wrapped_cat_views( + web3, source, block_number + ) + except RpcRateLimited: + return PolicyResult("retry", "rpc rate limited") if code is None: return PolicyResult("retry", "missing WrappedCAT runtime code") # get_code succeeded. Empty or non-WrappedCAT is final: do not trace the From abb88f61273772a3c6287760d27b132a8b3559f3 Mon Sep 17 00:00:00 2001 From: Yak Date: Sat, 10 Oct 2026 20:29:57 +0100 Subject: [PATCH 11/19] better errors --- commands/policy_runtime.py | 9 ++++++++- commands/spend_policy.py | 27 +++++++++++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/commands/policy_runtime.py b/commands/policy_runtime.py index 7d26561..2acfaf8 100644 --- a/commands/policy_runtime.py +++ b/commands/policy_runtime.py @@ -1012,7 +1012,14 @@ def is_rpc_rate_limited(exc: BaseException) -> bool: def _raise_if_rate_limited(exc: BaseException) -> None: if is_rpc_rate_limited(exc): - raise RpcRateLimited("rpc rate limited") from None + body = getattr(exc, "body", "") or "" + if not isinstance(body, str): + body = "" + body = body.replace("\n", " ").strip() + if len(body) > 300: + body = body[:300] + message = "rpc rate limited" if not body else f"rpc rate limited: {body}" + raise RpcRateLimited(message) from None def _bytecode_is_not_wrapped_cat(code: bytes) -> bool: diff --git a/commands/spend_policy.py b/commands/spend_policy.py index a1bbf99..b575673 100644 --- a/commands/spend_policy.py +++ b/commands/spend_policy.py @@ -496,6 +496,17 @@ def stamp_wrapped_cat_immutables( return bytes(buf) +def _artifact_load_retry(exc: BaseException) -> PolicyResult: + """Retry, and keep the loader error. The message must not include a URL.""" + detail = f"{type(exc).__name__}: {exc}".replace("\n", " ").strip() + if len(detail) > 300: + detail = detail[:300] + return PolicyResult( + "retry", + f"could not load WrappedCAT artifact for mask compare ({detail})", + ) + + def match_wrapped_cat_bytecode(runtime_code: bytes) -> bool: """Masked structural compare. Does not accept immutable values. @@ -861,8 +872,8 @@ def validate_chia_cat_wrap( try: if not match_wrapped_cat_bytecode(wrapped_cat_runtime_code): return PolicyResult("reject", "WrappedCAT bytecode mismatch after immutable mask") - except Exception: - return PolicyResult("retry", "could not load WrappedCAT artifact for mask compare") + except Exception as exc: + return _artifact_load_retry(exc) if view_tip is None or view_mojo_to_token_ratio is None: return PolicyResult("retry", "missing WrappedCAT bytecode or views") @@ -875,8 +886,8 @@ def validate_chia_cat_wrap( mojo_to_token_ratio=view_mojo_to_token_ratio, ): return PolicyResult("reject", "WrappedCAT immutable occurrence mismatch") - except Exception: - return PolicyResult("retry", "could not load WrappedCAT artifact for mask compare") + except Exception as exc: + return _artifact_load_retry(exc) if ( view_portal is None @@ -1056,8 +1067,8 @@ def validate_evm_cat_unwrap( try: if not match_wrapped_cat_bytecode(wrapped_cat_runtime_code): return PolicyResult("reject", "WrappedCAT bytecode mismatch after immutable mask") - except Exception: - return PolicyResult("retry", "could not load WrappedCAT artifact for mask compare") + except Exception as exc: + return _artifact_load_retry(exc) if tip_bps is not None and mojo_to_token_ratio is not None: if isinstance(tip_bps, bool) or not isinstance(tip_bps, int) or tip_bps < 1 or tip_bps > 1000: @@ -1077,8 +1088,8 @@ def validate_evm_cat_unwrap( mojo_to_token_ratio=mojo_to_token_ratio, ): return PolicyResult("reject", "WrappedCAT immutable occurrence mismatch") - except Exception: - return PolicyResult("retry", "could not load WrappedCAT artifact for mask compare") + except Exception as exc: + return _artifact_load_retry(exc) if view_portal is None or view_other_chain is None or unlocker_puzzle_hash is None: return PolicyResult("retry", "missing WrappedCAT views") From b003456037e7361519232a6fbda69035de34da19 Mon Sep 17 00:00:00 2001 From: Yak Date: Sat, 10 Oct 2026 20:30:30 +0100 Subject: [PATCH 12/19] test update --- tests/test_spend_policy.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_spend_policy.py b/tests/test_spend_policy.py index e521e1a..037d03a 100644 --- a/tests/test_spend_policy.py +++ b/tests/test_spend_policy.py @@ -641,6 +641,34 @@ def test_parent_not_locker_rejects(self): assert result.kind == "reject" assert "LOCKER_MOD" in result.reason or "parent" in result.reason + def test_artifact_load_error_names_the_exception(self): + with patch( + "commands.spend_policy.match_wrapped_cat_bytecode", + side_effect=FileNotFoundError("no WrappedCAT in artifacts/build-info"), + ): + result = validate_evm_cat_unwrap( + routes=_routes(), + source_chain=b"eth", + source=left_pad_32(WRAPPED_CAT_ADDR), + destination=b"\x24" * 32, + destination_chain=b"xch", + contents=[left_pad_32(RECEIVER), (1).to_bytes(32, "big")], + tx_status=1, + tip_bps=30, + mojo_to_token_ratio=1, + gross_mojo_amount=1, + sender_balance_diff=1, + portal_balance_diff=1, + view_portal=left_pad_32(ETH_PORTAL), + view_other_chain=b"xch", + unlocker_puzzle_hash=b"\x24" * 32, + wrapped_cat_runtime_code=b"\x60\x00", + ) + assert result.kind == "retry" + assert "could not load WrappedCAT artifact for mask compare" in result.reason + assert "FileNotFoundError" in result.reason + assert "no WrappedCAT in artifacts/build-info" in result.reason + def test_load_wrapped_cat_build_info_from_other_cwd(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) artifact = load_wrapped_cat_build_info() From d36a27121af413c591dd75e2548069fedee58789 Mon Sep 17 00:00:00 2001 From: Yak Date: Sat, 10 Oct 2026 20:48:51 +0100 Subject: [PATCH 13/19] save credits on the watcher --- commands/policy_runtime.py | 28 +++-- commands/spend_policy.py | 41 +++---- tests/test_spend_policy.py | 215 +++++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+), 27 deletions(-) diff --git a/commands/policy_runtime.py b/commands/policy_runtime.py index 2acfaf8..26c926a 100644 --- a/commands/policy_runtime.py +++ b/commands/policy_runtime.py @@ -1253,6 +1253,7 @@ async def evaluate_evm_message_policy( contents: Sequence[bytes], block_number: int, tx_hash: str, + verify_balance_diff: bool = True, ) -> PolicyResult: if len(source) > 32: source = source[-32:] @@ -1291,6 +1292,7 @@ async def evaluate_evm_message_policy( tx=tx, tx_hash=tx_hash, tx_status=tx_status, + verify_balance_diff=verify_balance_diff, ) return await _evaluate_evm_cat_unwrap( @@ -1305,6 +1307,7 @@ async def evaluate_evm_message_policy( tx=tx, tx_hash=tx_hash, tx_status=tx_status, + verify_balance_diff=verify_balance_diff, ) @@ -1322,6 +1325,7 @@ async def _evaluate_evm_erc20_wrap( tx, tx_hash: str, tx_status: int, + verify_balance_diff: bool = True, ) -> PolicyResult: try: bridge = web3.eth.contract(address=_checksum(route.erc20_bridge_address), abi=BRIDGE_ABI) @@ -1379,7 +1383,7 @@ async def _evaluate_evm_erc20_wrap( balance_diff_asset = left_pad_32(contents[0]) if contents else None bridge_token_balance_diff = None - if balance_diff_asset is not None: + if verify_balance_diff and balance_diff_asset is not None: bridge_token_balance_diff = await fetch_token_balance_diff( web3, tx_hash, @@ -1407,6 +1411,7 @@ async def _evaluate_evm_erc20_wrap( msg_value=msg_value if is_bridge_ether else None, message_toll=int(message_toll) if is_bridge_ether else None, weth_to_eth_ratio=int(weth_to_eth_ratio) if is_bridge_ether else None, + verify_balance_diff=verify_balance_diff, ) @@ -1423,6 +1428,7 @@ async def _evaluate_evm_cat_unwrap( tx, tx_hash: str, tx_status: int, + verify_balance_diff: bool = True, ) -> PolicyResult: try: code, view_portal, view_other, _, unlocker, tip_bps, mojo_to_token_ratio = await read_wrapped_cat_views( @@ -1454,14 +1460,17 @@ async def _evaluate_evm_cat_unwrap( if route is None: return PolicyResult("reject", "unknown source chain") - sender_balance_diff = await fetch_token_balance_diff( - web3, tx_hash, source, sender, block_number - ) - portal_balance_diff = await fetch_token_balance_diff( - web3, tx_hash, source, route.portal_address, block_number - ) - if sender_balance_diff is None or portal_balance_diff is None: - return PolicyResult("retry", "missing balance diffs") + sender_balance_diff = None + portal_balance_diff = None + if verify_balance_diff: + sender_balance_diff = await fetch_token_balance_diff( + web3, tx_hash, source, sender, block_number + ) + portal_balance_diff = await fetch_token_balance_diff( + web3, tx_hash, source, route.portal_address, block_number + ) + if sender_balance_diff is None or portal_balance_diff is None: + return PolicyResult("retry", "missing balance diffs") return validate_evm_cat_unwrap( routes=routes, @@ -1481,6 +1490,7 @@ async def _evaluate_evm_cat_unwrap( unlocker_puzzle_hash=unlocker, wrapped_cat_runtime_code=code, asset_id_known=False, + verify_balance_diff=verify_balance_diff, ) diff --git a/commands/spend_policy.py b/commands/spend_policy.py index b575673..e919ad8 100644 --- a/commands/spend_policy.py +++ b/commands/spend_policy.py @@ -948,6 +948,7 @@ def validate_evm_erc20_wrap( msg_value: Optional[int] = None, message_toll: Optional[int] = None, weth_to_eth_ratio: Optional[int] = None, + verify_balance_diff: bool = True, ) -> PolicyResult: route = routes.for_chain(source_chain) if route is None: @@ -997,12 +998,13 @@ def validate_evm_erc20_wrap( factor = 10 ** (decimals - 3) asset = left_pad_32(contents[0]) - if bridge_token_balance_diff is None: - return PolicyResult("retry", "missing bridge balance diff") - if balance_diff_asset is None: - return PolicyResult("retry", "missing balance diff asset") - if evm_address_from_bytes32(balance_diff_asset) != evm_address_from_bytes32(contents[0]): - return PolicyResult("reject", "balance diff asset mismatch") + if verify_balance_diff: + if bridge_token_balance_diff is None: + return PolicyResult("retry", "missing bridge balance diff") + if balance_diff_asset is None: + return PolicyResult("retry", "missing balance diff asset") + if evm_address_from_bytes32(balance_diff_asset) != evm_address_from_bytes32(contents[0]): + return PolicyResult("reject", "balance diff asset mismatch") if is_bridge_ether: if msg_value is None or message_toll is None or weth_to_eth_ratio is None: @@ -1019,10 +1021,11 @@ def validate_evm_erc20_wrap( left_pad_32(route.millieth_address) ): return PolicyResult("reject", "ether bridge asset is not milliETH/WETH") - expected_diff = (gross_mojo_amount - tip) * factor - if bridge_token_balance_diff != expected_diff: - return PolicyResult("reject", "milliETH bridge balance diff mismatch") - else: + if verify_balance_diff: + expected_diff = (gross_mojo_amount - tip) * factor + if bridge_token_balance_diff != expected_diff: + return PolicyResult("reject", "milliETH bridge balance diff mismatch") + elif verify_balance_diff: expected_diff = (gross_mojo_amount - tip) * factor if bridge_token_balance_diff != expected_diff: return PolicyResult("reject", "bridge token balance diff mismatch") @@ -1050,6 +1053,7 @@ def validate_evm_cat_unwrap( wrapped_cat_runtime_code: Optional[bytes] = None, asset_id: Optional[bytes] = None, asset_id_known: bool = False, + verify_balance_diff: bool = True, ) -> PolicyResult: route = routes.for_chain(source_chain) if route is None: @@ -1134,15 +1138,16 @@ def validate_evm_cat_unwrap( if _as_int_word(contents[1]) != net: return PolicyResult("reject", "message net amount mismatch") - if sender_balance_diff is None or portal_balance_diff is None: - return PolicyResult("retry", "missing balance diffs") + if verify_balance_diff: + if sender_balance_diff is None or portal_balance_diff is None: + return PolicyResult("retry", "missing balance diffs") - expected_sender_diff = -(gross_mojo_amount * mojo_to_token_ratio) - expected_portal_diff = tip * mojo_to_token_ratio - if sender_balance_diff != expected_sender_diff: - return PolicyResult("reject", "sender balance diff mismatch") - if portal_balance_diff != expected_portal_diff: - return PolicyResult("reject", "portal balance diff mismatch") + expected_sender_diff = -(gross_mojo_amount * mojo_to_token_ratio) + expected_portal_diff = tip * mojo_to_token_ratio + if sender_balance_diff != expected_sender_diff: + return PolicyResult("reject", "sender balance diff mismatch") + if portal_balance_diff != expected_portal_diff: + return PolicyResult("reject", "portal balance diff mismatch") return PolicyResult("accept", "evm cat unwrap") diff --git a/tests/test_spend_policy.py b/tests/test_spend_policy.py index 037d03a..63a7cc5 100644 --- a/tests/test_spend_policy.py +++ b/tests/test_spend_policy.py @@ -957,6 +957,221 @@ def test_ether_amount_after_toll_divisible(self): assert "wethToEthRatio" in bad.reason +class _ViewCall: + def __init__(self, value): + self._value = value + + async def call(self, block_identifier=None): + return self._value + + +class _ViewFunctions: + def __init__(self, values): + self._values = values + + def __getattr__(self, name): + value = self._values[name] + + def method(*_args, **_kwargs): + return _ViewCall(value) + + return method + + +class _ViewContract: + def __init__(self, values): + self.functions = _ViewFunctions(values) + + +class _ViewEth: + def __init__(self, values): + self._values = values + + def contract(self, address, abi): + return _ViewContract(self._values) + + +class _ViewWeb3: + def __init__(self, values): + self.eth = _ViewEth(values) + + +class _CalldataTx: + def __init__(self, data: bytes, value: int = 0, sender: str = "0x" + "44" * 20): + self.input = data + self.value = value + self._sender = sender + + def __getitem__(self, key): + if key == "from": + return self._sender + raise KeyError(key) + + +class TestSkippedBalanceDiff: + def test_wrap_accepts_without_diff_and_still_rejects_amount_mismatch(self): + kwargs = TestEvmErc20Wrap()._base_kwargs() + kwargs["bridge_token_balance_diff"] = None + kwargs["verify_balance_diff"] = False + result = validate_evm_erc20_wrap(**kwargs) + assert result.kind == "accept" + + kwargs["contents"] = [ + ASSET_A, + left_pad_32(RECEIVER), + (10_000).to_bytes(32, "big"), + ] + result = validate_evm_erc20_wrap(**kwargs) + assert result.kind == "reject" + assert "message amount" in result.reason + + def test_unwrap_accepts_without_diffs_and_still_rejects_amount_mismatch(self): + unlocker = b"\x24" * 32 + gross = 10_000 + tip = compute_bridge_tip(gross, WRAP_TIP_BPS) + net = gross - tip + kwargs = dict( + routes=_routes(), + source_chain=b"eth", + source=left_pad_32(WRAPPED_CAT_ADDR), + destination=unlocker, + destination_chain=b"xch", + contents=[left_pad_32(RECEIVER), net.to_bytes(32, "big")], + tx_status=1, + tip_bps=WRAP_TIP_BPS, + mojo_to_token_ratio=WRAP_MOJO_TO_TOKEN_RATIO, + gross_mojo_amount=gross, + sender_balance_diff=None, + portal_balance_diff=None, + view_portal=left_pad_32(ETH_PORTAL), + view_other_chain=b"xch", + unlocker_puzzle_hash=unlocker, + wrapped_cat_runtime_code=b"\x60\x00", + verify_balance_diff=False, + ) + with patch("commands.spend_policy.match_wrapped_cat_bytecode", return_value=True), patch( + "commands.spend_policy.wrapped_cat_immutable_values_match", return_value=True + ): + ok = validate_evm_cat_unwrap(**kwargs) + assert ok.kind == "accept" + + bad_kwargs = dict(kwargs) + bad_kwargs["contents"] = [left_pad_32(RECEIVER), gross.to_bytes(32, "big")] + bad = validate_evm_cat_unwrap(**bad_kwargs) + assert bad.kind == "reject" + assert "net amount" in bad.reason + + default_kwargs = dict(kwargs) + default_kwargs["verify_balance_diff"] = True + default = validate_evm_cat_unwrap(**default_kwargs) + assert default.kind == "retry" + assert default.reason == "missing balance diffs" + + @pytest.mark.asyncio + async def test_wrap_evaluate_skips_fetch_and_default_still_fetches(self): + from commands.policy_runtime import _evaluate_evm_erc20_wrap + + gross = 10_000 + net = gross - compute_bridge_tip(gross, 30) + asset = bytes.fromhex("aa" * 20) + calldata = ( + bytes.fromhex("cdb50da7") + + left_pad_32(asset) + + b"\x00" * 32 + + gross.to_bytes(32, "big") + ) + web3 = _ViewWeb3( + {"tip": 30, "wethToEthRatio": 1, "messageToll": 0, "decimals": 18} + ) + contents = [left_pad_32(asset), left_pad_32(RECEIVER), net.to_bytes(32, "big")] + calls = [] + + async def fetch(*_args, **_kwargs): + calls.append(1) + return None + + common = dict( + web3=web3, + routes=_routes(), + route=_routes().for_chain(b"eth"), + source_chain=b"eth", + source=left_pad_32(ETH_BRIDGE), + destination=get_cat_minter_puzzle(PORTAL, b"eth", ETH_BRIDGE).get_tree_hash(), + destination_chain=b"xch", + contents=contents, + block_number=1, + tx=_CalldataTx(calldata), + tx_hash="0x" + "11" * 32, + tx_status=1, + ) + with patch("commands.policy_runtime.fetch_token_balance_diff", new=fetch): + skipped = await _evaluate_evm_erc20_wrap(**common, verify_balance_diff=False) + assert calls == [] + assert skipped.kind == "accept" + + default = await _evaluate_evm_erc20_wrap(**common) + assert calls == [1] + assert default.kind == "retry" + assert default.reason == "missing bridge balance diff" + + @pytest.mark.asyncio + async def test_unwrap_evaluate_skips_fetch_and_default_still_fetches(self): + from commands.policy_runtime import _evaluate_evm_cat_unwrap + + unlocker = b"\x24" * 32 + gross = 10_000 + net = gross - compute_bridge_tip(gross, WRAP_TIP_BPS) + calldata = bytes.fromhex("415d9361") + b"\x00" * 32 + gross.to_bytes(32, "big") + contents = [left_pad_32(RECEIVER), net.to_bytes(32, "big")] + calls = [] + + async def fetch(*_args, **_kwargs): + calls.append(1) + return None + + async def views(*_args, **_kwargs): + return ( + b"\x60\x00", + left_pad_32(ETH_PORTAL), + b"xch", + b"\x00" * 32, + unlocker, + WRAP_TIP_BPS, + WRAP_MOJO_TO_TOKEN_RATIO, + ) + + common = dict( + web3=object(), + routes=_routes(), + source_chain=b"eth", + source=left_pad_32(WRAPPED_CAT_ADDR), + destination=unlocker, + destination_chain=b"xch", + contents=contents, + block_number=1, + tx=_CalldataTx(calldata), + tx_hash="0x" + "11" * 32, + tx_status=1, + ) + with patch("commands.policy_runtime.fetch_token_balance_diff", new=fetch), patch( + "commands.policy_runtime.read_wrapped_cat_views", new=views + ), patch( + "commands.policy_runtime.match_wrapped_cat_bytecode", return_value=True + ), patch( + "commands.spend_policy.match_wrapped_cat_bytecode", return_value=True + ), patch( + "commands.spend_policy.wrapped_cat_immutable_values_match", return_value=True + ): + skipped = await _evaluate_evm_cat_unwrap(**common, verify_balance_diff=False) + assert calls == [] + assert skipped.kind == "accept" + + default = await _evaluate_evm_cat_unwrap(**common) + assert calls == [1, 1] + assert default.kind == "retry" + assert default.reason == "missing balance diffs" + + # --------------------------------------------------------------------------- # 4. evm_address_from_bytes32 # --------------------------------------------------------------------------- From afc35089a54d34c50949876eb0f851a9290bf6fd Mon Sep 17 00:00:00 2001 From: Yak Date: Sun, 11 Oct 2026 12:35:04 +0100 Subject: [PATCH 14/19] works when not relays are online --- commands/control_channel.py | 41 ++++++++++++++++++++----- commands/policy_runtime.py | 1 + tests/test_control_channel.py | 56 +++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/commands/control_channel.py b/commands/control_channel.py index 6448509..b680985 100644 --- a/commands/control_channel.py +++ b/commands/control_channel.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from dataclasses import dataclass from datetime import timedelta from typing import Iterable, List, Literal, Optional, Sequence, Set @@ -23,11 +24,15 @@ class ControlEvent: created_at: int +DEFAULT_RELAY_QUORUM = 5 + + @dataclass(frozen=True) class ControlConfig: stop_authors: Set[str] start_authors: Set[str] relays: List[str] + relay_quorum: int = DEFAULT_RELAY_QUORUM def normalize_hex(value: str) -> str: @@ -55,6 +60,7 @@ def load_control_config(config: Optional[dict] = None) -> ControlConfig: Requires non-empty, non-overlapping control_stop_authors and control_start_authors. control_relays is optional and defaults to nostr.relays. + control_relay_quorum is optional and defaults to 5. """ cfg = _loaded_config if config is None else config try: @@ -92,13 +98,22 @@ def load_control_config(config: Optional[dict] = None) -> ControlConfig: if not isinstance(relays, list) or not all(isinstance(r, str) for r in relays): raise ValueError("nostr control relays must be a list of strings") + relay_quorum = _parse_relay_quorum(nostr.get("control_relay_quorum", DEFAULT_RELAY_QUORUM)) + return ControlConfig( stop_authors=stop_authors, start_authors=start_authors, relays=list(relays), + relay_quorum=relay_quorum, ) +def _parse_relay_quorum(raw: object) -> int: + if isinstance(raw, bool) or not isinstance(raw, int) or raw < 1: + raise ValueError("nostr.control_relay_quorum must be a positive integer") + return raw + + def _command_and_id(content: str) -> Optional[tuple[str, str]]: """Parse a note whose entire text is `start ` or `stop `. @@ -226,19 +241,23 @@ async def fetch_control_events( start_authors: Set[str], *, timeout_seconds: float = 10.0, + relay_quorum: int = DEFAULT_RELAY_QUORUM, ) -> List[ControlEvent]: """ - Fetch kind-1 notes from every control relay and union by event id. + Fetch kind-1 notes from control relays and union by event id. - Each relay is queried independently so one withholding relay cannot hide a - stop seen on another. The events are unioned by id only when every - configured relay answers. If any relay errors, raises RuntimeError and - does not return the partial union (caller must treat that as stop). One - live relay cannot outvote a halt the others failed to return. No caching. + Each relay is queried independently. The events are unioned when at least + min(relay_quorum, number of relays) relays answer. Fewer successes raise + RuntimeError and do not return a partial union (caller must treat that as + stop). Relays that fail after the quorum is met are logged and omitted + from this poll. No caching. """ if not relays: raise RuntimeError("no control relays configured") + if isinstance(relay_quorum, bool) or not isinstance(relay_quorum, int) or relay_quorum < 1: + raise ValueError("relay_quorum must be a positive integer") + required = min(relay_quorum, len(relays)) authors = _author_public_keys(set(stop_authors) | set(start_authors)) filt = Filter().kinds([Kind(1)]).authors(authors) timeout = timedelta(seconds=timeout_seconds) @@ -257,7 +276,7 @@ async def fetch_control_events( except Exception as e: errors.append(f"{relay}: {e}") - if successes != len(relays): + if successes < required: detail = "; ".join(errors) if errors else "unknown errors" if successes == 0: raise RuntimeError(f"all control relays failed: {detail}") @@ -265,4 +284,12 @@ async def fetch_control_events( f"control relay fetch incomplete ({successes}/{len(relays)} relays): {detail}" ) + if errors: + logging.warning( + "control relay fetch met quorum (%s/%s relays); ignoring failures: %s", + successes, + len(relays), + "; ".join(errors), + ) + return list(by_id.values()) diff --git a/commands/policy_runtime.py b/commands/policy_runtime.py index 26c926a..5de3576 100644 --- a/commands/policy_runtime.py +++ b/commands/policy_runtime.py @@ -404,6 +404,7 @@ async def control_allows_start(control_config: ControlConfig) -> bool: control_config.relays, control_config.stop_authors, control_config.start_authors, + relay_quorum=control_config.relay_quorum, ) except Exception: if _last_control_command != "stop": diff --git a/tests/test_control_channel.py b/tests/test_control_channel.py index e3a1ce9..8f2a3ae 100644 --- a/tests/test_control_channel.py +++ b/tests/test_control_channel.py @@ -97,6 +97,19 @@ def test_loads_disjoint_authors(self): cfg = load_control_config(_nostr_config()) assert STOP_AUTHOR in cfg.stop_authors assert START_AUTHOR in cfg.start_authors + assert cfg.relay_quorum == 5 + + def test_relay_quorum_override(self): + raw = _nostr_config() + raw["nostr"]["control_relay_quorum"] = 3 + assert load_control_config(raw).relay_quorum == 3 + + @pytest.mark.parametrize("quorum", [0, -1, True, False, "5", 1.5]) + def test_relay_quorum_must_be_a_positive_integer(self, quorum): + raw = _nostr_config() + raw["nostr"]["control_relay_quorum"] = quorum + with pytest.raises(ValueError, match="control_relay_quorum"): + load_control_config(raw) class TestDecideControl: @@ -273,6 +286,49 @@ async def fake_fetch(relay, filt, timeout): ) assert {ev.id for ev in events} == {"aa" * 32, "bb" * 32} + @pytest.mark.asyncio + async def test_quorum_allows_some_relays_to_fail(self, monkeypatch): + stop_author, start_author = _valid_pubkeys() + + async def fake_fetch(relay, filt, timeout): + if relay.endswith("bad"): + raise RuntimeError("timeout") + index = relay[-1] + return [_RawEvent(index * 64, stop_author, "stop a", NOW - 100)] + + monkeypatch.setattr( + "commands.control_channel._fetch_events_from_relay", fake_fetch + ) + relays = [f"wss://relay-{i}" for i in "abcde"] + ["wss://relay-bad"] + events = await fetch_control_events( + relays, + {stop_author}, + {start_author}, + relay_quorum=5, + ) + assert {ev.id for ev in events} == {i * 64 for i in "abcde"} + + @pytest.mark.asyncio + async def test_below_quorum_is_incomplete(self, monkeypatch): + stop_author, start_author = _valid_pubkeys() + + async def fake_fetch(relay, filt, timeout): + if relay[-1] in "ef": + raise RuntimeError("timeout") + return [_RawEvent("aa" * 32, stop_author, "stop a", NOW - 100)] + + monkeypatch.setattr( + "commands.control_channel._fetch_events_from_relay", fake_fetch + ) + relays = [f"wss://relay-{i}" for i in "abcdef"] + with pytest.raises(RuntimeError, match="incomplete"): + await fetch_control_events( + relays, + {stop_author}, + {start_author}, + relay_quorum=5, + ) + class TestPublish: @pytest.mark.asyncio From b97b8bd5a730d6564915c78a3e6a001442bd1a09 Mon Sep 17 00:00:00 2001 From: Yak Date: Sun, 11 Oct 2026 13:04:37 +0100 Subject: [PATCH 15/19] ability to pre-sign stop/start --- commands/control.py | 183 +++++++- tests/test_control_channel.py | 794 ++++++++++++++++++++++++++++++++++ 2 files changed, 959 insertions(+), 18 deletions(-) diff --git a/commands/control.py b/commands/control.py index 60dbba4..484f5cd 100644 --- a/commands/control.py +++ b/commands/control.py @@ -1,10 +1,18 @@ +import asyncio import sys import click -from nostr_sdk import Client, EventBuilder, Keys +from nostr_sdk import Client, Event, EventBuilder, Keys from commands.cli_wrappers import async_func -from commands.control_channel import load_control_config, normalize_hex +from commands.control_channel import ( + _command_and_id, + load_control_config, + normalize_hex, +) + +# Tests monkeypatch this. Referenced at call time so a patched value is used. +PUBLISH_TIMEOUT_SECONDS = 10 @click.group() @@ -13,7 +21,7 @@ def control(): def _prompt_keys() -> Keys: - mnemonic = click.prompt("Nostr mnemonic", hide_input=True) + mnemonic = click.prompt("Nostr mnemonic", hide_input=True, err=True) return Keys.from_mnemonic(mnemonic, None) @@ -30,27 +38,54 @@ def _print_event(event) -> None: click.echo(f"created_at: {int(event.created_at().as_secs())}") +async def _send_to_relay(client, relay: str, event): + """add_relay, connect, and send_event. CancelledError is not caught.""" + try: + await client.add_relay(relay) + await client.connect() + return await client.send_event(event) + except Exception as exc: + return exc + + +async def _publish_one(relay: str, event) -> tuple[str, str, str]: + """Publish to one relay. Returns (relay, "accepted"|"rejected", detail).""" + client = Client(None) + try: + try: + result = await asyncio.wait_for( + _send_to_relay(client, relay, event), + timeout=PUBLISH_TIMEOUT_SECONDS, + ) + except TimeoutError: + return relay, "rejected", "timeout" + finally: + try: + await client.disconnect() + except Exception: + pass + if isinstance(result, Exception): + return relay, "rejected", str(result) + return relay, "accepted", result.to_hex() + + async def _publish_to_relays(relays: list[str], event) -> None: """Publish the same signed event to each relay independently. - Exits non-zero when every relay rejects. One acceptance is enough. + Relays are attempted concurrently, and each attempt is bounded by + PUBLISH_TIMEOUT_SECONDS. Exits non-zero when every relay rejects. + One acceptance is enough. """ + results = await asyncio.gather( + *(_publish_one(relay, event) for relay in relays) + ) accepted = 0 - for relay in relays: - client = Client(None) - try: - await client.add_relay(relay) - await client.connect() - event_id = await client.send_event(event) - click.echo(f"ACCEPTED {relay} {event_id.to_hex()}") + for relay, status, detail in results: + if status == "accepted": + click.echo(f"ACCEPTED {relay} {detail}") accepted += 1 - except Exception as e: - click.echo(f"REJECTED {relay} {e}") - finally: - try: - await client.disconnect() - except Exception: - pass + else: + click.echo(f"REJECTED {relay} {detail}") if accepted == 0: click.echo("FAILED: no relay accepted the event") sys.exit(1) @@ -92,3 +127,115 @@ async def start_cmd(note_id: str): event = EventBuilder.text_note(f"start {note_id}", []).to_event(keys) _print_event(event) await _publish_to_relays(cfg.relays, event) + + +def _sign_control_note( + note_id: str, + command: str, + authors, + author_list_name: str, +) -> None: + """Sign a kind-1 control note and print it. Does not publish.""" + keys = _prompt_keys() + pubkey = normalize_hex(keys.public_key().to_hex()) + if pubkey not in authors: + click.echo( + f"Refused: pubkey {pubkey} is not in {author_list_name}" + ) + sys.exit(1) + + event = EventBuilder.text_note(f"{command} {note_id}", []).to_event(keys) + click.echo(f"content: {event.content()}", err=True) + click.echo(f"author: {event.author().to_hex()}", err=True) + click.echo(f"tags: {[t.as_vec() for t in event.tags()]}", err=True) + click.echo(f"created_at: {int(event.created_at().as_secs())}", err=True) + click.echo(event.as_json()) + + +@control.command("sign-stop") +@click.argument("note_id") +def sign_stop(note_id: str): + _require_note_id(note_id) + cfg = load_control_config() + _sign_control_note(note_id, "stop", cfg.stop_authors, "control_stop_authors") + + +@control.command("sign-start") +@click.argument("note_id") +def sign_start(note_id: str): + _require_note_id(note_id) + cfg = load_control_config() + _sign_control_note( + note_id, "start", cfg.start_authors, "control_start_authors" + ) + + +def _refuse(message: str) -> None: + click.echo(f"Refused: {message}") + sys.exit(1) + + +def _read_event_source(src: str) -> str: + """Read one event JSON document from a path, or stdin when src is '-'.""" + if src == "-": + return sys.stdin.read() + try: + with open(src, encoding="utf-8") as handle: + return handle.read() + except (OSError, UnicodeError) as exc: + _refuse(f"cannot read {src}: {exc}") + + +def _require_signed_control_event(raw: str, command: str): + """Parse a signed control note. No relay contact and no re-signing.""" + try: + event = Event.from_json(raw) + except Exception: + _refuse("invalid event JSON") + + try: + verified = event.verify() + except Exception: + verified = False + if not verified: + _refuse("signature or id did not verify") + + if event.kind().as_u16() != 1: + _refuse("kind must be 1") + + if list(event.tags()): + _refuse("tags must be empty") + + parsed = _command_and_id(event.content()) + if parsed is None or parsed[0] != command: + _refuse(f"content must be exactly '{command} '") + return event + + +async def _publish_signed_note( + src: str, command: str, author_list_name: str +) -> None: + """Publish an already-signed control note. Does not query relays.""" + event = _require_signed_control_event(_read_event_source(src), command) + cfg = load_control_config() + authors = cfg.stop_authors if command == "stop" else cfg.start_authors + pubkey = normalize_hex(event.author().to_hex()) + if pubkey not in authors: + _refuse(f"pubkey {pubkey} is not in {author_list_name}") + + _print_event(event) + await _publish_to_relays(cfg.relays, event) + + +@control.command("publish-signed-stop") +@click.argument("src") +@async_func +async def publish_signed_stop(src: str): + await _publish_signed_note(src, "stop", "control_stop_authors") + + +@control.command("publish-signed-start") +@click.argument("src") +@async_func +async def publish_signed_start(src: str): + await _publish_signed_note(src, "start", "control_start_authors") diff --git a/tests/test_control_channel.py b/tests/test_control_channel.py index 8f2a3ae..97e4b10 100644 --- a/tests/test_control_channel.py +++ b/tests/test_control_channel.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio +import io import json import logging @@ -330,6 +332,84 @@ async def fake_fetch(relay, filt, timeout): ) +class _Hex: + def __init__(self, value): + self._value = value + + def to_hex(self): + return self._value + + +class _Secs: + def __init__(self, value): + self._value = value + + def as_secs(self): + return self._value + + +class _KindValue: + def __init__(self, value): + self._value = value + + def as_u16(self): + return self._value + + +class _FakeControlEvent: + def __init__( + self, + *, + author, + content, + kind=1, + tags=None, + verified=True, + verify_raises=False, + created_at=NOW, + ): + self._author = author + self._content = content + self._kind = kind + self._tags = [] if tags is None else tags + self._verified = verified + self._verify_raises = verify_raises + self._created_at = created_at + + def content(self): + return self._content + + def author(self): + return _Hex(self._author) + + def tags(self): + return self._tags + + def created_at(self): + return _Secs(self._created_at) + + def kind(self): + return _KindValue(self._kind) + + def verify(self): + if self._verify_raises: + raise RuntimeError("bad signature") + return self._verified + + +def _event_json(**overrides) -> str: + payload = { + "author": STOP_AUTHOR, + "content": "stop incident-7", + "kind": 1, + "tags": [], + "created_at": NOW, + "verified": True, + } + payload.update(overrides) + return json.dumps(payload) + + class TestPublish: @pytest.mark.asyncio async def test_zero_accepts_exits_nonzero(self, monkeypatch, capsys): @@ -391,6 +471,94 @@ async def disconnect(self): assert "ACCEPTED wss://good.example" in out assert "REJECTED wss://bad.example" in out + @pytest.mark.asyncio + async def test_relays_are_published_concurrently(self, monkeypatch, capsys): + from commands.control import _publish_to_relays + + # Both sends must be inside send_event together. Sequential publish + # never starts the second send, so the barrier waits until the test + # timeout fails it. + barrier = asyncio.Barrier(2) + event_hex = "ab" * 32 + + class _Eid: + def to_hex(self): + return event_hex + + class _Client: + def __init__(self, signer): + self.relay = "" + + async def add_relay(self, relay): + self.relay = relay + + async def connect(self): + return None + + async def send_event(self, event): + await barrier.wait() + return _Eid() + + async def disconnect(self): + return None + + monkeypatch.setattr("commands.control.Client", _Client) + await asyncio.wait_for( + _publish_to_relays( + ["wss://a.example", "wss://b.example"], object() + ), + timeout=1, + ) + out = capsys.readouterr().out + assert out.splitlines() == [ + f"ACCEPTED wss://a.example {event_hex}", + f"ACCEPTED wss://b.example {event_hex}", + ] + + @pytest.mark.asyncio + async def test_hung_relay_times_out_other_is_accepted( + self, monkeypatch, capsys + ): + from commands.control import _publish_to_relays + + event_hex = "cd" * 32 + monkeypatch.setattr("commands.control.PUBLISH_TIMEOUT_SECONDS", 0.05) + + class _Eid: + def to_hex(self): + return event_hex + + class _Client: + def __init__(self, signer): + self.relay = "" + + async def add_relay(self, relay): + self.relay = relay + + async def connect(self): + return None + + async def send_event(self, event): + if "hung" in self.relay: + await asyncio.Event().wait() + return _Eid() + + async def disconnect(self): + return None + + monkeypatch.setattr("commands.control.Client", _Client) + await asyncio.wait_for( + _publish_to_relays( + ["wss://hung.example", "wss://good.example"], object() + ), + timeout=1, + ) + out = capsys.readouterr().out + assert out.splitlines() == [ + "REJECTED wss://hung.example timeout", + f"ACCEPTED wss://good.example {event_hex}", + ] + @pytest.mark.asyncio async def test_stop_and_start_publish_id_text_without_tags(self, monkeypatch): import commands.control as control_mod @@ -483,6 +651,632 @@ def unwrap(command): assert seen["tags"] == [] assert seen["published"] == "start incident-7" + def _install_sign_fakes(self, monkeypatch, pubkey: str): + import commands.control as control_mod + + seen = {"signed": False, "published": False, "pubkey": pubkey} + + def fake_text_note(content, tags): + seen["signed"] = True + seen["content"] = content + seen["tags"] = list(tags) + + class _Author: + def to_hex(self): + return seen["pubkey"] + + class _Created: + def as_secs(self): + return NOW + + class _Event: + def content(self): + return content + + def author(self): + return _Author() + + def tags(self): + return [] + + def created_at(self): + return _Created() + + def as_json(self): + return json.dumps({"content": content, "tags": []}) + + class _Builder: + def to_event(self, keys): + return _Event() + + return _Builder() + + class _Pub: + def __init__(self, value): + self._value = value + + def to_hex(self): + return self._value + + class _Keys: + def public_key(self): + return _Pub(pubkey) + + def fail_publish(*args, **kwargs): + seen["published"] = True + raise AssertionError("sign must not publish") + + class _Client: + def __init__(self, *args, **kwargs): + raise AssertionError("sign must not construct a Client") + + monkeypatch.setattr(control_mod.EventBuilder, "text_note", fake_text_note) + monkeypatch.setattr(control_mod, "_publish_to_relays", fail_publish) + monkeypatch.setattr(control_mod, "Client", _Client) + monkeypatch.setattr(control_mod, "_prompt_keys", lambda: _Keys()) + monkeypatch.setattr( + control_mod, + "load_control_config", + lambda: ControlConfig( + stop_authors={STOP_AUTHOR}, + start_authors={START_AUTHOR}, + relays=["wss://relay.example"], + ), + ) + return control_mod, seen + + @staticmethod + def _unwrap(command): + callback = command.callback + while hasattr(callback, "__wrapped__"): + callback = callback.__wrapped__ + return callback + + def test_sign_stop_and_sign_start_print_json_without_publishing( + self, monkeypatch, capsys + ): + control_mod, seen = self._install_sign_fakes(monkeypatch, STOP_AUTHOR) + note_id = "Incident-7" + + self._unwrap(control_mod.sign_stop)(note_id) + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert payload == {"content": f"stop {note_id}", "tags": []} + assert captured.out == json.dumps(payload) + "\n" + assert captured.err.splitlines() == [ + f"content: stop {note_id}", + f"author: {STOP_AUTHOR}", + "tags: []", + f"created_at: {NOW}", + ] + assert "content:" not in captured.out + assert seen["content"] == f"stop {note_id}" + assert seen["tags"] == [] + assert seen["published"] is False + + control_mod, seen = self._install_sign_fakes(monkeypatch, START_AUTHOR) + self._unwrap(control_mod.sign_start)(note_id) + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert payload == {"content": f"start {note_id}", "tags": []} + assert captured.out == json.dumps(payload) + "\n" + assert captured.err.splitlines() == [ + f"content: start {note_id}", + f"author: {START_AUTHOR}", + "tags: []", + f"created_at: {NOW}", + ] + assert "content:" not in captured.out + assert seen["content"] == f"start {note_id}" + assert seen["tags"] == [] + assert seen["published"] is False + + @pytest.mark.parametrize( + ("command_name", "verb", "author"), + [ + ("sign-stop", "stop", STOP_AUTHOR), + ("sign-start", "start", START_AUTHOR), + ], + ) + def test_sign_mnemonic_prompt_goes_to_stderr( + self, monkeypatch, command_name, verb, author + ): + from click.testing import CliRunner + + import commands.control as control_mod + + seen = {"published": False, "pubkey": author, "mnemonic": None} + + def fake_text_note(content, tags): + class _Author: + def to_hex(self): + return seen["pubkey"] + + class _Created: + def as_secs(self): + return NOW + + class _Event: + def content(self): + return content + + def author(self): + return _Author() + + def tags(self): + return [] + + def created_at(self): + return _Created() + + def as_json(self): + return json.dumps({"content": content, "tags": []}) + + class _Builder: + def to_event(self, keys): + return _Event() + + return _Builder() + + class _Pub: + def to_hex(self): + return seen["pubkey"] + + class _Keys: + def public_key(self): + return _Pub() + + def fake_from_mnemonic(mnemonic, passphrase): + seen["mnemonic"] = mnemonic + return _Keys() + + def fail_publish(*args, **kwargs): + seen["published"] = True + raise AssertionError("sign must not publish") + + monkeypatch.setattr(control_mod.EventBuilder, "text_note", fake_text_note) + monkeypatch.setattr( + control_mod.Keys, "from_mnemonic", staticmethod(fake_from_mnemonic) + ) + monkeypatch.setattr(control_mod, "_publish_to_relays", fail_publish) + monkeypatch.setattr( + control_mod, + "load_control_config", + lambda: ControlConfig( + stop_authors={STOP_AUTHOR}, + start_authors={START_AUTHOR}, + relays=["wss://relay.example"], + ), + ) + + note_id = "Incident-7" + mnemonic = "test mnemonic words" + result = CliRunner(mix_stderr=False).invoke( + control_mod.control, + [command_name, note_id], + input=f"{mnemonic}\n", + ) + assert result.exit_code == 0 + stdout = result.stdout.strip() + assert "Nostr mnemonic" not in stdout + assert json.loads(stdout) == { + "content": f"{verb} {note_id}", + "tags": [], + } + assert "Nostr mnemonic" in result.stderr + assert seen["published"] is False + assert seen["mnemonic"] == mnemonic + + def test_sign_refuses_pubkey_outside_author_list(self, monkeypatch, capsys): + control_mod, seen = self._install_sign_fakes(monkeypatch, OTHER_AUTHOR) + + with pytest.raises(SystemExit) as exc: + self._unwrap(control_mod.sign_stop)("incident-7") + assert exc.value.code not in (0, None) + captured = capsys.readouterr() + assert ( + captured.out + == f"Refused: pubkey {OTHER_AUTHOR} is not in control_stop_authors\n" + ) + assert seen["signed"] is False + assert seen["published"] is False + + with pytest.raises(SystemExit) as exc: + self._unwrap(control_mod.sign_start)("incident-7") + assert exc.value.code not in (0, None) + captured = capsys.readouterr() + assert ( + captured.out + == f"Refused: pubkey {OTHER_AUTHOR} is not in control_start_authors\n" + ) + assert seen["signed"] is False + assert seen["published"] is False + + @pytest.mark.parametrize("note_id", ["", " ", "bad id", "id\n"]) + @pytest.mark.parametrize( + "command_name", + ["sign_stop", "sign_start"], + ) + def test_sign_refuses_empty_or_whitespace_id( + self, monkeypatch, capsys, note_id, command_name + ): + control_mod, seen = self._install_sign_fakes(monkeypatch, STOP_AUTHOR) + + def fail_prompt(): + raise AssertionError("sign must not prompt for a bad id") + + monkeypatch.setattr(control_mod, "_prompt_keys", fail_prompt) + with pytest.raises(SystemExit) as exc: + self._unwrap(getattr(control_mod, command_name))(note_id) + assert exc.value.code not in (0, None) + captured = capsys.readouterr() + assert captured.out == "Refused: id must be a single token\n" + assert seen["signed"] is False + assert seen["published"] is False + + def _install_publish_recorder( + self, monkeypatch, *, stop_authors, start_authors=None + ): + import commands.control as control_mod + + seen = {"published": False} + + async def fake_publish(relays, event): + seen["published"] = True + seen["relays"] = list(relays) + seen["published_event"] = event + + async def fail_fetch(*args, **kwargs): + raise AssertionError("control publish must not query relays") + + def fail_prompt(): + raise AssertionError("publish-signed must not prompt for a mnemonic") + + def fail_text_note(*args, **kwargs): + raise AssertionError("publish-signed must not re-sign") + + class _Client: + def __init__(self, *args, **kwargs): + raise AssertionError("publish-signed must not open a relay client") + + monkeypatch.setattr(control_mod, "_publish_to_relays", fake_publish) + monkeypatch.setattr(control_mod, "_prompt_keys", fail_prompt) + monkeypatch.setattr(control_mod.EventBuilder, "text_note", fail_text_note) + monkeypatch.setattr(control_mod, "Client", _Client) + monkeypatch.setattr( + "commands.control_channel.fetch_control_events", fail_fetch + ) + monkeypatch.setattr( + control_mod, + "load_control_config", + lambda: ControlConfig( + stop_authors=set(stop_authors), + start_authors=set( + start_authors if start_authors is not None else {START_AUTHOR} + ), + relays=["wss://relay.example"], + ), + ) + return control_mod, seen + + def _install_fake_signed_event(self, monkeypatch): + control_mod, seen = self._install_publish_recorder( + monkeypatch, + stop_authors={STOP_AUTHOR}, + start_authors={START_AUTHOR}, + ) + + def fake_from_json(raw): + seen["raw"] = raw + data = json.loads(raw) + if data.get("parse_error"): + raise ValueError("structurally invalid event") + event = _FakeControlEvent( + author=data["author"], + content=data["content"], + kind=data.get("kind", 1), + tags=data.get("tags", []), + verified=data.get("verified", True), + verify_raises=data.get("verify_raises", False), + created_at=data.get("created_at", NOW), + ) + seen["event"] = event + return event + + monkeypatch.setattr(control_mod.Event, "from_json", fake_from_json) + return control_mod, seen + + @pytest.mark.asyncio + async def test_publish_signed_stop_publishes_valid_event( + self, monkeypatch, tmp_path, capsys + ): + control_mod, seen = self._install_fake_signed_event(monkeypatch) + payload = _event_json() + path = tmp_path / "stop.json" + path.write_text(payload, encoding="utf-8") + + await self._unwrap(control_mod.publish_signed_stop)(str(path)) + + assert seen["raw"] == path.read_text(encoding="utf-8") + assert seen["published"] is True + assert seen["published_event"] is seen["event"] + assert seen["relays"] == ["wss://relay.example"] + assert seen["published_event"].content() == "stop incident-7" + assert capsys.readouterr().out.splitlines() == [ + "content: stop incident-7", + f"author: {STOP_AUTHOR}", + "tags: []", + f"created_at: {NOW}", + ] + + @pytest.mark.asyncio + async def test_publish_signed_start_publishes_valid_event( + self, monkeypatch, tmp_path, capsys + ): + control_mod, seen = self._install_fake_signed_event(monkeypatch) + payload = _event_json( + author=START_AUTHOR, + content="start incident-7", + ) + path = tmp_path / "start.json" + path.write_text(payload, encoding="utf-8") + + await self._unwrap(control_mod.publish_signed_start)(str(path)) + + assert seen["raw"] == payload + assert seen["published"] is True + assert seen["published_event"] is seen["event"] + assert seen["relays"] == ["wss://relay.example"] + assert seen["published_event"].content() == "start incident-7" + assert capsys.readouterr().out.splitlines() == [ + "content: start incident-7", + f"author: {START_AUTHOR}", + "tags: []", + f"created_at: {NOW}", + ] + + @pytest.mark.asyncio + async def test_publish_signed_reads_stdin( + self, monkeypatch, capsys + ): + control_mod, seen = self._install_fake_signed_event(monkeypatch) + payload = _event_json() + monkeypatch.setattr("sys.stdin", io.StringIO(payload)) + + await self._unwrap(control_mod.publish_signed_stop)("-") + + assert seen["raw"] == payload + assert seen["published"] is True + assert seen["published_event"].content() == "stop incident-7" + assert "content: stop incident-7" in capsys.readouterr().out + + @pytest.mark.asyncio + @pytest.mark.parametrize("verify_raises", [False, True]) + async def test_publish_signed_refuses_bad_signature( + self, monkeypatch, tmp_path, capsys, verify_raises + ): + control_mod, seen = self._install_fake_signed_event(monkeypatch) + path = tmp_path / "bad-sig.json" + path.write_text( + _event_json(verified=False, verify_raises=verify_raises), + encoding="utf-8", + ) + + with pytest.raises(SystemExit) as exc: + await self._unwrap(control_mod.publish_signed_stop)(str(path)) + assert exc.value.code not in (0, None) + assert seen["published"] is False + assert ( + capsys.readouterr().out + == "Refused: signature or id did not verify\n" + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "content", + [ + "start incident-7", + "stop", + "stop incident-7 extra", + "STOP incident-7", + ], + ) + async def test_publish_signed_stop_refuses_non_stop_content( + self, monkeypatch, tmp_path, capsys, content + ): + control_mod, seen = self._install_fake_signed_event(monkeypatch) + path = tmp_path / "content.json" + path.write_text(_event_json(content=content), encoding="utf-8") + + with pytest.raises(SystemExit) as exc: + await self._unwrap(control_mod.publish_signed_stop)(str(path)) + assert exc.value.code not in (0, None) + assert seen["published"] is False + assert ( + capsys.readouterr().out + == "Refused: content must be exactly 'stop '\n" + ) + + @pytest.mark.asyncio + async def test_publish_signed_refuses_non_empty_tags( + self, monkeypatch, tmp_path, capsys + ): + control_mod, seen = self._install_fake_signed_event(monkeypatch) + path = tmp_path / "tagged.json" + path.write_text( + _event_json(tags=[["t", "nope"]]), + encoding="utf-8", + ) + + with pytest.raises(SystemExit) as exc: + await self._unwrap(control_mod.publish_signed_stop)(str(path)) + assert exc.value.code not in (0, None) + assert seen["published"] is False + assert capsys.readouterr().out == "Refused: tags must be empty\n" + + @pytest.mark.asyncio + async def test_publish_signed_refuses_kind_other_than_1( + self, monkeypatch, tmp_path, capsys + ): + control_mod, seen = self._install_fake_signed_event(monkeypatch) + path = tmp_path / "kind.json" + path.write_text(_event_json(kind=0), encoding="utf-8") + + with pytest.raises(SystemExit) as exc: + await self._unwrap(control_mod.publish_signed_stop)(str(path)) + assert exc.value.code not in (0, None) + assert seen["published"] is False + assert capsys.readouterr().out == "Refused: kind must be 1\n" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "command_name,content,author_list", + [ + ("publish_signed_stop", "stop incident-7", "control_stop_authors"), + ("publish_signed_start", "start incident-7", "control_start_authors"), + ], + ) + async def test_publish_signed_refuses_author_outside_list( + self, monkeypatch, tmp_path, capsys, command_name, content, author_list + ): + control_mod, seen = self._install_fake_signed_event(monkeypatch) + path = tmp_path / "author.json" + path.write_text( + _event_json(author=OTHER_AUTHOR, content=content), + encoding="utf-8", + ) + + with pytest.raises(SystemExit) as exc: + await self._unwrap(getattr(control_mod, command_name))(str(path)) + assert exc.value.code not in (0, None) + assert seen["published"] is False + assert ( + capsys.readouterr().out + == f"Refused: pubkey {OTHER_AUTHOR} is not in {author_list}\n" + ) + + @pytest.mark.asyncio + async def test_publish_signed_stop_round_trips_real_event( + self, monkeypatch, tmp_path, capsys + ): + from nostr_sdk import EventBuilder, Keys + + import commands.control as control_mod + + keys = Keys.generate() + pubkey = control_mod.normalize_hex(keys.public_key().to_hex()) + signed = EventBuilder.text_note("stop incident-7", []).to_event(keys) + path = tmp_path / "real-stop.json" + path.write_text(signed.as_json(), encoding="utf-8") + control_mod, seen = self._install_publish_recorder( + monkeypatch, stop_authors={pubkey} + ) + + await self._unwrap(control_mod.publish_signed_stop)(str(path)) + + event = seen["published_event"] + assert seen["published"] is True + assert event.content() == "stop incident-7" + assert list(event.tags()) == [] + assert event.kind().as_u16() == 1 + assert control_mod.normalize_hex(event.author().to_hex()) == pubkey + assert event.verify() is True + assert seen["relays"] == ["wss://relay.example"] + assert capsys.readouterr().out.splitlines()[:3] == [ + "content: stop incident-7", + f"author: {keys.public_key().to_hex()}", + "tags: []", + ] + + @pytest.mark.asyncio + async def test_publish_signed_start_round_trips_real_event( + self, monkeypatch, tmp_path, capsys + ): + from nostr_sdk import EventBuilder, Keys + + import commands.control as control_mod + + keys = Keys.generate() + pubkey = control_mod.normalize_hex(keys.public_key().to_hex()) + signed = EventBuilder.text_note("start incident-7", []).to_event(keys) + path = tmp_path / "real-start.json" + path.write_text(signed.as_json(), encoding="utf-8") + control_mod, seen = self._install_publish_recorder( + monkeypatch, + stop_authors={STOP_AUTHOR}, + start_authors={pubkey}, + ) + + await self._unwrap(control_mod.publish_signed_start)(str(path)) + + event = seen["published_event"] + assert event.content() == "start incident-7" + assert list(event.tags()) == [] + assert event.kind().as_u16() == 1 + assert event.verify() is True + assert seen["relays"] == ["wss://relay.example"] + assert "content: start incident-7" in capsys.readouterr().out + + @pytest.mark.asyncio + async def test_publish_signed_refuses_tampered_real_event( + self, monkeypatch, tmp_path, capsys + ): + from nostr_sdk import EventBuilder, Keys + + import commands.control as control_mod + + keys = Keys.generate() + pubkey = control_mod.normalize_hex(keys.public_key().to_hex()) + signed = EventBuilder.text_note("stop incident-7", []).to_event(keys) + data = json.loads(signed.as_json()) + data["content"] = "stop incident-8" + path = tmp_path / "tampered.json" + path.write_text(json.dumps(data), encoding="utf-8") + control_mod, seen = self._install_publish_recorder( + monkeypatch, stop_authors={pubkey} + ) + + with pytest.raises(SystemExit) as exc: + await self._unwrap(control_mod.publish_signed_stop)(str(path)) + assert exc.value.code not in (0, None) + assert seen["published"] is False + assert ( + capsys.readouterr().out + == "Refused: signature or id did not verify\n" + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("text", ["{", "{}", ""]) + async def test_publish_signed_refuses_invalid_event_json( + self, monkeypatch, tmp_path, capsys, text + ): + control_mod, seen = self._install_publish_recorder( + monkeypatch, stop_authors={STOP_AUTHOR} + ) + path = tmp_path / "bad.json" + path.write_text(text, encoding="utf-8") + + with pytest.raises(SystemExit) as exc: + await self._unwrap(control_mod.publish_signed_stop)(str(path)) + assert exc.value.code not in (0, None) + assert seen["published"] is False + assert capsys.readouterr().out == "Refused: invalid event JSON\n" + + @pytest.mark.asyncio + async def test_publish_signed_missing_file_is_refused( + self, monkeypatch, tmp_path, capsys + ): + control_mod, seen = self._install_publish_recorder( + monkeypatch, stop_authors={STOP_AUTHOR} + ) + missing = tmp_path / "missing.json" + + with pytest.raises(SystemExit) as exc: + await self._unwrap(control_mod.publish_signed_stop)(str(missing)) + assert exc.value.code not in (0, None) + assert seen["published"] is False + assert capsys.readouterr().out.startswith("Refused:") + def _control_config(): return ControlConfig( From 489c7fc7234418cbb15e35a9fe2d9147c30b2117 Mon Sep 17 00:00:00 2001 From: Yak Date: Sun, 11 Oct 2026 13:57:09 +0100 Subject: [PATCH 16/19] better logging/err handling --- commands/control_channel.py | 147 ++++++++++++++++++------ commands/followers/eth_follower.py | 4 - commands/followers/xch_follower.py | 7 +- commands/policy_runtime.py | 59 +++++++--- tests/test_control_channel.py | 177 ++++++++++++++++++++++++++++- 5 files changed, 332 insertions(+), 62 deletions(-) diff --git a/commands/control_channel.py b/commands/control_channel.py index b680985..68caaf0 100644 --- a/commands/control_channel.py +++ b/commands/control_channel.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import threading from dataclasses import dataclass from datetime import timedelta from typing import Iterable, List, Literal, Optional, Sequence, Set @@ -26,6 +27,10 @@ class ControlEvent: DEFAULT_RELAY_QUORUM = 5 +# Last set of relays omitted after quorum was met. Logged again only when it changes. +_quorum_failure_lock = threading.Lock() +_last_quorum_failures: Optional[frozenset[str]] = None + @dataclass(frozen=True) class ControlConfig: @@ -148,28 +153,15 @@ def future_stop_author_notes( ] -def decide_control( +def _classified_notes( events: Sequence[ControlEvent], now_unix: int, stop_authors: Set[str], start_authors: Set[str], -) -> ControlCommand: - """ - Pure fail-closed control decision. No I/O. Callers inject `now_unix`. - - A stop note is ``stop `` from a stop author. A start note is - ``start `` from a start author. Anything else is ignored, including - notes dated more than CLOCK_SKEW_SECONDS ahead of ``now_unix``. - - Returns \"start\" only when the set of stop ids is non-empty and equal - to the set of start ids, and every stop note is cancelled by a start of - the same id with ``start.created_at >= stop.created_at - CLOCK_SKEW_SECONDS``. - A start timestamped earlier than that cannot pre-cancel a later stop. - Otherwise returns \"stop\". - """ +) -> tuple[List[tuple[str, ControlEvent]], List[tuple[str, ControlEvent]]]: + """Stop and start notes that are eligible for the decision right now.""" stop_set = {normalize_hex(a) for a in stop_authors} start_set = {normalize_hex(a) for a in start_authors} - stops: List[tuple[str, ControlEvent]] = [] starts: List[tuple[str, ControlEvent]] = [] for ev in events: @@ -184,23 +176,97 @@ def decide_control( stops.append((note_id, ev)) elif command == "start" and author in start_set: starts.append((note_id, ev)) + return stops, starts + + +def ignored_stop_author_notes( + events: Sequence[ControlEvent], + now_unix: int, + stop_authors: Set[str], +) -> List[ControlEvent]: + """Stop-author notes that are not ``stop `` and are not future-dated. + + Future-dated stop-author notes are reported separately. A start note or + any other text from a stop author does not affect the decision. + """ + stop_set = {normalize_hex(a) for a in stop_authors} + ignored: List[ControlEvent] = [] + for ev in events: + if normalize_hex(ev.author) not in stop_set: + continue + if ev.created_at > now_unix + CLOCK_SKEW_SECONDS: + continue + parsed = _command_and_id(ev.content) + if parsed is None or parsed[0] != "stop": + ignored.append(ev) + return ignored + + +def explain_control( + events: Sequence[ControlEvent], + now_unix: int, + stop_authors: Set[str], + start_authors: Set[str], +) -> tuple[ControlCommand, str]: + """Same decision as ``decide_control``, plus the one-line reason. + The reason names unmatched stop ids, a set mismatch, or the start ids + that cancelled every stop. It does not log. + """ + stops, starts = _classified_notes(events, now_unix, stop_authors, start_authors) stop_ids = {note_id for note_id, _ in stops} start_ids = {note_id for note_id, _ in starts} - if not stop_ids or stop_ids != start_ids: - return "stop" + if not stop_ids: + return "stop", "NOSTR STOP: no stop notes" starts_by_id: dict[str, List[ControlEvent]] = {} for note_id, ev in starts: starts_by_id.setdefault(note_id, []).append(ev) - for note_id, stop in stops: + unmatched = sorted({ + note_id + for note_id, stop in stops if not any( start.created_at >= stop.created_at - CLOCK_SKEW_SECONDS for start in starts_by_id.get(note_id, ()) - ): - return "stop" - return "start" + ) + }) + if unmatched: + reasons = ", ".join(f"stop {note_id}" for note_id in unmatched) + return "stop", f"NOSTR STOP: stopping because {reasons}" + + if stop_ids != start_ids: + stop_list = ", ".join(sorted(stop_ids)) + start_list = ", ".join(sorted(start_ids)) + return ( + "stop", + f"NOSTR STOP: stop ids {stop_list} are not matched by start ids {start_list}", + ) + + resumed = ", ".join(f"start {note_id}" for note_id in sorted(start_ids)) + return "start", f"NOSTR START: resuming because {resumed}" + + +def decide_control( + events: Sequence[ControlEvent], + now_unix: int, + stop_authors: Set[str], + start_authors: Set[str], +) -> ControlCommand: + """ + Pure fail-closed control decision. No I/O. Callers inject `now_unix`. + + A stop note is ``stop `` from a stop author. A start note is + ``start `` from a start author. Anything else is ignored, including + notes dated more than CLOCK_SKEW_SECONDS ahead of ``now_unix``. + + Returns \"start\" only when the set of stop ids is non-empty and equal + to the set of start ids, and every stop note is cancelled by a start of + the same id with ``start.created_at >= stop.created_at - CLOCK_SKEW_SECONDS``. + A start timestamped earlier than that cannot pre-cancel a later stop. + Otherwise returns \"stop\". + """ + return explain_control(events, now_unix, stop_authors, start_authors)[0] def control_event_from_nostr(event) -> ControlEvent: @@ -217,6 +283,29 @@ def _author_public_keys(authors: Iterable[str]) -> List[PublicKey]: return [PublicKey.from_hex(normalize_hex(a)) for a in authors] +def _warn_quorum_failures( + successes: int, + relay_count: int, + failures: Sequence[tuple[str, str]], +) -> None: + """Log a quorum miss once per distinct set of failed relays.""" + global _last_quorum_failures + failed = frozenset(relay for relay, _error in failures) + with _quorum_failure_lock: + if failed == _last_quorum_failures: + return + _last_quorum_failures = failed + if not failed: + return + detail = "; ".join(f"{relay}: {error}" for relay, error in failures) + logging.warning( + "control relay fetch met quorum (%s/%s relays); ignoring failures: %s", + successes, + relay_count, + detail, + ) + + async def _fetch_events_from_relay( relay: str, filt: Filter, @@ -263,7 +352,7 @@ async def fetch_control_events( timeout = timedelta(seconds=timeout_seconds) by_id: dict[str, ControlEvent] = {} - errors: List[str] = [] + failures: List[tuple[str, str]] = [] successes = 0 for relay in relays: @@ -274,22 +363,16 @@ async def fetch_control_events( ev = control_event_from_nostr(raw) by_id[ev.id] = ev except Exception as e: - errors.append(f"{relay}: {e}") + failures.append((relay, str(e))) if successes < required: - detail = "; ".join(errors) if errors else "unknown errors" + detail = "; ".join(f"{relay}: {error}" for relay, error in failures) or "unknown errors" if successes == 0: raise RuntimeError(f"all control relays failed: {detail}") raise RuntimeError( f"control relay fetch incomplete ({successes}/{len(relays)} relays): {detail}" ) - if errors: - logging.warning( - "control relay fetch met quorum (%s/%s relays); ignoring failures: %s", - successes, - len(relays), - "; ".join(errors), - ) + _warn_quorum_failures(successes, len(relays), failures) return list(by_id.values()) diff --git a/commands/followers/eth_follower.py b/commands/followers/eth_follower.py index 7ae08ea..aa834ca 100644 --- a/commands/followers/eth_follower.py +++ b/commands/followers/eth_follower.py @@ -170,7 +170,6 @@ async def messageListener(self): while True: try: if not await control_allows_start(self.control_config): - logging.info(f"{self.chain_id.decode()} message listener: control stop; sleeping 60s") await asyncio.sleep(60) continue @@ -181,7 +180,6 @@ async def messageListener(self): while next_message_event is None: if not await control_allows_start(self.control_config): - logging.info(f"{self.chain_id.decode()} message listener: control stop; sleeping 60s") await asyncio.sleep(60) continue await asyncio.sleep(30) @@ -238,7 +236,6 @@ async def messageListener(self): logging.info(f"{self.chain_id.decode()} message listener: Current L1 block number is {l1_block_number}") if not await control_allows_start(self.control_config): - logging.info(f"{self.chain_id.decode()} message listener: control stop; sleeping 60s") await asyncio.sleep(60) continue @@ -463,7 +460,6 @@ async def sign_one(message): while True: try: if not await control_allows_start(self.control_config): - logging.info(f"{self.chain_id.decode()} message signer: control stop; sleeping 60s") await asyncio.sleep(60) continue diff --git a/commands/followers/xch_follower.py b/commands/followers/xch_follower.py index 6464aa0..7939c1a 100644 --- a/commands/followers/xch_follower.py +++ b/commands/followers/xch_follower.py @@ -349,8 +349,9 @@ async def messageSigner(self): db = self.getDb() deadlines = SignerRetryDeadlines() - while self.syncing: + if self.syncing: logging.info(f"{self.chain_id.decode()} message signer: Waiting to be synced before signing messages...") + while self.syncing: await asyncio.sleep(10) await self.getUnspentPortalId() @@ -365,7 +366,6 @@ async def sign_one(message): while True: if not await control_allows_start(self.control_config): - logging.info(f"{self.chain_id.decode()} message signer: control stop; sleeping 60s") await asyncio.sleep(60) continue @@ -587,7 +587,6 @@ async def portalFollower(self): while True: try: if not await control_allows_start(self.control_config): - logging.info(f"{self.chain_id.decode()} portal follower: control stop; sleeping 60s") await asyncio.sleep(60) continue last_synced_portal = await self.syncPortal(db, node, last_synced_portal) @@ -744,7 +743,6 @@ async def messageListener(self): while True: try: if not await control_allows_start(self.control_config): - logging.info(f"{self.chain_id.decode()} message listener: control stop; sleeping 60s") await asyncio.sleep(60) continue @@ -800,7 +798,6 @@ async def messageListener(self): await asyncio.sleep(10) if not await control_allows_start(self.control_config): - logging.info(f"{self.chain_id.decode()} message listener: control stop; sleeping 60s") await asyncio.sleep(60) reorg = True break diff --git a/commands/policy_runtime.py b/commands/policy_runtime.py index 5de3576..454fbec 100644 --- a/commands/policy_runtime.py +++ b/commands/policy_runtime.py @@ -19,9 +19,10 @@ ControlConfig, ControlEvent, _command_and_id, - decide_control, + explain_control, fetch_control_events, future_stop_author_notes, + ignored_stop_author_notes, normalize_hex, ) from commands.spend_policy import ( @@ -244,7 +245,9 @@ async def sign_due_unsigned_messages( return batch -_last_control_command: str | None = None +_last_control_explanation: str | None = None +_logged_future_stop_ids: set[str] = set() +_logged_ignored_stop_ids: set[str] = set() # App folder, same working directory as config.json and data.db (/app in Docker). STOPS_FILE = Path("stops") @@ -259,15 +262,26 @@ class StopsFileError(Exception): """The stops file exists but is not a JSON object of stops.""" -def _note_control_command(command: str) -> None: - global _last_control_command - if command == _last_control_command: +def _note_control_explanation(explanation: str, *, exc_info: bool = False) -> None: + """Log a control reason once, and again only when the reason changes. + + A start line says the process is resuming, so it is logged only after a + stop in this process. A gate that is already open at startup is recorded + and stays quiet. + """ + global _last_control_explanation + if explanation == _last_control_explanation: return - if command == "stop": - logging.warning("NOSTR STOP") - elif command == "start" and _last_control_command == "stop": - logging.warning("NOSTR START: resume after stop") - _last_control_command = command + resuming = explanation.startswith("NOSTR START:") + was_stopped = ( + _last_control_explanation is not None + and _last_control_explanation.startswith("NOSTR STOP:") + ) + if resuming and not was_stopped: + _last_control_explanation = explanation + return + logging.warning(explanation, exc_info=exc_info) + _last_control_explanation = explanation def _is_remembered_stop(event: ControlEvent, stop_authors: set[str]) -> bool: @@ -398,7 +412,6 @@ def _with_remembered_stops( async def control_allows_start(control_config: ControlConfig) -> bool: - global _last_control_command try: events = await fetch_control_events( control_config.relays, @@ -407,9 +420,9 @@ async def control_allows_start(control_config: ControlConfig) -> bool: relay_quorum=control_config.relay_quorum, ) except Exception: - if _last_control_command != "stop": - logging.warning("NOSTR STOP: control relay fetch failed", exc_info=True) - _last_control_command = "stop" + _note_control_explanation( + "NOSTR STOP: control relay fetch failed", exc_info=True + ) return False stop_authors = {normalize_hex(author) for author in control_config.stop_authors} @@ -417,10 +430,10 @@ async def control_allows_start(control_config: ControlConfig) -> bool: remembered = _remember_stops(events, stop_authors) except StopsFileError: logging.error("stops file is not a JSON object of stops", exc_info=True) - _note_control_command("stop") + _note_control_explanation("NOSTR STOP: stops file is not a JSON object of stops") return False except OSError: - _note_control_command("stop") + _note_control_explanation("NOSTR STOP: failed to write stops file") return False now_unix = int(time.time()) @@ -428,17 +441,27 @@ async def control_allows_start(control_config: ControlConfig) -> bool: for ev in future_stop_author_notes( combined, now_unix, control_config.stop_authors ): + if ev.id in _logged_future_stop_ids: + continue + _logged_future_stop_ids.add(ev.id) logging.warning( f"ignoring future-dated stop-author note {ev.content!r} " f"created_at={ev.created_at} until the clock catches up" ) - command = decide_control( + for ev in ignored_stop_author_notes( + combined, now_unix, control_config.stop_authors + ): + if ev.id in _logged_ignored_stop_ids: + continue + _logged_ignored_stop_ids.add(ev.id) + logging.warning(f"ignoring note from stop author: {ev.content!r}") + command, explanation = explain_control( combined, now_unix, control_config.stop_authors, control_config.start_authors, ) - _note_control_command(command) + _note_control_explanation(explanation) return command == "start" diff --git a/tests/test_control_channel.py b/tests/test_control_channel.py index 97e4b10..ca988f3 100644 --- a/tests/test_control_channel.py +++ b/tests/test_control_channel.py @@ -14,6 +14,7 @@ ControlConfig, ControlEvent, decide_control, + explain_control, fetch_control_events, future_stop_author_notes, load_control_config, @@ -233,6 +234,28 @@ def test_other_note_text_ignored(self): ] assert decide_control(events, NOW, {STOP_AUTHOR}, {START_AUTHOR}) == "stop" + def test_explain_names_the_stop_or_the_resume(self): + stop = _ev(id="s1", author=STOP_AUTHOR, content="stop a", created_at=NOW - 30) + start = _ev(id="t1", author=START_AUTHOR, content="start a", created_at=NOW - 10) + extra = _ev(id="t2", author=START_AUTHOR, content="start b", created_at=NOW - 5) + authors = ({STOP_AUTHOR}, {START_AUTHOR}) + + assert explain_control([], NOW, *authors) == ("stop", "NOSTR STOP: no stop notes") + assert explain_control([stop], NOW, *authors) == ( + "stop", + "NOSTR STOP: stopping because stop a", + ) + assert explain_control([stop, start], NOW, *authors) == ( + "start", + "NOSTR START: resuming because start a", + ) + assert explain_control([stop, start, extra], NOW, *authors) == ( + "stop", + "NOSTR STOP: stop ids a are not matched by start ids a, b", + ) + assert decide_control([stop, start], NOW, *authors) == "start" + assert decide_control([stop, start, extra], NOW, *authors) == "stop" + def _valid_pubkeys() -> tuple[str, str]: from nostr_sdk import Keys @@ -331,6 +354,56 @@ async def fake_fetch(relay, filt, timeout): relay_quorum=5, ) + @pytest.mark.asyncio + async def test_repeated_quorum_failure_logs_once(self, monkeypatch, caplog): + import commands.control_channel as control_channel + + monkeypatch.setattr(control_channel, "_last_quorum_failures", None) + stop_author, start_author = _valid_pubkeys() + + async def fake_fetch(relay, filt, timeout): + if relay.endswith("bad"): + raise RuntimeError("timeout") + return [] + + monkeypatch.setattr( + "commands.control_channel._fetch_events_from_relay", fake_fetch + ) + relays = [f"wss://relay-{i}" for i in "abcde"] + ["wss://relay-bad"] + with caplog.at_level(logging.WARNING): + await fetch_control_events( + relays, {stop_author}, {start_author}, relay_quorum=5 + ) + await fetch_control_events( + relays, {stop_author}, {start_author}, relay_quorum=5 + ) + warnings = [r.getMessage() for r in caplog.records if "met quorum" in r.getMessage()] + assert warnings == [ + "control relay fetch met quorum (5/6 relays); ignoring failures: " + "wss://relay-bad: timeout" + ] + + caplog.clear() + + async def fail_other(relay, filt, timeout): + if relay.endswith("a"): + raise RuntimeError("timeout") + return [] + + monkeypatch.setattr( + "commands.control_channel._fetch_events_from_relay", fail_other + ) + with caplog.at_level(logging.WARNING): + await fetch_control_events( + [f"wss://relay-{i}" for i in "abcde"], + {stop_author}, + {start_author}, + relay_quorum=4, + ) + warnings = [r.getMessage() for r in caplog.records if "met quorum" in r.getMessage()] + assert len(warnings) == 1 + assert "wss://relay-a: timeout" in warnings[0] + class _Hex: def __init__(self, value): @@ -1295,15 +1368,23 @@ def stops_file(tmp_path, monkeypatch): monkeypatch.setattr(policy_runtime, "STOPS_FILE", path) saved_stops = dict(policy_runtime._seen_stops) saved_warned = policy_runtime._missing_stops_warned - saved_command = policy_runtime._last_control_command + saved_explanation = policy_runtime._last_control_explanation + saved_future = set(policy_runtime._logged_future_stop_ids) + saved_ignored = set(policy_runtime._logged_ignored_stop_ids) policy_runtime._seen_stops.clear() policy_runtime._missing_stops_warned = False - policy_runtime._last_control_command = None + policy_runtime._last_control_explanation = None + policy_runtime._logged_future_stop_ids.clear() + policy_runtime._logged_ignored_stop_ids.clear() yield path policy_runtime._seen_stops.clear() policy_runtime._seen_stops.update(saved_stops) policy_runtime._missing_stops_warned = saved_warned - policy_runtime._last_control_command = saved_command + policy_runtime._last_control_explanation = saved_explanation + policy_runtime._logged_future_stop_ids.clear() + policy_runtime._logged_future_stop_ids.update(saved_future) + policy_runtime._logged_ignored_stop_ids.clear() + policy_runtime._logged_ignored_stop_ids.update(saved_ignored) def _install_fetch(monkeypatch, responses): @@ -1482,3 +1563,93 @@ def fail_write(path, stops): monkeypatch.setattr(policy_runtime, "_write_stops_file", original_write) assert await control_allows_start(cfg) is False assert "new-stop" in json.loads(stops_file.read_text()) + + +def _warnings(caplog, text: str) -> list[str]: + return [record.getMessage() for record in caplog.records if text in record.getMessage()] + + +@pytest.mark.asyncio +async def test_stop_reason_logs_once_until_the_ids_change(monkeypatch, caplog, stops_file): + from commands.policy_runtime import control_allows_start + + stop_a = _ev(id="stop-a", author=STOP_AUTHOR, content="stop a", created_at=NOW - 10) + stop_b = _ev(id="stop-b", author=STOP_AUTHOR, content="stop b", created_at=NOW - 5) + _install_fetch(monkeypatch, [[stop_a], [stop_a], [stop_a, stop_b]]) + stops_file.write_text("") + with caplog.at_level(logging.WARNING): + assert await control_allows_start(_control_config()) is False + assert await control_allows_start(_control_config()) is False + assert await control_allows_start(_control_config()) is False + assert _warnings(caplog, "stopping because") == [ + "NOSTR STOP: stopping because stop a", + "NOSTR STOP: stopping because stop a, stop b", + ] + + +@pytest.mark.asyncio +async def test_no_stop_notes_logs_once(monkeypatch, caplog, stops_file): + from commands.policy_runtime import control_allows_start + + _install_fetch(monkeypatch, [[], []]) + stops_file.write_text("") + with caplog.at_level(logging.WARNING): + assert await control_allows_start(_control_config()) is False + assert await control_allows_start(_control_config()) is False + assert _warnings(caplog, "no stop notes") == ["NOSTR STOP: no stop notes"] + + +@pytest.mark.asyncio +async def test_ignored_stop_author_note_logs_once(monkeypatch, caplog, stops_file): + from commands.policy_runtime import control_allows_start + + halt = _ev(id="halt", author=STOP_AUTHOR, content="halt", created_at=NOW - 10) + other = _ev(id="go", author=STOP_AUTHOR, content="go", created_at=NOW - 5) + _install_fetch(monkeypatch, [[halt], [halt], [halt, other]]) + stops_file.write_text("") + with caplog.at_level(logging.WARNING): + assert await control_allows_start(_control_config()) is False + assert await control_allows_start(_control_config()) is False + assert await control_allows_start(_control_config()) is False + assert _warnings(caplog, "ignoring note from stop author") == [ + "ignoring note from stop author: 'halt'", + "ignoring note from stop author: 'go'", + ] + + +@pytest.mark.asyncio +async def test_future_stop_warning_logs_once(monkeypatch, caplog, stops_file): + from commands.policy_runtime import control_allows_start + + created = NOW + CLOCK_SKEW_SECONDS + 5 + events = [ + _ev(id="futurestop", author=STOP_AUTHOR, content="stop a", created_at=created), + ] + _install_fetch(monkeypatch, [events, events]) + stops_file.write_text("") + with caplog.at_level(logging.WARNING): + assert await control_allows_start(_control_config()) is False + assert await control_allows_start(_control_config()) is False + assert caplog.text.count("clock catches up") == 1 + + +@pytest.mark.asyncio +async def test_open_gate_does_not_log_a_resume_until_a_stop(monkeypatch, caplog, stops_file): + from commands.policy_runtime import control_allows_start + + stop = _ev(id="stop-a", author=STOP_AUTHOR, content="stop a", created_at=NOW - 30) + start = _ev(id="start-a", author=START_AUTHOR, content="start a", created_at=NOW - 10) + extra = _ev(id="stop-b", author=STOP_AUTHOR, content="stop b", created_at=NOW - 5) + resumed = _ev(id="start-b", author=START_AUTHOR, content="start b", created_at=NOW - 1) + _install_fetch(monkeypatch, [[stop, start], [stop, start, extra], [stop, start, extra, resumed]]) + stops_file.write_text("") + with caplog.at_level(logging.WARNING): + assert await control_allows_start(_control_config()) is True + assert await control_allows_start(_control_config()) is False + assert await control_allows_start(_control_config()) is True + assert _warnings(caplog, "NOSTR START") == [ + "NOSTR START: resuming because start a, start b" + ] + assert _warnings(caplog, "NOSTR STOP") == [ + "NOSTR STOP: stopping because stop b" + ] From d0058ce641d6b1fb90218266d94cb7722ac69b58 Mon Sep 17 00:00:00 2001 From: Yak Date: Sun, 11 Oct 2026 14:15:05 +0100 Subject: [PATCH 17/19] coinbase rpc retries --- commands/policy_runtime.py | 32 +++++++++++++--- tests/test_spend_policy.py | 76 +++++++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/commands/policy_runtime.py b/commands/policy_runtime.py index 454fbec..828516d 100644 --- a/commands/policy_runtime.py +++ b/commands/policy_runtime.py @@ -731,7 +731,17 @@ def _keccak_topic(signature: str) -> str: _TRANSFER_TOPIC = _keccak_topic("Transfer(address,address,uint256)") -_missing_tracer_methods: set[str] = set() +# (endpoint, method) pairs this process has learned the RPC does not offer. +_missing_tracer_methods: set[tuple[str, str]] = set() + + +def _tracer_endpoint(web3: AsyncWeb3) -> str: + """Identity of the RPC, so one chain's 405 does not disable another's tracer.""" + provider = web3.provider + endpoint = getattr(provider, "endpoint_uri", None) + if isinstance(endpoint, str) and endpoint: + return endpoint + return str(id(provider)) def _rpc_error_code(exc: BaseException) -> Optional[int]: @@ -745,10 +755,18 @@ def _rpc_error_code(exc: BaseException) -> Optional[int]: return None -def _log_tracer_failure(method: str, exc: BaseException) -> None: - if _rpc_error_code(exc) == -32601: - if method not in _missing_tracer_methods: - _missing_tracer_methods.add(method) +def _tracer_unsupported(exc: BaseException) -> bool: + """JSON-RPC method-not-found, or an HTTP 405 from an endpoint that blocks it.""" + if getattr(exc, "status", None) == 405: + return True + return _rpc_error_code(exc) == -32601 + + +def _log_tracer_failure(web3: AsyncWeb3, method: str, exc: BaseException) -> None: + if _tracer_unsupported(exc): + key = (_tracer_endpoint(web3), method) + if key not in _missing_tracer_methods: + _missing_tracer_methods.add(key) logging.info("%s is not available on this RPC", method) return logging.info("%s failed", method, exc_info=True) @@ -963,10 +981,12 @@ async def _diff_from_trace_method( The diff inside the tuple may be None. That still means a tracer answered, so the log fallback must not replace it. """ + if (_tracer_endpoint(web3), method) in _missing_tracer_methods: + return None try: result = await _rpc(web3, method, params) except Exception as exc: - _log_tracer_failure(method, exc) + _log_tracer_failure(web3, method, exc) return None state_diff = _normalize_trace_state_diff(result) if state_diff is None: diff --git a/tests/test_spend_policy.py b/tests/test_spend_policy.py index 63a7cc5..4c3e138 100644 --- a/tests/test_spend_policy.py +++ b/tests/test_spend_policy.py @@ -1602,9 +1602,75 @@ async def test_missing_tracers_fall_through_and_log_once(self, caplog): "trace_replayTransaction is not available on this RPC", ] assert all(record.exc_info is None for record in missing) + assert web3.provider.methods == [ + "debug_traceTransaction", + "trace_replayTransaction", + ] + + @pytest.mark.asyncio + async def test_http_405_is_remembered_per_endpoint(self, caplog): + from aiohttp import ClientResponseError + from commands import policy_runtime + + policy_runtime._missing_tracer_methods.clear() + refused = ClientResponseError( + type("Req", (), {"real_url": "https://rpc.example/base"})(), + (), + status=405, + message="Method Not Allowed", + ) + logs = [self._transfer(self.TX_A, self.OTHER, self.HOLDER, 50)] + slots = {**self._slot(self.BLOCK, 50), **self._slot(self.BLOCK - 1, 0)} + base = self._web3( + 50, + slots, + logs, + { + "debug_traceTransaction": refused, + "trace_replayTransaction": refused, + }, + ) + base.provider.endpoint_uri = "https://rpc.example/base" + eth = self._web3( + 50, + slots, + logs, + { + "debug_traceTransaction": refused, + "trace_replayTransaction": refused, + }, + ) + eth.provider.endpoint_uri = "https://rpc.example/eth" + with caplog.at_level(logging.INFO): + assert await self._fetch(base, self.TX_A) == 50 + assert await self._fetch(base, self.TX_A) == 50 + assert await self._fetch(eth, self.TX_A) == 50 + missing = [ + record + for record in caplog.records + if record.message.endswith("is not available on this RPC") + ] + assert [record.message for record in missing] == [ + "debug_traceTransaction is not available on this RPC", + "trace_replayTransaction is not available on this RPC", + "debug_traceTransaction is not available on this RPC", + "trace_replayTransaction is not available on this RPC", + ] + assert all(record.exc_info is None for record in missing) + assert base.provider.methods == [ + "debug_traceTransaction", + "trace_replayTransaction", + ] + assert eth.provider.methods == [ + "debug_traceTransaction", + "trace_replayTransaction", + ] @pytest.mark.asyncio async def test_other_tracer_errors_keep_a_traceback(self, caplog): + from commands import policy_runtime + + policy_runtime._missing_tracer_methods.clear() logs = [self._transfer(self.TX_A, self.OTHER, self.HOLDER, 50)] slots = {**self._slot(self.BLOCK, 50), **self._slot(self.BLOCK - 1, 0)} web3 = self._web3( @@ -1615,13 +1681,19 @@ async def test_other_tracer_errors_keep_a_traceback(self, caplog): ) with caplog.at_level(logging.INFO): assert await self._fetch(web3, self.TX_A) == 50 + assert await self._fetch(web3, self.TX_A) == 50 failed = [ record for record in caplog.records if record.message == "debug_traceTransaction failed" ] - assert len(failed) == 1 - assert failed[0].exc_info is not None + assert len(failed) == 2 + assert all(record.exc_info is not None for record in failed) + assert web3.provider.methods == [ + "debug_traceTransaction", + "trace_replayTransaction", + "debug_traceTransaction", + ] @pytest.mark.asyncio async def test_trace_result_skips_logs(self): From 08e53e92626c531d728390d51a044b570a8cb052 Mon Sep 17 00:00:00 2001 From: Yak Date: Sun, 11 Oct 2026 15:14:31 +0100 Subject: [PATCH 18/19] fix some Base signing issues --- commands/policy_runtime.py | 218 ++++++++++++++++++++-- tests/test_spend_policy.py | 357 ++++++++++++++++++++++++++++++++++++- 2 files changed, 552 insertions(+), 23 deletions(-) diff --git a/commands/policy_runtime.py b/commands/policy_runtime.py index 828516d..bd4846e 100644 --- a/commands/policy_runtime.py +++ b/commands/policy_runtime.py @@ -537,6 +537,17 @@ def _storage_key(holder: bytes, slot: int) -> str: return "0x" + binascii.hexlify(bytes(digest)).decode("ascii") +# OpenZeppelin v5 stores balances at the ERC-7201 base of +# openzeppelin.storage.ERC20, not in slots 0-63. +_OZ_ERC20_BALANCES_SLOT = int( + "52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00", 16 +) + + +def _balance_mapping_slots() -> list[int]: + return [*range(64), _OZ_ERC20_BALANCES_SLOT] + + def _parse_uint(value) -> Optional[int]: if value is None: return None @@ -621,7 +632,7 @@ async def _find_balance_mapping_slot( return None matching: List[int] = [] - for slot in range(0, 64): + for slot in _balance_mapping_slots(): key = _storage_key(holder, slot) try: raw = await web3.eth.get_storage_at( @@ -858,6 +869,75 @@ def _holder_log_nets( return nets +async def _unwrap_burn_sender( + web3: AsyncWeb3, + tx_hash: str, + token: bytes, + burn_value: int, + block_number: int, +) -> Optional[bytes]: + """Account that burned exactly burn_value of token in this transaction. + + The burn is the wrapped-token Transfer to address(0). Zero matches, or + more than one, returns None. + """ + try: + token_addr = evm_address_from_bytes32(left_pad_32(token)) + except ValueError: + return None + tx_key = _norm_tx_hash(tx_hash) + if tx_key is None: + return None + try: + logs = await web3.eth.get_logs( + { + "fromBlock": block_number, + "toBlock": block_number, + "address": _checksum(token), + "topics": [_TRANSFER_TOPIC], + } + ) + log_list = list(logs) + except Exception: + logging.info("eth_getLogs failed during unwrap sender lookup", exc_info=True) + return None + + transfer_topic = bytes.fromhex(_TRANSFER_TOPIC[2:]) + zero = b"\x00" * 20 + senders: list[bytes] = [] + for log in log_list: + address = _topic_address(_log_field(log, "address")) + if address is None: + raw_address = _as_hex_bytes(_log_field(log, "address")) + if raw_address is None or len(raw_address) != 20: + continue + address = raw_address + if address != token_addr: + continue + if _norm_tx_hash(_log_field(log, "transactionHash")) != tx_key: + continue + topics = _log_field(log, "topics") or [] + try: + topic_list = list(topics) + except TypeError: + continue + if len(topic_list) != 3: + continue + if _as_hex_bytes(topic_list[0]) != transfer_topic: + continue + data = _as_hex_bytes(_log_field(log, "data")) + sender = _topic_address(topic_list[1]) + receiver = _topic_address(topic_list[2]) + if data is None or len(data) != 32 or sender is None or receiver is None: + continue + if receiver != zero or int.from_bytes(data, "big") != burn_value: + continue + senders.append(sender) + if len(senders) != 1: + return None + return senders[0] + + async def _read_slot_uint( web3: AsyncWeb3, token_cs: str, holder: bytes, slot: int, block_number: int ) -> Optional[int]: @@ -895,7 +975,7 @@ async def _balance_storage_delta( return None matching: List[int] = [] - for slot in range(0, 64): + for slot in _balance_mapping_slots(): stored = await _read_slot_uint(web3, token_cs, holder, slot, block_number) if stored is None: return None @@ -1123,12 +1203,85 @@ async def read_wrapped_cat_views( return None, None, None, None, None, None, None -def _decode_uint256(data: bytes, position: int) -> int: - return int.from_bytes(data[position:position + 32], "big") +def _failed_tx(tx_status: Optional[int]) -> Optional[PolicyResult]: + """Final reject once a receipt says the transaction did not succeed. + + A missing status stays a retry. These checks run before calldata matching + so a reverted bundle is not retried as a missing burn or amount. + """ + if tx_status is None or tx_status == 1: + return None + return PolicyResult("reject", "tx status not success") + + +def _as_word(value: bytes) -> Optional[bytes]: + try: + return left_pad_32(value) + except ValueError: + return None + + +def _embedded_calls(raw: bytes, selector: bytes, word_count: int) -> list[list[bytes]]: + """ABI words after every occurrence of selector, including nested calls.""" + hits: list[list[bytes]] = [] + start = 0 + need = 4 + 32 * word_count + while True: + found = raw.find(selector, start) + if found < 0: + break + start = found + 1 + if len(raw) < found + need: + continue + base = found + 4 + hits.append([raw[base + 32 * n:base + 32 * (n + 1)] for n in range(word_count)]) + return hits + + +def _unique_uint(values: list[int]) -> tuple[Optional[int], bool]: + found = set(values) + if len(found) == 1: + return next(iter(found)), False + if len(found) > 1: + return None, True + return None, False + + +def _bridge_back_gross(raw: bytes, receiver: bytes) -> tuple[Optional[int], bool]: + """Gross mojos from bridgeBack calls whose receiver word matches.""" + receiver_word = _as_word(receiver) + if receiver_word is None: + return None, False + amounts = [ + int.from_bytes(words[1], "big") + for words in _embedded_calls(raw, BRIDGE_BACK_SEL, 2) + if words[0] == receiver_word + ] + return _unique_uint(amounts) -def _decode_address(data: bytes, position: int) -> bytes: - return data[position + 12:position + 32] +def _token_bridge_gross( + raw: bytes, contents: Sequence[bytes] +) -> tuple[Optional[int], Optional[bytes], bool]: + """Gross mojos and asset from bridgeToChia or permit calls. + + The bool is true when more than one amount matches this message. + """ + if len(contents) < 2: + return None, None, False + asset_word = _as_word(contents[0]) + receiver_word = _as_word(contents[1]) + if asset_word is None or receiver_word is None: + return None, None, False + amounts: list[int] = [] + for selector in (BRIDGE_TO_CHIA_SEL, BRIDGE_PERMIT_SEL): + for words in _embedded_calls(raw, selector, 3): + if words[0] == asset_word and words[1] == receiver_word: + amounts.append(int.from_bytes(words[2], "big")) + amount, ambiguous = _unique_uint(amounts) + if ambiguous or amount is None: + return None, None, ambiguous + return amount, asset_word[-20:], False def _burner_tail_hash_hash_mismatch(burner_spend: CoinSpend, routes: BridgeRoutes) -> bool: @@ -1382,6 +1535,10 @@ async def _evaluate_evm_erc20_wrap( except Exception: return PolicyResult("retry", "could not read bridge/portal views") + failed = _failed_tx(tx_status) + if failed is not None: + return failed + raw_input = bytes(tx.input) selector = raw_input[:4] if len(raw_input) >= 4 else b"" msg_value = int(tx.value) @@ -1391,13 +1548,7 @@ async def _evaluate_evm_erc20_wrap( asset_for_decimals: Optional[bytes] = None try: - if selector == BRIDGE_TO_CHIA_SEL: - asset_for_decimals = _decode_address(raw_input, 4) - gross_mojo_amount = _decode_uint256(raw_input, 4 + 32 + 32) - elif selector == BRIDGE_PERMIT_SEL: - asset_for_decimals = _decode_address(raw_input, 4) - gross_mojo_amount = _decode_uint256(raw_input, 4 + 32 + 32) - elif selector == BRIDGE_ETHER_SEL: + if is_bridge_ether: asset_for_decimals = evm_address_from_bytes32(left_pad_32(route.millieth_address)) if msg_value < message_toll: return PolicyResult("reject", "msg.value < message toll") @@ -1411,9 +1562,14 @@ async def _evaluate_evm_erc20_wrap( return PolicyResult("reject", "amountAfterToll not divisible by wethToEthRatio") gross_mojo_amount = amount_after_toll // weth_to_eth_ratio // factor else: - if len(contents) == 3: + amount, asset, ambiguous = _token_bridge_gross(raw_input, contents) + if ambiguous: + return PolicyResult("retry", "ambiguous bridge amount") + if amount is not None: + gross_mojo_amount = amount + asset_for_decimals = asset + elif len(contents) == 3: asset_for_decimals = evm_address_from_bytes32(left_pad_32(contents[0])) - gross_mojo_amount = None except Exception: return PolicyResult("retry", "could not decode bridge calldata") @@ -1489,11 +1645,18 @@ async def _evaluate_evm_cat_unwrap( if len(code) == 0 or _bytecode_is_not_wrapped_cat(code): return PolicyResult("reject", "WrappedCAT bytecode mismatch after immutable mask") + failed = _failed_tx(tx_status) + if failed is not None: + return failed + raw_input = bytes(tx.input) - gross_mojo_amount: Optional[int] = None - selector = raw_input[:4] if len(raw_input) >= 4 else b"" - if selector == BRIDGE_BACK_SEL and len(raw_input) >= 4 + 64: - gross_mojo_amount = _decode_uint256(raw_input, 4 + 32) + outer_bridge_back = raw_input[:4] == BRIDGE_BACK_SEL + receiver = contents[0] if contents else b"" + gross_mojo_amount, ambiguous_back = _bridge_back_gross(raw_input, receiver) + if ambiguous_back: + return PolicyResult("retry", "ambiguous bridgeBack amount") + if outer_bridge_back and gross_mojo_amount is None and len(raw_input) >= 4 + 64: + return PolicyResult("reject", "bridgeBack receiver mismatch") tx_from = tx["from"] if isinstance(tx_from, str): @@ -1504,6 +1667,23 @@ async def _evaluate_evm_cat_unwrap( if route is None: return PolicyResult("reject", "unknown source chain") + if ( + verify_balance_diff + and not outer_bridge_back + and gross_mojo_amount is not None + and mojo_to_token_ratio is not None + ): + burner = await _unwrap_burn_sender( + web3, + tx_hash, + source, + gross_mojo_amount * int(mojo_to_token_ratio), + block_number, + ) + if burner is None: + return PolicyResult("retry", "missing unwrap sender") + sender = burner + sender_balance_diff = None portal_balance_diff = None if verify_balance_diff: diff --git a/tests/test_spend_policy.py b/tests/test_spend_policy.py index 4c3e138..784145e 100644 --- a/tests/test_spend_policy.py +++ b/tests/test_spend_policy.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from contextlib import contextmanager from typing import List, Optional, Tuple from unittest.mock import patch @@ -1077,7 +1078,7 @@ async def test_wrap_evaluate_skips_fetch_and_default_still_fetches(self): calldata = ( bytes.fromhex("cdb50da7") + left_pad_32(asset) - + b"\x00" * 32 + + left_pad_32(RECEIVER) + gross.to_bytes(32, "big") ) web3 = _ViewWeb3( @@ -1121,7 +1122,11 @@ async def test_unwrap_evaluate_skips_fetch_and_default_still_fetches(self): unlocker = b"\x24" * 32 gross = 10_000 net = gross - compute_bridge_tip(gross, WRAP_TIP_BPS) - calldata = bytes.fromhex("415d9361") + b"\x00" * 32 + gross.to_bytes(32, "big") + calldata = ( + bytes.fromhex("415d9361") + + left_pad_32(RECEIVER) + + gross.to_bytes(32, "big") + ) contents = [left_pad_32(RECEIVER), net.to_bytes(32, "big")] calls = [] @@ -1171,6 +1176,338 @@ async def views(*_args, **_kwargs): assert default.kind == "retry" assert default.reason == "missing balance diffs" + @pytest.mark.asyncio + async def test_bundled_unwrap_uses_inner_bridge_back_and_burn_sender(self): + from commands.policy_runtime import _TRANSFER_TOPIC, _evaluate_evm_cat_unwrap + + unlocker = b"\x24" * 32 + smart = bytes.fromhex("ab" * 20) + gross = 1_000_000_000 + tip = compute_bridge_tip(gross, WRAP_TIP_BPS) + net = gross - tip + prefix = bytes.fromhex("1fad948c") + b"\x11" * 80 + calldata = ( + prefix + + bytes.fromhex("415d9361") + + left_pad_32(RECEIVER) + + gross.to_bytes(32, "big") + ) + burn = gross * WRAP_MOJO_TO_TOKEN_RATIO + tx_hash = "0x" + "11" * 32 + web3 = _LogsWeb3( + [ + { + "address": "0x" + WRAPPED_CAT_ADDR.hex(), + "topics": [ + _TRANSFER_TOPIC, + _padded_topic(smart), + _padded_topic(b"\x00" * 20), + ], + "data": _uint_data(burn), + "transactionHash": tx_hash, + } + ] + ) + holders = [] + + async def fetch(_web3, _tx_hash, _token, holder, _block): + holders.append(bytes(holder)[-20:]) + if bytes(holder)[-20:] == smart: + return -burn + return tip * WRAP_MOJO_TO_TOKEN_RATIO + + with _unwrap_patches(fetch, unlocker): + result = await _evaluate_evm_cat_unwrap( + **_unwrap_call( + web3, + calldata, + [left_pad_32(RECEIVER), net.to_bytes(32, "big")], + unlocker, + tx_hash, + "0x" + "44" * 20, + ) + ) + assert web3.eth.calls == 1 + assert holders[0] == smart + assert result.kind == "accept" + + @pytest.mark.asyncio + async def test_ambiguous_embedded_bridge_back_retries(self): + from commands.policy_runtime import _evaluate_evm_cat_unwrap + + unlocker = b"\x24" * 32 + receiver = left_pad_32(RECEIVER) + first = bytes.fromhex("415d9361") + receiver + (1_000_000_000).to_bytes(32, "big") + second = bytes.fromhex("415d9361") + receiver + (2_000_000_000).to_bytes(32, "big") + calldata = bytes.fromhex("1fad948c") + first + second + calls = [] + + async def fetch(*_args, **_kwargs): + calls.append(1) + return None + + with _unwrap_patches(fetch, unlocker): + result = await _evaluate_evm_cat_unwrap( + **_unwrap_call( + _LogsWeb3([]), + calldata, + [receiver, (997_000_000).to_bytes(32, "big")], + unlocker, + "0x" + "11" * 32, + "0x" + "44" * 20, + ) + ) + assert calls == [] + assert result.kind == "retry" + assert result.reason == "ambiguous bridgeBack amount" + + @pytest.mark.asyncio + async def test_direct_bridge_back_uses_tx_from_without_burn_log(self): + from commands.policy_runtime import _evaluate_evm_cat_unwrap + + unlocker = b"\x24" * 32 + gross = 10_000 + tip = compute_bridge_tip(gross, WRAP_TIP_BPS) + net = gross - tip + sender = bytes.fromhex("44" * 20) + calldata = ( + bytes.fromhex("415d9361") + + left_pad_32(RECEIVER) + + gross.to_bytes(32, "big") + ) + web3 = _LogsWeb3(AssertionError("burn log")) + holders = [] + + async def fetch(_web3, _tx_hash, _token, holder, _block): + holders.append(bytes(holder)[-20:]) + if len(holders) == 1: + return -(gross * WRAP_MOJO_TO_TOKEN_RATIO) + return tip * WRAP_MOJO_TO_TOKEN_RATIO + + with _unwrap_patches(fetch, unlocker): + result = await _evaluate_evm_cat_unwrap( + **_unwrap_call( + web3, + calldata, + [left_pad_32(RECEIVER), net.to_bytes(32, "big")], + unlocker, + "0x" + "11" * 32, + "0x" + sender.hex(), + ) + ) + assert web3.eth.calls == 0 + assert holders[0] == sender + assert result.kind == "accept" + + @pytest.mark.asyncio + async def test_failed_bundled_unwrap_rejects_without_burn_lookup(self): + from commands.policy_runtime import _evaluate_evm_cat_unwrap + + unlocker = b"\x24" * 32 + gross = 1_000_000_000 + net = gross - compute_bridge_tip(gross, WRAP_TIP_BPS) + calldata = ( + bytes.fromhex("1fad948c") + + bytes.fromhex("415d9361") + + left_pad_32(RECEIVER) + + gross.to_bytes(32, "big") + ) + web3 = _LogsWeb3(AssertionError("burn log")) + calls = [] + + async def fetch(*_args, **_kwargs): + calls.append(1) + return None + + kwargs = _unwrap_call( + web3, + calldata, + [left_pad_32(RECEIVER), net.to_bytes(32, "big")], + unlocker, + "0x" + "11" * 32, + "0x" + "44" * 20, + ) + kwargs["tx_status"] = 0 + with _unwrap_patches(fetch, unlocker): + result = await _evaluate_evm_cat_unwrap(**kwargs) + assert web3.eth.calls == 0 + assert calls == [] + assert result.kind == "reject" + assert result.reason == "tx status not success" + + @pytest.mark.asyncio + async def test_direct_bridge_back_receiver_mismatch_rejects(self): + from commands.policy_runtime import _evaluate_evm_cat_unwrap + + unlocker = b"\x24" * 32 + gross = 10_000 + net = gross - compute_bridge_tip(gross, WRAP_TIP_BPS) + other = bytes.fromhex("cc" * 20) + calldata = ( + bytes.fromhex("415d9361") + + left_pad_32(other) + + gross.to_bytes(32, "big") + ) + calls = [] + + async def fetch(*_args, **_kwargs): + calls.append(1) + return None + + with _unwrap_patches(fetch, unlocker): + result = await _evaluate_evm_cat_unwrap( + **_unwrap_call( + _LogsWeb3([]), + calldata, + [left_pad_32(RECEIVER), net.to_bytes(32, "big")], + unlocker, + "0x" + "11" * 32, + "0x" + "44" * 20, + ) + ) + assert calls == [] + assert result.kind == "reject" + assert result.reason == "bridgeBack receiver mismatch" + + @pytest.mark.asyncio + async def test_embedded_bridge_to_chia_decodes_gross(self): + from commands.policy_runtime import _evaluate_evm_erc20_wrap + + gross = 10_000 + net = gross - compute_bridge_tip(gross, 30) + asset = bytes.fromhex("aa" * 20) + prefix = bytes.fromhex("1fad948c") + b"\x22" * 40 + calldata = ( + prefix + + bytes.fromhex("cdb50da7") + + left_pad_32(asset) + + left_pad_32(RECEIVER) + + gross.to_bytes(32, "big") + ) + web3 = _ViewWeb3( + {"tip": 30, "wethToEthRatio": 1, "messageToll": 0, "decimals": 18} + ) + contents = [left_pad_32(asset), left_pad_32(RECEIVER), net.to_bytes(32, "big")] + calls = [] + + async def fetch(*_args, **_kwargs): + calls.append(1) + return None + + with patch("commands.policy_runtime.fetch_token_balance_diff", new=fetch): + result = await _evaluate_evm_erc20_wrap( + web3=web3, + routes=_routes(), + route=_routes().for_chain(b"eth"), + source_chain=b"eth", + source=left_pad_32(ETH_BRIDGE), + destination=get_cat_minter_puzzle(PORTAL, b"eth", ETH_BRIDGE).get_tree_hash(), + destination_chain=b"xch", + contents=contents, + block_number=1, + tx=_CalldataTx(calldata), + tx_hash="0x" + "11" * 32, + tx_status=1, + verify_balance_diff=False, + ) + assert calls == [] + assert result.kind == "accept" + + @pytest.mark.asyncio + async def test_failed_wrap_rejects_before_balance_lookup(self): + from commands.policy_runtime import _evaluate_evm_erc20_wrap + + asset = bytes.fromhex("aa" * 20) + web3 = _ViewWeb3( + {"tip": 30, "wethToEthRatio": 1, "messageToll": 0, "decimals": 18} + ) + calls = [] + + async def fetch(*_args, **_kwargs): + calls.append(1) + return None + + with patch("commands.policy_runtime.fetch_token_balance_diff", new=fetch): + result = await _evaluate_evm_erc20_wrap( + web3=web3, + routes=_routes(), + route=_routes().for_chain(b"eth"), + source_chain=b"eth", + source=left_pad_32(ETH_BRIDGE), + destination=get_cat_minter_puzzle(PORTAL, b"eth", ETH_BRIDGE).get_tree_hash(), + destination_chain=b"xch", + contents=[ + left_pad_32(asset), + left_pad_32(RECEIVER), + (1).to_bytes(32, "big"), + ], + block_number=1, + tx=_CalldataTx(b""), + tx_hash="0x" + "11" * 32, + tx_status=0, + ) + assert calls == [] + assert result.kind == "reject" + assert result.reason == "tx status not success" + + +def _unwrap_call(web3, calldata, contents, unlocker, tx_hash, sender): + return dict( + web3=web3, + routes=_routes(), + source_chain=b"eth", + source=left_pad_32(WRAPPED_CAT_ADDR), + destination=unlocker, + destination_chain=b"xch", + contents=contents, + block_number=1, + tx=_CalldataTx(calldata, sender=sender), + tx_hash=tx_hash, + tx_status=1, + ) + + +@contextmanager +def _unwrap_patches(fetch, unlocker): + async def views(*_args, **_kwargs): + return ( + b"\x60\x00", + left_pad_32(ETH_PORTAL), + b"xch", + b"\x00" * 32, + unlocker, + WRAP_TIP_BPS, + WRAP_MOJO_TO_TOKEN_RATIO, + ) + + with patch("commands.policy_runtime.fetch_token_balance_diff", new=fetch), patch( + "commands.policy_runtime.read_wrapped_cat_views", new=views + ), patch( + "commands.policy_runtime.match_wrapped_cat_bytecode", return_value=True + ), patch( + "commands.spend_policy.match_wrapped_cat_bytecode", return_value=True + ), patch( + "commands.spend_policy.wrapped_cat_immutable_values_match", return_value=True + ): + yield + + +class _LogsEth: + def __init__(self, logs): + self.logs = logs + self.calls = 0 + + async def get_logs(self, _filt): + self.calls += 1 + if isinstance(self.logs, Exception): + raise self.logs + return self.logs + + +class _LogsWeb3: + def __init__(self, logs): + self.eth = _LogsEth(logs) + # --------------------------------------------------------------------------- # 4. evm_address_from_bytes32 @@ -1526,10 +1863,10 @@ def _transfer(self, tx_hash: str, sender: bytes, receiver: bytes, value: int): "transactionHash": "0x" + tx_hash, } - def _slot(self, block: int, value: int): + def _slot(self, block: int, value: int, slot: int = 0): from commands.policy_runtime import _storage_key - return {(block, _storage_key(self.HOLDER, 0).lower()): value} + return {(block, _storage_key(self.HOLDER, slot).lower()): value} def _web3(self, balance, slots, logs, responses=None): provider = _TraceProvider(responses or {}) @@ -1708,6 +2045,18 @@ async def test_trace_result_skips_logs(self): assert web3.eth.log_calls == 0 assert "trace_replayTransaction" not in web3.provider.methods + @pytest.mark.asyncio + async def test_openzeppelin_v5_balance_slot_matches_logs(self): + from commands.policy_runtime import _OZ_ERC20_BALANCES_SLOT + + logs = [self._transfer(self.TX_A, self.OTHER, self.HOLDER, 1_000_000_000_000_000)] + slots = { + **self._slot(self.BLOCK, 20_040_000_000_000_000_000, _OZ_ERC20_BALANCES_SLOT), + **self._slot(self.BLOCK - 1, 20_039_000_000_000_000_000, _OZ_ERC20_BALANCES_SLOT), + } + web3 = self._web3(20_040_000_000_000_000_000, slots, logs) + assert await self._fetch(web3, self.TX_A) == 1_000_000_000_000_000 + @pytest.mark.asyncio async def test_trace_without_token_does_not_use_logs(self): logs = [self._transfer(self.TX_A, self.OTHER, self.HOLDER, 50)] From 4a12876338d3d3eadb2e8dfea12cf4400e812f63 Mon Sep 17 00:00:00 2001 From: Yak Date: Sun, 11 Oct 2026 15:23:10 +0100 Subject: [PATCH 19/19] async relay checks --- commands/control_channel.py | 31 +++++++++++++++----- tests/test_control_channel.py | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/commands/control_channel.py b/commands/control_channel.py index 68caaf0..faac85a 100644 --- a/commands/control_channel.py +++ b/commands/control_channel.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging import threading from dataclasses import dataclass @@ -355,15 +356,31 @@ async def fetch_control_events( failures: List[tuple[str, str]] = [] successes = 0 - for relay in relays: + async def _query_relay(relay: str): try: - raw_events = await _fetch_events_from_relay(relay, filt, timeout) - successes += 1 - for raw in raw_events: - ev = control_event_from_nostr(raw) - by_id[ev.id] = ev + raw_events = await asyncio.wait_for( + _fetch_events_from_relay(relay, filt, timeout), + timeout_seconds, + ) + except TimeoutError: + return relay, None, "timeout" except Exception as e: - failures.append((relay, str(e))) + return relay, None, str(e) + return relay, raw_events, None + + # Every relay is queried at once. The wait is the slowest one, capped at + # timeout_seconds, and a late relay is still included so a stop it alone + # holds is not dropped once the quorum has already answered. + for relay, raw_events, error in await asyncio.gather( + *(_query_relay(relay) for relay in relays) + ): + if error is not None: + failures.append((relay, error)) + continue + successes += 1 + for raw in raw_events: + ev = control_event_from_nostr(raw) + by_id[ev.id] = ev if successes < required: detail = "; ".join(f"{relay}: {error}" for relay, error in failures) or "unknown errors" diff --git a/tests/test_control_channel.py b/tests/test_control_channel.py index ca988f3..5a2f272 100644 --- a/tests/test_control_channel.py +++ b/tests/test_control_channel.py @@ -404,6 +404,59 @@ async def fail_other(relay, filt, timeout): assert len(warnings) == 1 assert "wss://relay-a: timeout" in warnings[0] + @pytest.mark.asyncio + async def test_relays_are_queried_together(self, monkeypatch): + stop_author, start_author = _valid_pubkeys() + started = 0 + release = asyncio.Event() + + async def fake_fetch(relay, filt, timeout): + nonlocal started + started += 1 + if started < 3: + await release.wait() + else: + release.set() + return [] + + monkeypatch.setattr( + "commands.control_channel._fetch_events_from_relay", fake_fetch + ) + await asyncio.wait_for( + fetch_control_events( + ["wss://relay-a", "wss://relay-b", "wss://relay-c"], + {stop_author}, + {start_author}, + relay_quorum=1, + ), + timeout=1, + ) + assert started == 3 + + @pytest.mark.asyncio + async def test_slow_relay_is_capped_at_the_timeout(self, monkeypatch): + stop_author, start_author = _valid_pubkeys() + + async def fake_fetch(relay, filt, timeout): + if relay.endswith("slow"): + await asyncio.sleep(30) + return [] + + monkeypatch.setattr( + "commands.control_channel._fetch_events_from_relay", fake_fetch + ) + started = asyncio.get_running_loop().time() + events = await fetch_control_events( + ["wss://relay-fast", "wss://relay-slow"], + {stop_author}, + {start_author}, + timeout_seconds=0.2, + relay_quorum=1, + ) + elapsed = asyncio.get_running_loop().time() - started + assert events == [] + assert elapsed < 1 + class _Hex: def __init__(self, value):