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.)

Deliberately NOT in the registry — BNB Smart Chain (chain_id 56), for the same reason as Polygon PoS and it is worth stating separately because the token side looks so inviting. BSC’s finalized tag is its OWN validator set’s (Parlia + BEP-126 fast finality), not Ethereum-anchored. MEASURED 2026-08-25 against three endpoints on distinct hosts (bsc-dataseed.bnbchain.org, bsc-mainnet.public.blastapi.io, bsc-dataseed1.defibit.io): the finalized tag sits 0-2 seconds / 1-2 blocks behind the tip. An Ethereum-anchored chain cannot finalize in under the 768 s L1 checkpoint, so that measurement alone settles what kind of finality this is.

The floor would force a 768 s window onto it, and — exactly as for Polygon — that number misrepresents BSC in both directions: it finalizes far faster, and the finality it does have is secured by BSC’s stake, not Ethereum’s. Note that inflating the window does NOT recover the difference. A longer wait buys more of BSC’s own security, never Ethereum’s, so the swap operator’s trust model is silently substituted whatever number is chosen. BSC needs a reorg depth argued in its own validator-set terms before it can carry value here; evm_chain_by_id(56) fails closed until someone writes that down.

(The token side is genuinely clean — see the BSC note in tokens.py — which is precisely why this exclusion is recorded here rather than left to be rediscovered.)

The network tag feeds the coordinator’s value-bearing checks unchanged: any tag not in AUDIT_CLEARED_NETWORKS (only isolated test chains are) is read as value-bearing, so every chain here, testnets included, gets those setup checks. It does NOT make a leg refuse to run: EthLeg’s audit_cleared feeds require_audit_cleared, a no-op since 0.9.0.

pyrxd.eth_wallet.chains.ETH_FINALIZATION_WINDOW_FLOOR_S: int = 768

Hard floor (SECONDS) on any chain’s finalization_window_s. THE ONE DEFINITION — pyrxd.gravity.swap_coordinator.MarginPolicy imports this name rather than holding its own copy.

Post-Merge Ethereum finality is two epochs: 2 x 32 slots x 12 s. Every EVM chain here settles to Ethereum L1, so none of them can finalize faster than the L1 checkpoint they anchor to, which makes this a floor for L2s as much as for L1.

It was two independent literals — this one and MarginPolicy’s — joined by a “Keep in sync with” comment. That comment is the whole failure mode written down: it names the obligation and provides nothing that can discharge it. The two guard the SAME rule at two different boundaries (registry construction here, MarginPolicy construction there), so raising one to reflect a real consensus change and missing the other leaves a cross-chain swap claiming against a finalization reserve that the chain no longer honours — the reorg gate’s entire purpose.

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
is_testnet: bool = False

Whether this chain’s coins are FAUCET money. Stated per entry, never inferred.

network cannot answer this. It feeds the audit gate, whose cleared set holds Bitcoin-family tags only — so every EVM chain here, testnets included, reads as “not audit-cleared”. That is correct for what that gate does (nothing here is audit-cleared) and useless for deciding whether real value is at stake. Reading it as the latter forced measured margins and a multi-endpoint quorum onto a Base Sepolia rehearsal: a guard refusing honest work, caught by the runner’s own wiring tests.

Not derived from the name either. “ends in -sepolia” is true of every testnet in this registry today and is a naming convention, not a property; the next testnet that breaks it would be silently promoted to real-value.

__init__(name, chain_id, network, finalization_window_s, is_testnet=False)
Parameters:
  • name (str)

  • chain_id (int)

  • network (str)

  • finalization_window_s (int)

  • is_testnet (bool)

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

source_key

The source this endpoint is (its operator group, source_key()), for MultiSourceEthRpc to count it by. Derived before web3 is touched, so an unparseable URL fails here.

property expected_chain_id: int

The chain this endpoint is pinned to — what assert_chain() holds it to.

Public so a leg can check that the chain id it SIGNS with is this one. assert_chain only compares the endpoint with this value; a leg signing for a different chain passed it, and its signed bytes then reached a provider on the wrong network.

property w3: Any
property write_w3: Any

The web3 used to BUILD transactions. Identical to w3 here.

It exists so the legs can name the difference between building a transaction (which has to happen against one endpoint) and reading state (which should not). MultiSourceEthRpc makes w3 fatal precisely so an unconverted READ cannot quietly run single-source; the write sites say write_w3 and mean it.

async latest_block_timestamp()[source]

Head timestamp, for the claim-deadline guard (wants the LATEST any source admits to).

Return type:

int

async latest_block_timestamp_quorum()[source]

Same read; the multi-source class returns the quorum-th head instead. One endpoint has one answer, so all three accessors coincide here.

Return type:

int

async latest_block_timestamp_min()[source]

Same read; the name exists so the multi-source class can aggregate it the OTHER way for the staleness and refund-maturity guards. Identical here — one endpoint has one answer.

Return type:

int

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, block='pending')[source]

Nonce for the sender. Defaults to pending so a freshly built tx does not collide with one still in the mempool.

The block parameter exists so a caller can compare the two: pending != latest means this sender has transactions in flight. That is the difference between “the HTLC holds nothing” and “the HTLC holds nothing YET”, which a balance read alone cannot tell apart — see the resume guard in Erc20HtlcLeg._push_and_bind().

Parameters:
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 the fee fields, and KEEPS gas (#850 PR 4a): an eth_call without it runs with the node’s own large cap, so a call that would run out of gas at the signed limit passed here.

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 estimate_gas(tx)[source]

eth_estimateGas for tx, with its gas and fee fields stripped so the node estimates what the call NEEDS rather than whether it fits the limit already set. A typed contract revert is a ValidationError, classified as preflight() does; any other failure is a retryable NetworkError.

Parameters:

tx (dict[str, Any])

Return type:

int

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 find_transaction(tx_hash)[source]

eth_getTransactionByHash, with “this node does not have it” as None and every other failure (a 429, a timeout) as a NetworkError, so a caller can tell an ANSWER that a transaction is absent from a read that failed (review of #850 PR 5b).

Parameters:

tx_hash (str)

Return type:

dict | None

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 block_hash_and_timestamp(block_number)[source]

(hash, timestamp) of the canonical block at block_number, from ONE block read, so the timestamp is bound to the hash it came with (#850 disposition reader: timestamp(F) decides expiry and must be the timestamp of the block whose hash was agreed).

Parameters:

block_number (int)

Return type:

tuple[bytes, int]

async get_storage_at(address, slot, block_identifier)[source]

The 32-byte storage word at slot of address as of block_identifier. An integer block needs historic state, which a non-archive endpoint refuses: that is a failed read, never a zero.

Parameters:
Return type:

bytes

async erc20_balance_of(token, owner, block_identifier)[source]

token.balanceOf(owner) as of block_identifier, by a raw eth_call (#850 PR 7a). An integer block needs historic state; a refusal is a failed read, never a zero. An answer that is not one 32-byte word is a failed read too.

Parameters:
Return type:

int

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 get_logs_union(*, address, topics, from_block, to_block)[source]

The #850 union log read (pyrxd.eth_wallet.disposition.union_logs()) over this one endpoint: chunked, halved on a range-limit refusal, a failure recorded rather than read as “no logs”. For NOMINATION only. get_logs() is unchanged.

Parameters:
  • address (str)

  • topics (list[Any])

  • from_block (int)

  • to_block (int)

Return type:

LogUnion

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 runtime EXPECTED for the negotiated terms — the committed artifact with each negotiated immutable substituted into EVERY one of its immutableReferences offsets, compared for EXACT byte equality (no masking). The contract immutables (hashlock/claimant/refundee/timeout) are ALSO read back == negotiated, and the funded balance >= the negotiated amount. Run inside the funder’s own fund(), and again on the maker’s side before the maker reveals p (the maker’s RXD lock precedes both). The balance is a LOWER BOUND on purpose — anyone can force-send wei to a contract (selfdestruct/coinbase), so an == expected check is griefable into a permanent verify failure; over-funding is safe because claim/refund sweep the whole balance to the winner. This line said “== negotiated amount” until 2026-09-03; the check has been bal < expected_amount_wei since the red-team LOW that introduced it, and EthHtlcContractLeg.verify_funded() documents it correctly at the check itself.

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

pyrxd.eth_wallet.htlc_leg.REFUND_GAS_LIMIT: int = 150000

The gas limit a refund is signed with (#850 PR 4a, design §3.4), up from 100k. Measured gasUsed: 85.7k–86.3k for an Erc20Htlc refund (the design, anvil mainnet forks); about 51k for an EthHtlc refund to an EOA (anvil 1.7.1, the PR 4a review). The sender pays only the gas used, but a node requires limit x maxFeePerGas to be held up front, so a sender who cannot cover it is signed at a lower limit, never below the estimate (_affordable_refund_gas()). Every refund is checked against this limit with eth_estimateGas before it is signed.

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_key – PrivateKeyMaterial 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 equal rpc.expected_chain_id. Checked at the one place it matters — _sign_tx(), before any transaction is signed — because assert_chain only holds the endpoint to the RPC’s own id and cannot see the id the leg signs with.

  • artifact – The EthHtlc contract artifact dict (abi + bytecode + runtime_bytecode + immutableReferences + immutable_names), owned and INJECTED by the deploying application. Use load_artifact() to read it from disk. pyrxd ships no contract bytecode of its own. A plain Foundry build output does NOT qualify (it nests the code, and has no immutable_names); load_artifact() says how to produce one that does. An artifact without the immutable layout is refused HERE, at construction, because the exact runtime check in verify_funded() needs it and fund moves value first.

__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, allow_delegated_eoa_recipients=False)[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 == the runtime expected for the negotiated terms, by EXACT byte equality after substituting each negotiated immutable into every immutableReferences offset (no attacker contract, no modified logic, and no forged immutable copy the getters cannot see — see _expected_runtime());

  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 (a LOWER bound — see the comment at the check) (no underfunded contract);

  6. the contract is NOT ALREADY SETTLED (storage slot SETTLED_SLOT is zero). Check 2 covers the code, not storage; a contract whose settled flag is set cannot pay out (claim() and refund() both revert AlreadySettled), so it is not a funded HTLC.

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)

  • allow_delegated_eoa_recipients (bool)

Return type:

None

async fund(*, hashlock, claimant, refundee, timeout, amount_wei, on_deploy=None, resume_from=None, push_nonce=None, on_push_nonce=None, push_tx_hash=None, on_push_hash=None)[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.

push_nonce, on_push_nonce, push_tx_hash and on_push_hash are accepted and ignored: a native fund is one transaction, with no separate push to pin.

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, *, on_signed=None, replace=None)[source]

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

on_signed is awaited with (tx_hash, nonce) after signing and before the broadcast, as for fund (#502 item 2), so a caller can record the refund before it can be mined; it is how the coordinator records a pending refund (#850 PR 5b). An exception from it stops the send.

replace signs at that nonce, with both fee fields raised from the pending refund’s own (never below the current quote), so the node takes it as a replacement rather than as a second transaction (#850 PR 5b, design §3.4).

Gas (#850 PR 4a): eth_estimateGas runs first and must come in at or under REFUND_GAS_LIMIT, or this raises RefundGasAboveCeiling. The refund is signed at that limit, or at the most the sender can hold up front if less, never below the estimate (RefundFeeUnaffordable otherwise). The preflight then runs at the limit signed. The limit is in the signed transaction (get_transaction(hash)["gas"]), which is the value refund_ran_out_of_gas() must be given.

P3 maturity pre-check: the contract’s refund() reverts until block.timestamp >= timeout. We read the latest block timestamp and refuse a premature refund with a clear “matures at unix T, now S” message rather than burning gas on a guaranteed revert. Unlike the BTC/RXD CSV legs, a premature ETH refund is a deterministic on-chain revert (never a silent mempool strand), so this is a gas/clarity guard — the contract stays the source of truth for the deadline.

Parameters:
Return type:

str

async refund_progress(tx_hash, nonce, *, earlier=())[source]

What happened to the refund we signed as tx_hash at nonce (RefundProgress). earlier are refunds we signed before it at the same nonce (a replacement may lose to the one it replaced, or never reach the node): any of them mined is what happened, and the fees of whichever one the endpoint still holds are what a replacement must beat. Read-only. A read that fails raises; the caller then decides nothing.

Parameters:
Return type:

RefundProgress

async close_nonce(nonce, *, on_signed=None)[source]

Consume nonce with a 0-value transfer from this key to itself (#850 PR 7a, design §2.7).

Once a transaction at nonce is final, no other transaction this key signed at or below it can land, which is how an empty expired token contract is proven to stay empty: the token push was signed at or below it. It moves no value and needs no contract.

Refuses, with nothing signed, unless the broadcasting endpoint’s latest count for this key is exactly nonce: above it, the nonce is already consumed (wait for finality); below it, an earlier transaction of this key is missing, and this one would queue behind it. on_signed is awaited with (tx_hash, nonce) before the broadcast; an exception from it stops the send. Nothing here prices this transaction to replace one pending at nonce (a push, say). The node usually refuses it as underpriced, but if fees have risen enough since the pending one was priced, it replaces it. Either way one transaction consumes the nonce, and the contract is read for tokens at the finalized block, not assumed empty.

Parameters:
Return type:

str

async refund_estimate(locator)[source]

eth_estimateGas for a refund of locator now. A typed revert raises ValidationError; see refund_revert_reason() to name it.

Parameters:

locator (EthHtlcLocator)

Return type:

int

async refund_revert_reason(locator)[source]

Why a refund of locator would revert NOW: the contract error’s name when the node’s typed revert carries a selector this knows (AlreadySettled, TransferFailed, …), "unrecognised: <text>" for any other typed revert, or None when it would not revert.

The text is one endpoint’s, so this NAMES a revert; it must not be what decides to move value. The coordinator uses it only to choose which refusal to raise.

Parameters:

locator (EthHtlcLocator)

Return type:

str | None

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 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-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 providers of distinct operators — known operators, else registered domain — must agree the claim is final; that grouping does not prove independence). 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

class pyrxd.eth_wallet.htlc_leg.RefundProgress[source]

Bases: object

What happened to a refund we signed, read from the chain by hash and nonce (#850 PR 5b, design §3.4). kind is one of:

  • "pending": not mined, and the broadcasting endpoint holds a transaction at its nonce; fee_fields are the fees of the first of our hashes the endpoint returns, None when it returns none of them (the pending transaction may then be one we did not sign).

  • "dropped": not mined, the key’s latest nonce equals it, and nothing is pending there.

  • "nonce_gap": not mined, and the key’s latest nonce is BELOW it: an earlier transaction from this key is missing, so ours cannot mine yet.

  • "nonce_taken": not mined, and another transaction mined at its nonce.

  • "mined": mined with status == 1. Says nothing about finality.

  • "out_of_gas": mined status == 0 having used at least 63/64 of gas_limit, the limit it was signed with (refund_ran_out_of_gas()).

  • "reverted": mined status == 0 for any other reason.

The receipt is a quorum read on a multi-source rpc; the nonce counts and the transaction body are the broadcasting endpoint’s, the node whose mempool the refund went to.

kind: str
gas_limit: int | None = None
fee_fields: dict[str, int] | None = None
tx_hash: str | None = None

the one that MINED (mined, out_of_gas, reverted) or the one the node HOLDS (pending with fees). None otherwise.

Type:

Which of our hashes this is about

__init__(kind, gas_limit=None, fee_fields=None, tx_hash=None)
Parameters:
Return type:

None

class pyrxd.eth_wallet.htlc_leg.RefundReplacement[source]

Bases: object

Replace our pending refund at nonce: the new one is priced from the pending one’s own fee fields (previous_fees), raised enough that a node accepts it as a replacement (pyrxd.eth_wallet.replacement.bump_replacement_fees()).

nonce: int
previous_fees: dict[str, int]
__init__(nonce, previous_fees)
Parameters:
Return type:

None

pyrxd.eth_wallet.htlc_leg.create_address(sender, nonce)[source]

The CREATE address for sender at nonce — keccak(rlp([sender, nonce]))[12:].

Both inputs belong to the deployer, so a deploy receipt’s contractAddress was never something to be told. Minimal RLP: a 20-byte address is a 0x94-prefixed string, and the nonce is either the empty string (0), a single byte below 0x80, or a length-prefixed big-endian integer.

DUPLICATED, deliberately and temporarily, with erc20_leg._create_address: the token leg imports FROM this module, so importing back would be circular, and collapsing the two means editing that file. They are pinned to agree by a differential test rather than by a comment.

Parameters:
Return type:

str

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

Load an EthHtlc artifact from path (see below for the keys it must carry).

The contract artifact is owned by the DEPLOYING application (built from its audited contract source), 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.

A plain Foundry ``out/<C>.sol/<C>.json`` does NOT qualify, and the leg’s constructor refuses it. Foundry nests the code under bytecode.object / deployedBytecode.object, keeps immutableReferences under deployedBytecode, and has no immutable_names at all. The artifact must be a flat dict with:

  • abi, bytecode (creation code) and runtime_bytecode (deployed code), 0x hex;

  • immutableReferences — the compiler’s deployedBytecode.immutableReferences, copied unchanged: {reference-id: [{"start": int, "length": 32}, ...]};

  • immutable_names — {reference-id: name} naming the constructor term each id holds (hashlock/claimant/refundee/timeout, plus token/amount for Erc20Htlc). A reference id is the AST node id of the immutable’s VariableDeclaration: read, from the compiler’s AST, each VariableDeclaration whose mutability is "immutable"; its id is the key and its name the value (scripts/build_counter_leg_artifacts.py does this for the committed artifacts). Ids are specific to one compilation, so derive the map from the SAME build that produced immutableReferences, never by hand.

Parameters:

path (str | PathLike)

Return type:

dict

pyrxd.eth_wallet.htlc_leg.refund_ran_out_of_gas(receipt, *, gas_limit)[source]

Whether a mined refund failed for lack of gas: status == 0 with at least 63/64 of the limit used. An out-of-gas in the refund’s own frame uses the whole limit. When the starved part is the payout call to the refundee (EIP-150 forwards at most 63/64), the outer frame then reverts SendFailed with up to 1/64 left: measured on anvil, 99,432 of 100,000 (the PR 4a review). A plain contract revert (AlreadySettled, NotYetExpired) uses far less. eth_call cannot make this distinction: it reports an out-of-gas as an untyped error, read as transient.

A refundee that burns everything it is forwarded and then reverts reads the same as one that was starved; more gas may not help it, and one re-send is the most this drives.

gas_limit is the limit the transaction was SIGNED with, read from the transaction, not REFUND_GAS_LIMIT: a short-funded sender’s refund is signed lower.

Reads gasUsed from the receipt it is given, which a multi-source receipt read does not corroborate. That is enough for what this decides, one re-send (#850 PR 5b): a wrong answer costs one refund’s gas, never funds.

Parameters:
Return type:

bool