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_sis the steady-state lag of thefinalizedtag. Stalls (L1 inactivity leak, batcher outage) are budgeted SEPARATELY viaCrossClockMargin.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
finalizedonce 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 sizeeth_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 viaeth_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-lifecycleLinea (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:
objectOne EVM-equivalent counter chain the ETH leg machinery can run against.
chain_idpins 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 durableEthHtlcLocatorrecords it.networkis the tagEthLeg(network=...)reads for the value-bearing/audit gates.finalization_window_sseedsMarginPolicy.eth_finalization_window_s.
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:
objectThin async wrapper over
AsyncWeb3for 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
PrivateKeyMaterialto the signer at the call site).- async assert_chain()[source]¶
Fail-closed if the endpoint is not the chain this swap was negotiated for.
- Return type:
None
- async fee_fields()[source]¶
EIP-1559 fee fields (maxFeePerGas / maxPriorityFeePerGas) from the node.
- Return type:
- 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 aNetworkError. 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 get_transaction_receipt(tx_hash)[source]¶
A single NON-BLOCKING receipt fetch (eth_getTransactionReceipt). Returns
Nonewhen the tx is not currently mined — pending, or reorg-orphaned back to the mempool — instead of blocking likewait_receipt()(a poller must never sleep inside one read). A transport failure is still aNetworkError(fail-closed).
- 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:
- async block_number()[source]¶
The current
latesthead block number (eth_blockNumber). Used alongsidefinalized_block_number()to feed the across-time PoS finality-stall tracker the(head, finalized)pair (a frozenfinalizedwhile the head climbs = a stall).- Return type:
- 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.
- 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 tolatest, notfinalized: 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).
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_getCoderuntime-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:
objectNative-ETH HTLC counter-chain leg (Sepolia-first).
- Parameters:
rpc – An
pyrxd.eth_wallet.rpc.EthRpc(web3-backed).signing_key –
PrivateKeyMaterialfor 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). Useload_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:
rpc (Any)
signing_key (PrivateKeyMaterial)
chain_id (int)
artifact (dict)
private_submitter (Any)
- Return type:
None
- recover_secret(artifacts, hashlock)[source]¶
Recover
pfrom claim calldata + event-log data (pure; see secret.py).
- 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):
chain id matches;
deployed runtime logic == the committed artifact’s (immutable slots masked — no attacker contract / no modified logic);
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);
claimant and refundee are EOAs (empty code) — a contract recipient that reverts on
receivewould brick claim/refund via the contract’srequire(ok);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.
- 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.
- 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_submitterwhen 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_callpreflight 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.
- 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:
- 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.
- 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.
- 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
finalizedcheckpoint (NOT a confirmation depth — seeCounterClaimFinality):the claim’s block is at/under
finalized→FINAL;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-Mergefinalizedadvances 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 noconfirmations/required_depth.MALICIOUS-RPC HARDENING (red-team HIGH, single-source finality):
finalized_block_numberrejects a finalized > head over-report, and we bind the receipt’sblockHashto the CANONICAL block atblockNumber(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
pfrom 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 emitsClaimed(bytes32 preimage)with the SECRETpin the (non-indexed) log data. This leg targets the PER-SWAP-deployEthHtlc.solmodel (one fresh contract per swap,claim(bytes32 preimage)+ immutable hashlock/claimant/refundee/timeout getters thatverify_funded()reads back). NB the sibling repo’s canonicalHashedTimelock.solis a DIFFERENT shared-multi-swap model (claim(bytes32 swapId, bytes32 preimage), no per-swap immutables) NOT compatible with this leg — Phase 4 must inject anEthHtlc.sol-shaped artifact and reconcile/pin the exact event selector (keccak('Claimed(bytes32)')) rather than the current ABI-free p-in-log match.recover_secretmatchessha256(p)==Hover the supplied tx but TRUSTS that the tx belongs to this swap; we verify that here, fail-closed (NOT viatx.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 ifpsits in its calldata);a log emitted BY
contract_addresswhose data carries the SECRETp— the on-chainClaimed(p)event. We bind top, NOT the public hashlockH:His negotiated openly and reused on both legs (so anH-match adds no authenticity), and the deployed contract NEVER re-emitsH(it is a constructor immutable) — anH-in-log gate would reject every legitimate claim.pis secret until the maker reveals it, sopappearing in a log from our unique contract is a genuine, swap-specific proof of a real claim on it.
preimageis the value the coordinator already recovered viascrape_secretand re-verifiedsha256(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 vsfetch_claim_artifacts()is deliberate (correctness over a saved round-trip).
- 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
EthHtlcContractLegvia 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.