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 independent endpoints (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 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.

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

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

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, 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 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 (a LOWER bound — see the comment at the check) (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)

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

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:

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.create_address(sender, nonce)[source]

The CREATE address for sender at noncekeccak(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 (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