pyrxd.eth_wallet — Ethereum counter-leg

The ETH side of a cross-chain swap. The chain-neutral coordinator and the EthLeg orchestrator live in pyrxd.gravity — Cross-chain atomic swaps; the durable EthHtlcLocator and recover_secret are re-exported on the package and documented under pyrxd (top-level).

EVM chain registry

EVM counter-chain registry — the per-chain safety knobs for the finalized-checkpoint leg.

The swap coordinator treats every non-BTC counter leg as a finalized-checkpoint (EVM) chain: the proven EthLeg + EthHtlc.sol machinery is chain-id-agnostic (the same contract bytecode and finalized-tag reads work on any EVM-equivalent chain), so adding an EVM chain does NOT touch the coordinator. What IS chain-specific — and safety-critical for an atomic swap — is how long the ``finalized`` tag lags the tip, which sizes MarginPolicy.eth_finalization_window_s (the reorg gate’s finalization reserve).

This module is that knowledge, written down once with provenance, instead of a magic number per harness.

Window discipline (matches the existing codebase split):

  • finalization_window_s is the steady-state lag of the finalized tag. Stalls (L1 inactivity leak, batcher outage) are budgeted SEPARATELY via CrossClockMargin.eth_finality_stall_tolerance_s — do not inflate the window to cover them.

  • Ethereum L1: finality = 2 epochs = 768 s steady-state (Casper FFG). The 768 s floor is enforced by MarginPolicy.

  • Base (OP-stack L2): an L2 block is finalized once the batch containing it sits in a FINALIZED L1 block — i.e. batch-posting cadence (~1 min on Base) + L1 inclusion + the same 2-epoch L1 finality. Steady-state ≈ 15 min; the entry below uses 900 s (CHOSEN/ ESTIMATED). HONEST WORST CASE: the OP-stack sequencing window permits a batch to land up to 12 hours late (and a chain that misses it can reorg that far) — a swap operator must size eth_finality_stall_tolerance_s (and therefore the RXD timelock) for the stall they are willing to survive, exactly as for an L1 finality stall. Sources: Base “Transaction Finality” docs; OP-stack batcher/configurability specs (https://docs.base.org/base-chain/network-information/transaction-finality, https://specs.optimism.io/protocol/configurability.html).

  • Optimism (OP-stack L2): the SAME stack as Base, identical finalized-tag semantics; 900 s steady-state (observed ~15-20 min), 12 h sequencing-window worst case budgeted separately. Source: https://docs.optimism.io/app-developers/transactions/statuses

  • Arbitrum One (Nitro optimistic rollup): finalized = L2 block whose Sequencer batch sits in a FINALIZED L1 block (Ethereum-anchored hard finality). ~10-20 min steady-state -> 1200 s. WORST CASE: the sequencer force-inclusion delay (maxTimeVariation) is ~24 h (vs OP-stack 12 h) — a liveness stall of the finalized tag, budgeted via eth_finality_stall_tolerance_s; NOT the ~6.4 d withdrawal dispute window (irrelevant to reorg safety). Source: https://docs.arbitrum.io/how-arbitrum-works/transaction-lifecycle

  • Linea (zk / validity rollup): finalized = L2 block whose validity PROOF is verified in a finalized L1 block (Ethereum-anchored). Proof-cadence-dominated: official MEDIAN hard finality ~1 h 40 (6000 s), documented to “never exceed 16 h” — that tail goes to the stall budget, not the steady window. Source: https://docs.linea.build (finality).

Deliberately NOT in the registry — Polygon PoS (chain_id 137): it does not fit this model. Its finalized tag is Polygon’s OWN validator-set “milestone” finality (Heimdall / CometBFT, ~5 s), NOT Ethereum-anchored — Polygon PoS is a commit-chain/sidechain that checkpoints to Ethereum for withdrawal proofs but does not inherit Ethereum finality block-by-block. So the 768 s floor misrepresents it in BOTH directions: it finalizes far faster than 768 s (via its own ~5 s consensus), and that finality is secured by Polygon’s stake, not Ethereum’s. Treating it as “just another EVM chain” would silently swap the trust model an atomic-swap operator relies on; a Polygon-PoS swap needs an explicit, separately-justified finality model (a reorg depth in Polygon’s own security terms), not this Ethereum-anchored window. evm_chain_by_id(137) fails closed. (A 2025-09-10 faulty-milestone incident delayed Polygon finality ~15 min-1 h, resolved only by an emergency hard fork — a validator-set liveness risk with no Ethereum analogue.)

The network tag feeds the existing fail-closed gates unchanged: any tag not in AUDIT_CLEARED_NETWORKS (only isolated test chains are) is value-bearing and refuses to run without the explicit post-audit audit_cleared=True opt-in — so every chain here, including the testnets, stays behind the audit gate by construction.

class pyrxd.eth_wallet.chains.EvmChain[source]

Bases: object

One EVM-equivalent counter chain the ETH leg machinery can run against.

chain_id pins the chain everywhere it matters: EthRpc(expected_chain_id=...) refuses a node on the wrong chain, EthHtlcContractLeg(chain_id=...) signs with EIP-155 replay protection, and the durable EthHtlcLocator records it. network is the tag EthLeg(network=...) reads for the value-bearing/audit gates. finalization_window_s seeds MarginPolicy.eth_finalization_window_s.

name: str
chain_id: int
network: str
finalization_window_s: int
__init__(name, chain_id, network, finalization_window_s)
Parameters:
  • name (str)

  • chain_id (int)

  • network (str)

  • finalization_window_s (int)

Return type:

None

pyrxd.eth_wallet.chains.evm_chain_by_id(chain_id)[source]

Look up a known chain by EIP-155 chain id; raises for an unknown one (fail-closed — an unknown chain has no vetted finalization window, so refuse rather than guess).

Parameters:

chain_id (int)

Return type:

EvmChain

JSON-RPC client

Minimal async Ethereum JSON-RPC client (web3-backed), mirroring the repo’s BTC client.

Follows the network/bitcoin.py / network/electrumx.py house style: a client-owned session, close() lifecycle, NetworkError on transport failure, and a bounded response size. web3 is imported LAZILY so eth_wallet loads with no Ethereum dependency installed — only constructing/using EthRpc requires web3 (a Phase-3 network dependency), which is exactly when a live RPC endpoint is also needed.

This is the I/O layer; the security-critical preimage parsing is the pure pyrxd.eth_wallet.secret.recover_secret() (offline-fuzzable, no web3).

class pyrxd.eth_wallet.rpc.EthRpc[source]

Bases: object

Thin async wrapper over AsyncWeb3 for the handful of calls the leg needs.

Construction requires web3 + an RPC URL; signing keys are NOT held here (the leg feeds raw bytes from PrivateKeyMaterial to the signer at the call site).

__init__(rpc_url, *, expected_chain_id)[source]
Parameters:
  • rpc_url (str)

  • expected_chain_id (int)

Return type:

None

property w3: Any
async assert_chain()[source]

Fail-closed if the endpoint is not the chain this swap was negotiated for.

Return type:

None

async get_code(address, block_identifier=None)[source]
Parameters:
  • address (str)

  • block_identifier (str | int | None)

Return type:

bytes

async get_balance(address, block_identifier=None)[source]
Parameters:
  • address (str)

  • block_identifier (str | int | None)

Return type:

int

async get_transaction_count(address)[source]

Pending nonce for the sender.

Parameters:

address (str)

Return type:

int

async fee_fields()[source]

EIP-1559 fee fields (maxFeePerGas / maxPriorityFeePerGas) from the node.

Return type:

dict

async preflight(tx)[source]

eth_call the tx to detect a guaranteed revert BEFORE broadcasting.

Fails fast (raises ValidationError) instead of burning gas on a tx the node will mine-and-revert (e.g. a premature refund, a bad preimage, an already-settled HTLC). A transport failure is a NetworkError. Strips gas/fee fields the node would reject in an eth_call.

CONSERVATIVE CLASSIFICATION (red-team): a definite revert is recognised ONLY from web3’s TYPED contract-exception classes — an honest node raises ContractLogicError / ContractCustomError / ContractPanicError for a real revert (custom errors arrive as a 4-byte selector, e.g. NotYetExpired() -> 0x59912c06). We deliberately do NOT substring- match the error text: that string is RPC-controlled, so a lying node could stuff “revert” into a transport error to make us classify the HONEST taker refund (the only exit path) as a permanent ValidationError and abort it. An untyped failure is therefore treated as a retryable NetworkError — preflight is a gas-saving optimisation, not a safety gate, so under uncertainty we retry rather than permanently block the exit. A genuinely premature refund still reverts typed (NotYetExpired) on any honest node.

Parameters:

tx (dict)

Return type:

None

async send_raw(raw_tx)[source]
Parameters:

raw_tx (bytes)

Return type:

str

async wait_receipt(tx_hash, *, timeout_s=300.0)[source]
Parameters:
Return type:

dict

async get_transaction(tx_hash)[source]
Parameters:

tx_hash (str)

Return type:

dict

async get_transaction_receipt(tx_hash)[source]

A single NON-BLOCKING receipt fetch (eth_getTransactionReceipt). Returns None when the tx is not currently mined — pending, or reorg-orphaned back to the mempool — instead of blocking like wait_receipt() (a poller must never sleep inside one read). A transport failure is still a NetworkError (fail-closed).

Parameters:

tx_hash (str)

Return type:

dict[str, Any] | None

async finalized_block_number()[source]

Block number of the finalized consensus checkpoint (the reorg-safe tip).

SANITY-BOUNDED (red-team HIGH: single-source finality): a finalized value that exceeds the latest head from the SAME provider is incoherent (finalized is always <= head) and is rejected fail-closed — this catches a naive lying RPC that over-reports finalized to make a non-final claim look FINAL. It does NOT defend a fully-consistent malicious provider that lies about BOTH finalized and the canonical chain: for a real-value path a multi-source finality quorum is required (deferred; documented in claim_finality_verdict).

Return type:

int

async block_number()[source]

The current latest head block number (eth_blockNumber). Used alongside finalized_block_number() to feed the across-time PoS finality-stall tracker the (head, finalized) pair (a frozen finalized while the head climbs = a stall).

Return type:

int

async canonical_block_hash(block_number)[source]

The canonical block hash at block_number (eth_getBlockByNumber). Used to bind a receipt’s claimed blockNumber to the canonical chain (red-team HIGH: receipt blockNumber on faith) — a fabricated receipt height is caught when its blockHash != the canonical hash.

Parameters:

block_number (int)

Return type:

bytes

async get_logs(*, address, topics=None, from_block='earliest', to_block='latest')[source]

eth_getLogs for ONE contract address, optionally filtered by topics. READ-ONLY.

Scoped to a single address (the per-swap-unique HTLC), so the result is that contract’s own event history — a handful of entries. Pass an int from_block (e.g. the deploy block) to bound the scan; to_block="latest" catches a JUST-mined claim. Detection deliberately reads to latest, not finalized: a watchtower must not MISS a fresh claim it has to race, and reorg-safety is asserted SEPARATELY by the finalized-checkpoint verdict (a non-final log can only ever cause a false PAGE, never a broadcast). Transport failure → NetworkError; the entry count is bounded (a pathological return must not OOM the tower).

Parameters:
Return type:

list[dict[str, Any]]

async close()[source]

Close the underlying provider session if it exposes one.

Return type:

None

HTLC contract leg

EthHtlcContractLeg — the web3-backed ETH counter-chain leg.

Implements the counter-chain leg surface (deploy/claim/refund/recover-secret/is-final) for native-ETH HTLC swaps. This is the I/O-bearing layer; the security-critical preimage recovery is the pure pyrxd.eth_wallet.secret.recover_secret(), and the durable state is EthHtlcLocator.

DESIGNED-AND-UNPROVEN until the Sepolia end-to-end proof (Phase 4). web3 is imported lazily, so this module loads without the Ethereum stack; only the network-touching methods require it. The Phase-6 CounterChainLeg ABC will reconcile method names with the BTC leg; until then this exposes ETH-native names and is driven by the Phase-4 Sepolia harness (mirroring how the BTC leg was first proven by its own spike driver).

Key handling (HARD): the signing key is PrivateKeyMaterial; its raw bytes are fed to the signer at the call site and never persisted as an eth_account object.

Security gates enforced here (off-chain, per the security review):
  • pre-fund: eth_getCode runtime-bytecode == the committed artifact’s, the contract immutables (hashlock/claimant/refundee/timeout) == negotiated, and the funded balance == negotiated amount — BEFORE the maker is told to lock RXD.

  • EOA-only claimant/refundee (a recipient contract that reverts on receive would lock funds via the contract’s require(ok)).

class pyrxd.eth_wallet.htlc_leg.EthHtlcContractLeg[source]

Bases: object

Native-ETH HTLC counter-chain leg (Sepolia-first).

Parameters:
  • rpc – An pyrxd.eth_wallet.rpc.EthRpc (web3-backed).

  • signing_keyPrivateKeyMaterial for the EOA that sends txs (taker for fund/refund, maker for claim — separate leg instances per role).

  • chain_id – EIP-155 chain id; must match rpc’s endpoint (asserted at use).

  • artifact – The EthHtlc contract artifact dict (abi + bytecode + runtime_bytecode), owned and INJECTED by the deploying application (its audited Foundry build output). Use load_artifact() to read it from disk. pyrxd ships no contract bytecode of its own.

__init__(*, rpc, signing_key, chain_id, artifact, private_submitter=None)[source]
Parameters:
Return type:

None

property chain_id: int
property expected_runtime_code: bytes
expected_runtime_code_hash()[source]
Return type:

bytes

recover_secret(artifacts, hashlock)[source]

Recover p from claim calldata + event-log data (pure; see secret.py).

Parameters:
Return type:

bytes

async verify_funded(locator, *, expected_amount_wei, block_identifier=None)[source]

Pre-RXD-lock gate: the on-chain contract matches the negotiated terms.

Fail-closed checks (any mismatch raises; the taker does NOT tell the maker to lock RXD):

  1. chain id matches;

  2. deployed runtime logic == the committed artifact’s (immutable slots masked — no attacker contract / no modified logic);

  3. the contract IMMUTABLES (hashlock/claimant/refundee/timeout) read back via the getters == the negotiated terms in the locator (the meaningful binding check — proves the contract releases on the right secret to the right party at the right time);

  4. claimant and refundee are EOAs (empty code) — a contract recipient that reverts on receive would brick claim/refund via the contract’s require(ok);

  5. funded balance == expected amount (no underfunded contract).

block_identifier (red-team HIGH TOCTOU): pin EVERY read to one block. The taker’s fund-time self-verify reads ‘latest’ (None). The MAKER’s pre-lock re-verify passes 'finalized' so a reorg cannot re-deploy a DIFFERENT contract at the same CREATE address (EVM addresses are (deployer,nonce)-derived) between verify and the RXD lock — a finalized deploy is non-reorgable. All getters + get_code + get_balance honour it.

Parameters:
  • locator (EthHtlcLocator)

  • expected_amount_wei (int)

  • block_identifier (str | int | None)

Return type:

None

async fund(*, hashlock, claimant, refundee, timeout, amount_wei)[source]

Deploy + fund the HTLC (payable constructor). Returns the locator ONLY after the deploy tx confirms with status==1 (a reverted/dropped deploy never yields a ‘funded’ locator). The TAKER calls this; claimant=maker, refundee=taker.

Parameters:
Return type:

EthHtlcLocator

async claim(locator, preimage)[source]

Maker: call claim(preimage); returns the tx hash. On MAINNET the maker SHOULD use private inclusion (Flashbots) — the public mempool exposes p before mining, letting the taker claim RXD while this ETH claim is still reorg-able.

Routed through the injected private_submitter when one is supplied (private=True); otherwise it goes to the public mempool (the privacy property is then NOT provided — the operator opted out by not injecting a submitter).

PREIMAGE-LEAK FIX (red-team MEDIUM): the off-chain eth_call preflight sends the claim calldata — which CONTAINS p — to the (public) RPC, defeating private inclusion before the private submit even runs. So when a private submitter IS injected we SKIP the preflight (the whole point is that p must not touch the public RPC); the on-chain revert protection the preflight gave is traded for p-privacy, which is the correct trade for the reveal tx. On the public-fallback path (no submitter) p goes public anyway, so the preflight stays.

Parameters:
  • locator (EthHtlcLocator)

  • preimage (bytes)

Return type:

str

async refund(locator)[source]

Taker: call refund() after timeout; returns the tx hash. Taker-unilateral (no maker signature; the contract pays the immutable refundee).

Parameters:

locator (EthHtlcLocator)

Return type:

str

async fetch_claim_artifacts(tx_hash)[source]

Fetch the candidate byte blobs for recover_secret: the tx INPUT calldata + the DATA of every log in the receipt. Works on a reverted-but-mined tx too (calldata is still present). Pure recover_secret(…) then matches by sha256==H.

SIZE-BOUNDED (red-team LOW): recover_secret does an O(n) sliding-window sha256 scan, so a malicious RPC returning attacker-sized calldata/log data is a CPU/memory DoS. A legitimate claim(bytes32) calldata + Claimed(bytes32) log are ~tens of bytes; we cap each blob and the aggregate well above that and fail closed past the cap rather than scan unbounded.

Parameters:

tx_hash (str)

Return type:

list[bytes]

async is_final(tx_hash)[source]

True once the tx’s block is at/under the finalized checkpoint. The taker must NOT mark the swap COMPLETED (RXD claim irreversible) until the ETH claim is FINAL, since a pre-finality reorg could un-mine it.

Parameters:

tx_hash (str)

Return type:

bool

async claim_finality_verdict(tx_hash)[source]

The POINT-IN-TIME counter-leg finality verdict for the maker’s ETH claim, from the post-Merge finalized checkpoint (NOT a confirmation depth — see CounterClaimFinality):

  • the claim’s block is at/under finalizedFINAL;

  • otherwise (not yet finalized, OR reverted/dropped status != 1) → NOT_YET_FINAL_LIVE.

This is a stateless single observation: it never emits COUNTER_CHAIN_NOT_FINALIZING. That verdict means the chain is not advancing finalization, which can only be judged across time (post-Merge finalized advances at epoch boundaries, ~6.4 min, so a single non-advance is normal, not a stall). Detecting a genuine non-finality stall — finalized stuck for ≥ a patience window of epochs — is the coordinator’s polling-loop responsibility (Phase-3 wiring), not this point-in-time producer, which would otherwise false-positive on any fast poll. ETH finality is not a depth, so the verdict carries no confirmations / required_depth.

MALICIOUS-RPC HARDENING (red-team HIGH, single-source finality): finalized_block_number rejects a finalized > head over-report, and we bind the receipt’s blockHash to the CANONICAL block at blockNumber (a fabricated receipt height is caught when its hash != the canonical hash) — so a naive lying RPC cannot make a non-final claim read FINAL. This does NOT defend a fully-consistent malicious provider (one that lies coherently about the whole chain): a real-value path MUST use a multi-source finality quorum (≥2 independent providers must agree the claim is final). That quorum is DEFERRED to the audit-gated real-value track; the dust/pre-audit path accepts a single trusted provider.

Parameters:

tx_hash (str)

Return type:

CounterClaimFinality

async assert_claim_provenance(tx_hash, *, contract_address, preimage)[source]

Provenance gate (R6): the maker’s claim tx MUST target THIS swap’s HTLC contract instance AND emit the revealed secret p from it — the ETH analogue of the BTC “claim tx spends our funding outpoint” check (_assert_claim_tx_spends_our_htlc).

Each swap deploys a FRESH HTLC contract at a unique CREATE address (recorded in the locator after verify_funded()), so the contract address is per-swap-unique exactly like a BTC funding outpoint. The contract’s claim path emits Claimed(bytes32 preimage) with the SECRET p in the (non-indexed) log data. This leg targets the PER-SWAP-deploy EthHtlc.sol model (one fresh contract per swap, claim(bytes32 preimage) + immutable hashlock/claimant/refundee/timeout getters that verify_funded() reads back). NB the sibling repo’s canonical HashedTimelock.sol is a DIFFERENT shared-multi-swap model (claim(bytes32 swapId, bytes32 preimage), no per-swap immutables) NOT compatible with this leg — Phase 4 must inject an EthHtlc.sol-shaped artifact and reconcile/pin the exact event selector (keccak('Claimed(bytes32)')) rather than the current ABI-free p-in-log match. recover_secret matches sha256(p)==H over the supplied tx but TRUSTS that the tx belongs to this swap; we verify that here, fail-closed (NOT via tx.to — that rejected legitimate claims routed through a smart-contract wallet / multicall, red-team MEDIUM — but via the strictly-stronger log-emitter binding):

  • receipt.status == 1 — the claim actually succeeded (the ETH moved; a reverted tx never paid the maker even if p sits in its calldata);

  • a log emitted BY contract_address whose data carries the SECRET p — the on-chain Claimed(p) event. We bind to p, NOT the public hashlock H: H is negotiated openly and reused on both legs (so an H-match adds no authenticity), and the deployed contract NEVER re-emits H (it is a constructor immutable) — an H-in-log gate would reject every legitimate claim. p is secret until the maker reveals it, so p appearing in a log from our unique contract is a genuine, swap-specific proof of a real claim on it.

preimage is the value the coordinator already recovered via scrape_secret and re-verified sha256(p)==H, so passing it here adds no trust assumption. Any RPC error propagates and aborts the claim — also fail-closed. The redundant receipt read vs fetch_claim_artifacts() is deliberate (correctness over a saved round-trip).

Parameters:
  • tx_hash (str)

  • contract_address (str)

  • preimage (bytes)

Return type:

None

pyrxd.eth_wallet.htlc_leg.load_artifact(path)[source]

Load an EthHtlc artifact (ABI + bytecode + runtime_bytecode) from path.

The contract artifact is owned by the DEPLOYING application (its audited Foundry build output), NOT shipped inside the pyrxd wheel — it is INJECTED into EthHtlcContractLeg via its constructor so the wheel carries no contract bytecode and the audited artifact stays beside its contract source. This helper is a convenience for callers that have the artifact on disk; pass the resulting dict in.

Parameters:

path (str | PathLike)

Return type:

dict