Skip to content

Validator: reject dest-tx replays via init-time tip snapshot#346

Open
LandynDev wants to merge 2 commits into
testfrom
fix/dest-tx-recency-check
Open

Validator: reject dest-tx replays via init-time tip snapshot#346
LandynDev wants to merge 2 commits into
testfrom
fix/dest-tx-recency-check

Conversation

@LandynDev
Copy link
Copy Markdown
Collaborator

Summary

The contract's used_from_tx mapping (lib.rs:95, checked at 841) blocks reuse of a swap's source tx hash but has no used_to_tx analog for the miner's dest tx hash. Combined with the validator's verify_transaction only checking existence/recipient/amount/sender (chain_providers/base.py:71), a miner whose previous fulfillment tx happens to pay the same user the same amount can recycle that on-chain tx as fake fulfillment for a new swap — collecting the user's escrowed TAO without sending anything.

This PR closes that gap validator-side without a contract upgrade.

Mechanic

  • SwapVerifier.observe_initiation(swap): on first sighting of a non-TAO-dest swap, snapshot the dest chain's current tip (minus a 1-block grace so a tx mined in the same block we sampled still passes). Idempotent; fails open with a one-time log_on_change warning if the tip RPC errors.
  • SwapVerifier.is_dest_tx_fresh(swap, dest_info): rejects any dest tx whose block is < swap.initiated_block (TAO-dest) or < dest_tip_at_init[swap.id] (other chains).
  • forward.py: calls observe_initiation + prune_to_active once per forward step. Snapshots are bounded by the active swap set.

Chain-agnostic — adding a new dest chain only needs get_current_block_height on its provider. Same pattern would catch ETH/SOL/whatever dest-side replays the moment the chain is wired in.

Why validator-side, not contract-side

A used_to_tx mapping on the contract (parallel to the existing used_from_tx) would be a strictly stronger defense, but ships only on the next contract deployment. The validator check covers the gap now; the contract mirror can land in a future deploy.

Test plan

  • pytest -q — full suite (484 passed, 16 new in tests/test_chain_verification.py).
  • ruff format + ruff check clean.
  • E2E suite 02 (happy path) — verify legitimate fulfillments still confirm under the new check.
  • E2E negative case — manually craft a dest-tx hash from a prior swap and confirm the validator rejects the replay confirm.

Mirrors the contract's used_from_tx source-side defense on the dest side
without a contract upgrade: on first sighting of a non-TAO-dest swap,
SwapVerifier snapshots the dest chain's tip. Later, a miner-supplied
dest tx whose block predates the snapshot is rejected as a replay.

TAO-dest uses swap.initiated_block directly (already on the swap struct).
Chain-agnostic — adding a new dest chain only needs get_current_block_height
on its provider. Fails open with a one-time warning if the tip RPC errors,
preserving liveness over defense on a transient backend issue.
@xiao-xiao-mao xiao-xiao-mao Bot added the enhancement New feature or request label May 19, 2026
@anderdc anderdc changed the title validator: reject dest-tx replays via init-time tip snapshot Validator: reject dest-tx replays via init-time tip snapshot May 19, 2026
Tighter class docstring, single-line ivar comment, collapsed dual log
on snapshot failure, dropped redundant fail-open log at verify time
(observe_initiation already logged), shorter base.py + forward.py
comments. No behavior change — same tests, same defense.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant