Skip to content

feat(trade): opt-in standard execution route for swaps - #730

Open
kome12 wants to merge 9 commits into
mainfrom
trade-execute-standard-route
Open

kome12 wants to merge 9 commits into
mainfrom
trade-execute-standard-route

Conversation

@kome12

@kome12 kome12 commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

What

Adds NANSEN_TRADING_EXECUTION_ROUTE=standard, which broadcasts a swap through the execution endpoint that tracks the on-chain outcome server-side. The default is unchanged — without the env var, behaviour is byte-identical to today.

On the standard route the command:

  • translates the request body (VM-type chain + chain id, explicit source, wallet address, cross-chain fields) and normalises the response back to the legacy result shape, so the call sites and the output block are untouched
  • surfaces the aggregator's own revertReason alongside the backend's typed code — the route returns a static message, so reading message alone dropped the only informative field
  • treats 409 DUPLICATE_EXECUTION as fatal rather than retryable: the route holds a per-quote single-flight lock, so a 409 means the first attempt is in flight or already produced a hash
  • stops at a failed swap instead of signing the next candidate. Every candidate in one quote response shares a single backend quote id, and the backend keeps that id claimed once a transaction hash exists — so the old "try the next quote" fallback could only ever collect a 409, after signing a second transaction, which on the Privy and WalletConnect signers means a real user prompt
  • warns, up front, that the receipt poll after a node-rejected broadcast can take three minutes and may time out
  • no longer prints Broadcaster: (this route does not report one)

NANSEN_TRADING_EXECUTION_ROUTE is validated, not defaulted: a typo like Standard or a stray space is rejected. Silently falling back to legacy is the worst failure mode during a staged rollout — you believe you exercised the new route and you did not. It resolves once, before any signing.

Gasless stays on the legacy route deliberately: the standard route has no gasless envelope, so a signed Relay authorization sent there would lose its framing and submit as a plain swap.

Testing

Unit tests cover the body translation, response normalisation, failure-reason precedence, the 409 path, route validation, and the terminal-on-failure behaviour. They are mutation-verified — each guard was broken in turn to confirm the covering test fails.

Verified end to end against the live endpoint with real funds, with the route forced on. All three swap groups passed, and each leg was confirmed on-chain rather than from the assertions alone:

Group Result
Base ETH ↔ USDC pass
Solana SOL ↔ USDC pass
Cross-chain Base ↔ Solana pass

Endpoint selection was confirmed directly rather than inferred, by running trade execute behind a request-logging proxy: with the env var set the CLI posts to the standard execution path with the expected body; with it unset it posts to the legacy path. Request and response shapes were checked against the trading service schemas rather than guessed.

Also in this PR

The last commit is an unrelated, pre-existing fix. All three e2e groups gated on wallet list printing an EVM: / Solana: summary, but that command moved to the JSON envelope in #584 (extended in #727), so the preconditions could never pass and the sequential groups never ran. It now parses the envelope. Happy to split this out if you'd rather — it is test-only, but without it the route cannot be exercised e2e at all.

🤖 Generated with Claude Code

kome12 and others added 3 commits October 5, 2026 06:09
Add NANSEN_TRADING_EXECUTION_ROUTE=standard to broadcast a swap through
the execution endpoint that tracks the on-chain outcome server-side.
Defaults to legacy, so behaviour is unchanged unless the route is opted in.

On the standard route:
- translate the request body (VM-type chain + chain id, explicit source,
  wallet address, cross-chain fields) and normalise the response back to
  the legacy result shape so the call sites and output block are unchanged
- classify a 409 duplicate submission as a fatal broadcast failure: mark
  the quote spent and abort rather than broadcast the next candidate on top
  of a possibly-live swap
- treat a node-rejected soft-fail as spent (warn, do not re-broadcast)
- send a stable per-invocation attempt id on every POST, including retries
- keep gasless on the legacy route (no gasless envelope on the new route)
- omit the Broadcaster output line, which the new route does not report

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The standard execution route holds a per-quote single-flight lock and
deliberately keeps it after any attempt that produced a transaction hash.
Every candidate quote in one response shares a single backend quote id, so
the existing "try the next quote" fallback could only ever collect a 409 —
after signing a second transaction, which on the Privy and WalletConnect
signers means a real user prompt. Stop at a failed swap instead, on both
the 200-with-hash shape and the on-chain receipt revert, and report the
outcome that actually happened rather than a duplicate-submission error.

Also:

- Surface the aggregator's own revert reason. The route returns a typed
  code with a STATIC message and puts the real cause in revertReason, so
  reading message alone dropped the only informative field.
- Validate NANSEN_TRADING_EXECUTION_ROUTE instead of defaulting. A typo
  ('Standard', a stray space) silently running the legacy route is the
  worst failure mode during a staged rollout: you believe you exercised
  the new route and you did not. Resolved once, before any signing.
- Say up front that the receipt poll after a rejected broadcast can take
  three minutes and may time out. broadcastSucceeded is set by both the
  EVM and Solana executors, so on EVM the "may still propagate, keep
  going" warning was otherwise buried behind a silent wait and a scarier
  looking error.

Request and response shapes verified against the trading service schemas
rather than inferred. Tests are mutation-verified: each guard was broken
in turn to confirm the covering test fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three swap e2e groups gated on `wallet list` printing an `EVM:` /
`Solana:` summary, but that command reports data and so prints the
standard JSON envelope on stdout in a terminal and a pipe alike (#584,
extended to `wallet show` and redacted `wallet export` in #727). The
preconditions could therefore never pass, and because each group is
sequential, the whole file was unrunnable.

Parse the envelope through one `walletAddresses()` helper instead of
grepping prose, and take the Solana address from the parsed wallet rather
than a base58 regex over human output.

Pre-existing on main; unrelated to the execution route, but it blocks any
e2e run of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nansen-pr-reviewer

nansen-pr-reviewer Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

nansen-pr-reviewer summary

The change adds an opt-in standard execution route with fail-closed broadcast handling, quote locking, WalletConnect safeguards, and chain-name normalization. The new behavior is extensively covered by mocked unit tests, and the previously reported case-sensitive chain lookup issue is fixed. No correctness or security issue was identified in the reviewed diff.

Deterministic check: success — No issues found

Risk: 4/5 (High) — raised by: complexity: 1192 added lines


Token usage: 105,692 input, 1,957 output, 21,806 cache read | Usage Guide

Cooldown: for the next 10 minutes (counting from when this review finished), new pushes to this PR will not trigger another review — the next push after the window expires will. Need a fresh review sooner? Comment @nansen-pr-reviewer[bot] re-review.

Comment thread src/__tests__/trading.test.js Fixed
Comment thread src/__tests__/trading.test.js Fixed
@kome12 kome12 self-assigned this Oct 6, 2026
A WalletConnect wallet that broadcasts the swap itself hands back a tx
hash instead of signed bytes, so the swap never reaches the standard
execution endpoint. That bypass is unavoidable — there is nothing to
POST — but it left WalletConnect as the one signer where selecting the
standard route changed nothing at all: a landed revert still fell
through to the next candidate, which is a second real swap, approved in
the wallet, against a quote already recorded as spent.

Hoist the route decision above the signer branches so the WalletConnect
path can see it, say out loud when the selected route was bypassed, and
apply the same "a landed revert ends the command" rule there.

Also tighten the two new privy.io URL checks in the standard-route
Solana test to a full-prefix host match, which is what the substring
form was flagged for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/trading.js
Comment thread src/trading.js
… aggregator id as the lock key

Two gaps left by the previous round, both on paths where the standard
route's "stop at a failed swap" contract did not actually hold.

A WalletConnect send failure is ambiguous by construction: wcExec drops
the child's exit code and kill signal, so a user rejection (nothing was
broadcast) and a send-transaction timeout (the wallet may have accepted
AND broadcast before the CLI gave up) arrive at the catch as the same
bare Error. The wallet broadcasts for us on this path, so no backend
single-flight lock stands between the next candidate and a second real
swap on top of a possibly-live one -- and it is a second approval prompt
either way. The landed-revert case already ends the command here; the
ambiguous case, which is strictly less certain, fell through to the next
candidate. Apply the same rule. The quote was already unusable (
swapHandedOff keeps the claim), so only the in-process fallthrough
needed closing.

quoteId is the key the backend holds its per-quote single-flight lock
on, and the fallback feeding it was a per-quote id minted by the
AGGREGATOR in its own namespace -- LiFi returns '<uuid>:<index>', which
is not the backend id in the same response. Sending that would present a
value the lock cannot match: the request would look idempotency-
protected and would not be. Keep the fallback on the legacy route, where
it only costs BI correlation, and send the backend id or nothing on the
standard route. Every live /quote response carries one, so the absent
case is shape drift rather than routine: say the lock does not apply
instead of failing the swap, since the local claim and the executed
marker already stop a repeat run.

Tests are mutation-verified: each guard was broken in turn to confirm
the covering test fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/trading.js Outdated
The standard route keys its per-quote single-flight lock on quoteId, so
a submission without one claims duplicate protection and has none. The
previous commit warned and broadcast anyway, reasoning that the local
claim already stops a repeat run. That was the wrong trade: the local
claim is a file under the quotes dir, so it is per-machine and does not
replace a server-side lock, and warn-and-proceed contradicts the
principle this route is built on -- executionRoute() refuses an
unrecognised value for exactly the same reason. Degrade loudly or not at
all.

Every live /quote response carries response.metadata.quoteId (confirmed
across Base, Solana and cross-chain quotes), so reaching this is shape
drift or a hand-edited quote file, never a routine swap. The cost of
stopping is a re-quote; the cost of proceeding is an unprotected
broadcast.

Checked next to the route resolution rather than at the broadcast call,
so it refuses before anything is signed instead of being swallowed by
the candidate loop and resurfacing as ALL_QUOTES_FAILED. That also makes
the standard route's branch at the broadcast site unreachable, so the
per-quote fallback is written plainly as the legacy-only path it now is
rather than kept as a guard no test can distinguish.

Test fixtures for this route now carry a backend quote id, matching what
a live response always returns; the drift case is built explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/trading.js
Comment thread src/trading.js
…laim when nothing was broadcast

Two more shapes the standard route handled wrongly, both reachable only
because the test harness autofilled a transaction hash onto every
success response and so never built them.

A success body with no txHash was reported as a completed swap. There is
nothing to print, nothing to poll and nothing the user can check on an
explorer. EVM papered over it by deriving its own hash from the signed
bytes; Solana has no equivalent step on this path, so it printed a
successful trade with an undefined hash and an explorer link to match.
The request WAS accepted, so this is ambiguous rather than failed:
reject it in the response-translation layer, next to the 409 rule, as a
fail-closed BROADCAST_FAILED that marks the quote spent and aborts.

Separately, an explicit "failed, no hash" response left the quote
claimed. swapHandedOff is set before the POST, so when every candidate
came back that way the finalizer kept <quote>.executing.json and the
next execute was refused with "claimed by another execution" -- for a
quote that was never broadcast and is still good. The candidate fallback
already trusts that answer enough to go sign another transaction against
this quote, so it should trust it enough to hand the claim back. It does
now, guarded by a sticky flag so a clean answer from one candidate
cannot speak for an earlier one that threw mid-POST without ever saying
what it did.

The harness now takes noHash to build these shapes, and all three new
guards are mutation-verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/trading.js Outdated
…ection

executeTransaction has already classified every ambiguous outcome by the
time it reaches the generic !res.ok branch: a network error, a truncated
body and ANY 5xx are BROADCAST_FAILED, and a standard-route 409 is
carved out. What is left is a parseable 4xx error body, which that
branch's sibling already calls what it is -- "a definitive edge/backend
rejection, so it stays nonfatal and leaves the quote reusable".

It did not leave the quote reusable. swapHandedOff is set before the
POST, so the finalizer kept the claimed quote file and the next execute
was refused with "claimed by another execution" for a quote the backend
had explicitly rejected and never broadcast. The classification was
right and the claim silently disagreed with it.

Tag both definitive branches with broadcastRuledOut and hand the claim
back when one is raised, still subject to the sticky ambiguity flag so a
later candidate's clean rejection cannot speak for an earlier attempt
that threw without saying what it did.

That sticky flag now has a real test. The previous one asserted it using
a 400 as the ambiguous candidate, which this commit makes definitive;
the surviving ambiguous-and-continuing path is a WalletConnect send that
threw on the legacy route, so the test drives that instead and lets the
next candidate fall through to the shared POST.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/trading.js
…he standard body

The quote command stores the chain exactly as the user typed it, and
only resolveChain() lowercases on the way through. Every other lookup is
a direct index into a lowercase-keyed map, so a capitalised spelling
broke in two places.

In the standard-route body, an uppercase destination silently dropped
toChainId and produced a misclassified cross-chain request -- no error,
just a wrong request. That is the one that matters: it is corruption
rather than a failure.

An uppercase source chain does not reach that code at all. It fails
earlier and pre-existing, at the RPC registry, so trade quote --chain
Base would quote fine and then never execute: "No RPC URL configured for
chain: Base". That is wider than this route, so the chain is normalised
once where the quote is loaded, which fixes both routes and quotes
already on disk.

Found by writing the regression test for the first case and watching it
fail on the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kome12

kome12 commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

@nansen-pr-reviewer[bot] re-review

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.

2 participants