Skip to content

refactor(swapper): executable quotes + rate/quote context split - #12502

Merged
kaladinlight merged 146 commits into
developfrom
refactor/swapper-executable-quotes
Jul 31, 2026
Merged

refactor(swapper): executable quotes + rate/quote context split#12502
kaladinlight merged 146 commits into
developfrom
refactor/swapper-executable-quotes

Conversation

@kaladinlight

Copy link
Copy Markdown
Member

Description

Restructures the swapper package so quotes are executable artifacts and rates are display-only best-effort, and unifies how every swapper produces them.

Executable quotes (the headline). Quotes now carry their build-time transaction data on the step as a discriminated transactionData: TxBuildData union (evm | utxo | solana | cosmossdk_msg_send | cosmossdk_msg_deposit | tron | ton | cowswap), built once at quote time. Execution reads that stored data and only fetches fresh gas price at broadcast — no more re-estimating or rebuilding the transaction at execution. Prices are dynamic; gas/compute limits are static and baked at quote time. Rates never carry transactionData (banned at the type level) and degrade gracefully instead of hard-failing.

Rate/quote context split (all swappers). Every swapper (Across, Arbitrum, Avnu, Bebop, BobGateway, ButterSwap, Cetus, Chainflip, CowSwap, Debridge, NearIntents, Portals, Relay, Stonfi, Sunio, Zrx, Thorchain/Maya) is split into:

  • a shared getXTradeContext core (provider fetch + derived amounts/protocolFees, zero quoteOrRate branching),
  • thin rate/quote wrappers that assemble the typed trade,
  • a discriminated, no-throw getXStepData returning Result<…> (quote arm returns transactionData; rate arm is fee-only).

This removes scattered quoteOrRate === 'quote' checks, TradeQuoteStep | TradeRateStep casts, and per-swapper step-field sprawl.

Metadata split. The old per-swapper *Specific/*Metadata step fields collapse into two typed channels: transactionData (build-time) and swapperMetadata (status/tracking, a nested tagged union read via getSwapMetadata).

Shared execution utils. evm-utils / utxo-utils / solana-utils / cosmossdk-utils replace hand-rolled per-swapper execution, wired as bare readers off the stored transactionData.

Also included: dead trade-quote fields/exports removed; and honest rate input/step types — a wallet-connected rate now carries its real sendAddress/accountNumber (previously typed as always-walletless), which fixes approval-on-rate (the approval step requested before the executable quote could not derive the signing account). Executability is now a trade-level property (isExecutableTradeQuote / quoteOrRate), decoupled from accountNumber.

Issue (if applicable)

closes #

Risk

High. This modifies how on-chain transactions are constructed for every swapper and namespace (EVM/UTXO/Cosmos SDK/Solana/Tron/TON/etc.). Adding the High-Risk label; requires 2 approvals + a full swap regression pass.

Wire-format note for public-api consumers: utxo_psbt/utxo_deposit schemas removed and the cosmos extract shape changed (judged acceptable — only Relay-utxo functioned before).

Testing

Engineering

  • pnpm type-check green; packages/swapper (tsc -p tsconfig.esm.json) at 0 errors.
  • Live QA — one real trade per migrated swapper, including: Chainflip BTC boost, Zrx permit2, Thorchain longtail both directions, a RUNE/TCY MsgDeposit sell, ButterSwap BTC, an ATOM send + CACAO deposit, and at least one un-migrated-namespace trade (tron/sui/near/ton/starknet) to confirm the legacy-field exec paths are preserved.
  • Approval-on-rate: connect a wallet, select a token sell that needs approval, confirm the approval fires from a rate before the executable quote.
  • Upgrade test: a pre-deploy in-flight swap still resolves after deploy.

Known open items before merge (draft):

  • CowSwapper.test.ts > getUnsignedEvmMessage — stale fixture from the CowSwap executable-quote migration (runtime correct; test needs the transactionData: { type: 'cowswap' } fixture).
  • Additive follow-ups (not regressions): remaining TxBuildData migrations (Bebop-solana serialized, tron unification, Chainflip/Butter utxo variants), public-api chain extractors + /rates approvalTarget + /quote needsApproval error, solana compute-limit baking, deadline on TradeQuote.

Operations

  • 🏁 My feature is behind a flag and doesn't require operations testing (yet)

This is not flag-gated — it is a structural refactor of live swap paths. Functional QA should exercise a real swap on each supported swapper/namespace and watch for any regression vs develop; the refactor is intended to be behavior-preserving apart from documented, intentional deltas (mostly bug fixes).

Screenshots (if applicable)

N/A — no UI changes.

🤖 Generated with Claude Code

kaladinlight and others added 30 commits July 1, 2026 12:07
…rId, narrow swap)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nfirmFooter

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Compose SwapperTrackingMetadata from per-swapper tracking types (relocated
from the central inline union into each swapper's types.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… union

Swap.metadata / StoredQuote.metadata are now the flat SwapperTrackingMetadata
discriminated union (common fields intersected with the per-swapper tracking
variant, narrow on metadata.swapper) instead of the nested SwapperSpecificMetadata
with a swapperMetadata sub-key. Removes the double-nested metadata keys and the
per-swapper flat duplicates.

- Add CommonTrackingMetadata; SwapperTrackingMetadata = common & (relay | debridge
  | bob | chainflip | nearIntents | none). Delete SwapperSpecificMetadata.
- Shared buildSwapperTrackingMetadata(step, common) builder in the swapper package,
  consumed by public-api getQuote + web swap creation/execution (DRY).
- Migrate all readers (relay/debridge/bob/chainflip/nearIntents checkTradeStatus,
  web chainflip/nearIntents reads + dynamic writers) to narrow on metadata.swapper.
- Debridge: emit evm build via transactionData (fixes its missing public-api
  executable payload) + derive isSameChainSwap from sell/buy chainId; delete its
  TradeQuoteStep build field.

Microservices swap-verification.service.ts follow-up (out of scope here): relay
reads metadata.relayTransactionMetadata.relayId -> metadata.relayId; chainflip
metadata.chainflipSwapId still resolves on the flat chainflip variant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Emit step.transactionData (evm|utxo) in the bob quote builder and read it in the
four build consumers, fixing bob's previously-missing public-api executable payload
(generic extract passthrough now covers it). Lift utxoTxBuildData to the shared
toTxBuildData util (re-exported from relay's for back-compat). bobSpecific stays on
the step as the orderId tracking source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
relayerTxHash / relayerExplorerTxLink (bridged/relayer swaps) and
streamingSwapMetadata (THORChain + Chainflip streaming) are not universal and are
discovered at runtime, so they're optional on CommonTrackingMetadata rather than
required. They stay orthogonal to the swapper discriminant (a Chainflip streaming
swap carries both chainflipSwapId and streamingSwapMetadata), so they can't live in
the per-swapper variants. Only stepIndex/quoteId are truly universal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the 'Tracking' qualifier throughout: SwapperMetadata (was
SwapperTrackingMetadata), CommonSwapperMetadata, the per-swapper variants
Relay/Debridge/Bob/Chainflip/NearIntentsMetadata, and buildSwapperMetadata (helper
+ file). It IS the swap's metadata, and it now carries identity (stepIndex/quoteId)
too, so 'tracking' was both noisy and inaccurate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…narrowing

checkTradeStatus is dispatched polymorphically (swappers[name].checkTradeStatus)
with the full SwapperMetadata union, and the metadata round-trips through
swap-service, so a runtime variant check is the right validation boundary. DRY the
throwing narrow into getSwapperMetadata(metadata, swapper), which validates + narrows
to Extract<SwapperMetadata, { swapper: S }>. Applied to relay/bob/chainflip (which
throw on the wrong variant); debridge (boolean) and nearIntents (graceful default)
keep their inline checks by design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce SwapperMetadataVariant (the discriminated swapper-specific union, sans the
common fields) and put it on TradeQuoteStep as `swapperMetadata`, mirroring
Swap.metadata (= CommonSwapperMetadata & SwapperMetadataVariant). Fold ArbitrumBridge
into the union as an arbitrumBridge variant. buildSwapperMetadata now prefers
step.swapperMetadata and falls back to deriving from legacy per-swapper fields, so
swappers can be migrated to emit it one at a time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ArbitrumBridge no longer intersects a bespoke { direction } onto TradeQuote/TradeRate.
It emits step.swapperMetadata = { swapper: 'arbitrumBridge', direction } like every
other swapper, so nothing sits outside the unified metadata union. Delete
ArbitrumBridgeTradeQuote/Rate; replace the isArbitrumBridgeTradeQuoteOrRate guard with
isArbitrumBridgeWithdrawal(quote) reading the step variant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…wapMetadata

SwapperMetadata is now the per-swapper variant union (lives on TradeQuoteStep and as
the shared piece of the swap's metadata); SwapMetadata = CommonSwapMetadata &
SwapperMetadata is the Swap's metadata; CommonSwapMetadata the common fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relay, Debridge, Bob, Chainflip and NearIntents now populate the shared
step.swapperMetadata variant field directly in their quote builders (ArbitrumBridge
already did). buildSwapperMetadata's legacy per-field fallback is now dead and can be
removed next, along with the scattered per-swapper tracking fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…by swapperMetadata

Every swapper now emits step.swapperMetadata, so buildSwapperMetadata just layers the
common fields onto it (legacy per-field derivation deleted). Delete the tracking data
that the variant now owns: the bobSpecific step field (build is on transactionData),
chainflipSpecific.chainflipSwapId (+ its 7 redundant build guards, covered by the
chainflipDepositAddress guard), and nearIntentsSpecific timeEstimate/deadline. Remaining
per-swapper step fields are build payloads (migrate via transactionData in Batch C/D).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kaladinlight and others added 26 commits July 28, 2026 13:27
Remove the legacy generic execution api surface (executeTrade/getUnsignedTx
and their arg types), isTronChainId, getThorStepData's write-only vault
return, and four zero-reference files (filterBuyAssetsBySellAssetId, cow
blacklist, sunio abi, test-data fees); unexport AsyncResultOf.

Normalize every swapper barrel to export its api + swapper def (creating
zrx's) so constants.ts imports one line per swapper.

Collapse redundant type aliases: GetEvmTradeQuoteInputBase renamed to
GetEvmTradeQuoteInput (the alias), ExecutableTradeStep folded into
TradeQuoteStep, CommonTradeInput inlined to CommonTradeQuoteInput.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Align solana with the evm model: quotes carry the static compute unit
limit (measured simulation x per-swapper margin, set via a shared
withComputeUnitLimit helper across all six solana swappers) and execution
fetches only the dynamic priority fee via a new lightweight
adapter.getPriorityFees - the exec-side strip/re-simulate machinery and
per-endpoint computeBudget options are removed.

The swap-widget now executes solana quotes as versioned transactions,
fetching the payload's address lookup tables and appending only the
dynamic priority fee, making api solana quotes executable as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rename the instruction-based solana variant to solana_instructions and add
solana_serialized_tx for sealed multi-signer RFQ txs (maker pre-signed,
blockhash pinned - co-sign the taker slot as-is, never rebuild). Bebop
solana quotes carry the variant in transactionData, the legacy
bebopSolanaSerializedTx step field is removed, and the public-api
serializes it - bebop solana quotes are now executable via the api.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Companion to the bebop solana migration: handle the renamed
solana_instructions discriminant and co-sign sealed RFQ txs as-is via a
new solana_serialized_tx branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… protocol docs

The swapper keeps the generic permit2 signatureRequired shape; the
extractor names the convention explicitly at the wire boundary and the
schema documents the consumer protocol (sign eip712 then append the
32-byte signature-length word + signature to data).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
swapper 18.0.0 and types 9.0.0 (breaking: TxBuildData discriminant renames,
TradeStatus/wire reshapes, removed legacy exports), chain-adapters 11.5.0
(additive getPriorityFees), swap-widget 0.6.0 (requires the new api wire).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tes architecture

Rewrite the implementation phase around the canonical context split
(helpers/context/step data/arm wrappers, StepDataArgs overloads, no-throw
Results, assertQuoteAddresses, scoped input aliases), replace the dead
transactionMetadata/[swapper]Specific patterns with TxBuildData variants
and the SwapperMetadata union, fix registration locations (SwapperName in
types.ts, spread record via barrels), add public-api/swap-widget
enablement steps and checkTradeStatus tracker-link guidance, and convert
examples.md into a canonical in-repo example map. Contract updated to
match, including the tsconfig.esm.json verify command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d priority fee

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…as-is

Across svm swap txs arrive partially signed (their server generates and signs
the CCTP messageSentEventData keypair) with a pinned blockhash - decompiling to
instructions and rebuilding dropped that signature, so every broadcast was
silently discarded at signature verification (broken on develop too). Quotes
now carry the sealed tx as solana_serialized_tx; the decompiled instructions
remain estimation-only for the display fee.

Execution mirrors the bebop message flow with a new sign-and-broadcast
callback: the wallet co-signs the taker slot preserving the provider
signature, and the adapter broadcasts. SolanaMessageToSign becomes a
per-swapper union so bebop's RFQ quoteId stays a bebop concept, and the
gasless fee display is scoped to bebop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… throwing

The pair-validation lookups revert for tokens that aren't gateway-registered
(native circle USDC has no l1Address()), and the throw escaped the Result flow
as a raw CALL_EXCEPTION on every rate poll. A failed lookup now maps to the
UnsupportedTradePair decline the guard already intended.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Swap actions only carry a swapId pointer - the swapperMetadata reshape wipe
left them dangling invisibly in storage, so retire them the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Left unset, CoW picks a price estimator per request and the resulting
feeAmount swings widely for the same pair and size - observed 260854 to
391879 gasAmount (13.3% to 21.5% of a $10 order) across four requests in
ten minutes. The expensive draws land on the verified tier, understating
the output badly enough that CoW loses best-rate comparisons it should
win, and the rate and quote arms can resolve against different estimators
and trip the quote-drop warning.

optimal is what CoW documents for order creation and keeps both arms on
the same estimator set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
deBridge rejects 0x...dead with COMPLIANCE_ADDRESS_BLOCKED, so every rate
fetched without a connected wallet failed - getTradeRate falls back to
this constant for both sender and recipient. Verified against
/dln/order/create-tx and /chain/transaction; the zero address is not a
substitute, it fails with INVALID_ADDRESS_REQUEST_ERROR.

Matches the placeholder already used by the Bebop, Portals and BobGateway
rate paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rates estimate the deposit with the connected wallet's send address (sized
with the raw thornode memo), falling back to measured per-asset safe limits
(50k native / 85k token) instead of a flat padded 100k. Quotes no longer
fall back on failed estimation - they return NetworkFeeEstimationFailed
like every other swapper until the approval-before-quote work lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gas limits

Deposits are plain transfers, so the flat padded 100k overstated natives
~4.8x and tokens ~1.5x - price 21k intrinsic for natives and a measured
65k ceiling for the supported token set instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ts step data

Aligns the last two step data builders on the typed stepData const pattern
so each branch is checked against its exact rate/quote shape instead of
the permissive union.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rate/quote context split hardcoded INTENTS recipient/refund types on
rates while still passing the receive address, which 1click rejects as an
invalid intents account (and silently excluded destination withdrawal
fees when it didn't). Wallet-connected rates price the real route again
via ORIGIN_CHAIN/DESTINATION_CHAIN with the user's addresses.

Also pass the send address through to rate step data so ton/sui/starknet/
near fees estimate instead of returning undefined (displayed as $0) and
monad estimates from a funded sender instead of reverting off the deposit
address.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The portals api no longer lists the gnosis network and rejects its
tokens, so remove it from the supported chain ids and the router map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Portals now serves sonic and plasma, both routed live against the same
router deployment as base. Also add the hyperevm router address - the
chain was listed as supported but every same-chain trade failed the
missing-router guard in getPortalsTradeContext.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tenderly sim timeout matched the UI's 10s bulk rate budget, so a
stalled simulation consumed the whole budget and surfaced the swapper as
timed out before the gas fallbacks could run - observed on avalanche for
portals and relay simultaneously. Rate sims now time out at 3s and
degrade to each swapper's fallback pricing instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unapproved evm token sells previously validated the network fee alone, so
a wallet without gas for the approval sailed through rate validation and
died at the approval step with a raw node error - observed on an across
usdc sell where the pre-approval provider fee is 0 and the check passed
vacuously.

Validation now reads the live allowance (shared query key with the
approval step's watcher) and prices the actual approve() when short,
summing it with the network fee at both rate and quote time. Rate time is
the gate that matters - the executable quote is only fetched after
approval completes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g gasEstimatedTarget

Butter's gasEstimatedTarget lands 24-60% under actual need on ethereum,
so carrying it verbatim as the gas limit made the inner dex call run out
of gas and the router revert SWAP_FAIL() in flight. Leave gasLimit unset
so the fee helper estimates on chain and sets a 1.2x buffered limit,
throwing at execution if estimation reverts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…in gas estimation

Butterswap quote tests now expect the buffered on chain estimate instead
of the carried provider target, and the shared thorchain mock adapter
gains the average fee tier the quote estimation path reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kaladinlight
kaladinlight marked this pull request as ready for review July 31, 2026 17:06
@kaladinlight
kaladinlight requested a review from a team as a code owner July 31, 2026 17:06
@kaladinlight
kaladinlight merged commit 6755469 into develop Jul 31, 2026
4 checks passed
@kaladinlight
kaladinlight deleted the refactor/swapper-executable-quotes branch July 31, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant