Skip to content

feat(hindsight): decode Relay swaps from solver calldata - #388

Open
kayibal wants to merge 5 commits into
mainfrom
feat/hindsight-calldata-decoding
Open

feat(hindsight): decode Relay swaps from solver calldata#388
kayibal wants to merge 5 commits into
mainfrom
feat/hindsight-calldata-decoding

Conversation

@kayibal

@kayibal kayibal commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a calldata-primary decoder for Relay swaps, ahead of the existing netting decoder:

  • SwapIntent: a trader's swap terms (token_in/token_out/amount_in/min_amount_out, plus an optional declared quote) recovered from the settling solver frame's own calldata — Fly's packed LibRouter layout, KyberSwap's ABI-decoded swap params plus its clientData quote, and ParaSwap's hint-located word triple. Replaces the narrower embedded_quote/SolverQuote mechanism (only read a quote, needed netting to succeed first). Also restores zeroex.rs: 0x's Settler wraps its execute(AllowedSlippage, actions, zid) entry in an AllowanceHolder.exec(operator, token, amount, target, data) call — a live 61-transaction Base sample (settled and reverted) is 100% AllowanceHolder-wrapped, so swap_intent reads token_in/amount_in from the wrapper and token_out/min_amount_out from AllowedSlippage, plus the declared quote from the POSITIVE_SLIPPAGE action when present. A hypothetical bare-Settler entry (never observed in the sample) has no reliable token_in source, so swap_intent declines rather than guesses for that shape.
  • RelayCalldata: a TradeDecoder registered ahead of RelayNetting. It reads token_in/token_out/amount_in from the intent and recovers the settled amount_out as the gross amount of token_out received by the output recipient the calldata declares (SolverKnowledge::output_recipient, implemented for Fly, KyberSwap, and 0x) — the one field calldata can never carry. Guards: the recovered output must clear the intent's on-chain floor, and any declared quote must sit within plausible_quote's band of it; either failure falls through to RelayNetting. Neither amount needs a fee adjustment: Relay pays its input-side fee to the collector before forwarding into the solver call (so amount_in is already post-fee), and its output-side fee is taken after delivery (so the recipient's receipt is already gross).
  • RelayNetting: stays registered as the fallback for transactions with no solver frame at all, or where RelayCalldata's floor/plausibility guards reject a bad decode — first-wins chain unchanged. 0x, the dominant Relay solver on Base (50-90% of relay flow depending on window), now flows through RelayCalldata via zeroex.rs's output_recipient rather than falling back to netting.

Empirics (see .claude/plans/calldata-first-decoding.md): on a 315-transaction Base sample, coverage rises from 60.0% (netting alone) to 91.4% (calldata-first union), with zero divergences across the 165 trades both paths could decode.

Test plan

  • cargo check --workspace --all-features
  • cargo clippy --locked --workspace --all-targets --all-features -- -D warnings
  • cargo nextest run -p hindsight (274 passed, 1 skipped)
  • cargo +nightly fmt --check
  • RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p hindsight -p fynd-core -p fynd-rpc-types -p fynd-rpc -p fynd-client

Pre-commit hook (check.sh) was not used for the commits on this branch: its cargo nextest run --workspace --bin fynd step SIGABRTs on this machine on an unrelated fynd-core test (sim_guard::tests::test_get_amount_out_guarded_converts_panic_to_error, a conda/cc PATH issue breaking Rust panic unwinding — environmental, not caused by this change). Verified manually instead; see the commit bodies.

🤖 Generated with Claude Code

Add SwapIntent (token_in/token_out/amount_in/min_amount_out, plus an
optional declared quote) recovered from the settling solver frame's own
calldata: Fly's packed LibRouter layout, KyberSwap's ABI-decoded swap
params plus its clientData quote, and ParaSwap's hint-located word
triple. DecodedTrade and RangeComparison carry the recovered intent's
three calldata-declared terms (min_amount_out, declared_quote,
quote_timestamp) as flattened fields, next to the netted amounts, which
stay authoritative for what settled. When a netting-based decoder's flow
disagrees with the intent on token_in/token_out/amount_in, that is
logged (tracing::warn!) as diagnostic signal rather than surfaced as a
new metric.

Replaces the narrower embedded_quote/SolverQuote mechanism, which only
read a solver's off-chain quote and required netting to succeed first.
zeroex.rs is removed (0x still matches via the address book; it has no
calldata to recover terms from).

Pre-commit hook not run: cargo nextest run --workspace --bin fynd SIGABRTs
on this machine on an unrelated fynd-core test (a conda/cc PATH issue
breaking Rust panic unwinding, tracked separately). Verified manually
instead: cargo check --workspace --all-features, cargo clippy --locked
--workspace --all-targets --all-features -- -D warnings, cargo nextest
run -p hindsight (260 passed), cargo +nightly fmt --check, and
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p hindsight -p fynd-core
-p fynd-rpc-types -p fynd-rpc -p fynd-client — all clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kayibal
kayibal force-pushed the feat/hindsight-calldata-decoding branch from 495a7c5 to cc743ea Compare July 31, 2026 13:44
kayibal and others added 2 commits July 31, 2026 15:29
Add RelayCalldata, a calldata-primary TradeDecoder registered ahead of
RelayNetting: it reads token_in/token_out/amount_in from the settling
solver frame's SwapIntent and recovers the settled amount_out as the
gross amount of token_out received by the output recipient the same
calldata declares (SolverKnowledge::output_recipient, implemented for
Fly and KyberSwap) -- the one field calldata can never carry. Guards
against a mis-attributed recipient receipt: the recovered output must
clear the intent's on-chain floor, and any declared quote must sit
within plausible_quote's band of it; either failure falls through to
RelayNetting. Neither amount needs a fee adjustment -- Relay pays its
input-side fee to the collector before forwarding into the solver call,
so the intent's amount_in is already post-fee, and its output-side fee
is taken after delivery, so the recipient's receipt is already gross.

DecodeContext gains the trace root (CallFrame) so a decoder can walk it
directly, and TransferLedger gains received_by_address for the
recipient-anchored query.

RelayNetting stays registered as the fallback for solvers RelayCalldata
cannot parse (0x Settler) and transactions with no solver frame at all
-- first-wins chain unchanged. See .claude/plans/calldata-first-decoding.md
for the empirics behind the ordering.

Pre-commit hook not run: cargo nextest run --workspace --bin fynd SIGABRTs
on this machine on an unrelated fynd-core test (a conda/cc PATH issue
breaking Rust panic unwinding, tracked separately). Verified manually
instead: cargo check --workspace --all-features, cargo clippy --locked
--workspace --all-targets --all-features -- -D warnings, cargo nextest
run -p hindsight (274 passed), cargo +nightly fmt --check, and
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p hindsight -p fynd-core
-p fynd-rpc-types -p fynd-rpc -p fynd-client -- all clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Update the decoder table, the venues/solvers module docs, and the
architecture diagram for RelayCalldata + RelayNetting (the latter
staying registered as the fallback for solvers RelayCalldata cannot
parse) and the output_recipient capability. Describes DecodedTrade's
calldata-declared terms as the flattened min_amount_out / declared_quote
/ quote_timestamp fields they are, not a nested SwapIntent.

Pre-commit hook not run (see prior commits: cargo nextest run
--workspace --bin fynd SIGABRTs on an unrelated fynd-core test on this
machine). Docs-only change; no code checks apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kayibal
kayibal force-pushed the feat/hindsight-calldata-decoding branch from cc743ea to 0fb420d Compare July 31, 2026 14:31
kayibal and others added 2 commits August 3, 2026 18:21
0x is the dominant Relay solver on Base (50-90% of relay flow depending
on window, 0x-solved), and every one of its trades previously fell
through to RelayNetting (settled) or was counted-but-not-judged (a
reverted trade with no terms parser). solvers/zeroex.rs recovers
token_in/token_out/amount_in/min_amount_out (and, when declared, 0x's
own off-chain quote) from the settling frame's own calldata, the same
way fly.rs and kyberswap.rs already do -- flipping 0x-solved relay
trades from the netting fallback to the calldata-primary path
(RelayCalldata), and giving reverted 0x trades a real SwapIntent to
judge against min_amount_out for the first time.

Verified against a live 61-transaction Base sample (settled and
reverted, drawn from a running monitor instance) and cross-checked
against 0x-settler's published source (github.com/0xProject/0x-settler):
every transaction sampled enters through AllowanceHolder.exec(operator,
token, amount, target, data) before reaching Settler's own
execute(AllowedSlippage, actions, zid) -- 100% AllowanceHolder-wrapped,
0% bare Settler entry. exec's own token/amount parameters are the input
side; execute's AllowedSlippage struct (recipient, buyToken,
minAmountOut) is the output side and the payout address RelayCalldata
already anchors on for Fly/KyberSwap. For two settled trades in the
sample, the decoded token/amount matched the netted token_in/amount_in
hindsight already recorded exactly, and buyToken matched token_out
exactly -- as good a validation as an independent ground truth gets.
The actions array's POSITIVE_SLIPPAGE(recipient, token, expectedAmount,
maxBps), when present, restores the declared-quote capability the
now-deleted zeroex.rs (dead after the SwapIntent merge) used to scavenge
a different way; every selector used (exec 0x2213bc0b, execute
0x1fff991f, POSITIVE_SLIPPAGE 0x34ee90ca) is computed from its
signature and asserted against in a test.

A bare Settler entry never occurred in the sample and has no calldata
field that reliably carries token_in/amount_in (Settler's actions array
is heterogeneous per liquidity source, so scanning it for an input
token would be a guess); swap_intent declines for that shape rather
than guessing, while output_recipient -- which only needs
AllowedSlippage, present either way -- still resolves. minAmountOut is
passed through as-is including a legitimate zero (Settler's per-action
slippage checks can leave the top-level floor unset); the existing
fillable/margin judgment already treats a zero floor sanely (trivially
fillable, no margin to compute), so no special-casing was added.

Manual verification (pre-commit hook not run -- cargo nextest run
--workspace SIGABRTs on an unrelated fynd-core sim_guard test on this
machine, a known conda/cc PATH issue breaking panic unwinding):
- cargo check --workspace --all-features: pass
- cargo clippy --locked --workspace --all-targets --all-features
  -- -D warnings: pass
- cargo nextest run -p hindsight --all-features: 284 passed, 1 skipped
- cargo +nightly fmt --all -- --check: pass
- RUSTDOCFLAGS="-D warnings" cargo doc for hindsight, fynd-core,
  fynd-rpc-types, fynd-rpc, fynd-client: pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add zeroex.rs to the solvers/ row alongside fly.rs and kyberswap.rs.

Docs-only change; no code checks apply. Pre-commit hook not run (see
prior commit: cargo nextest run --workspace SIGABRTs on an unrelated
fynd-core test on this machine).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant