refactor(swapper): executable quotes + rate/quote context split - #12502
Merged
Conversation
…rId, narrow swap) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… tracking metadata
…nfirmFooter Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e ButterSwap type
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: TxBuildDataunion (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 carrytransactionData(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:
getXTradeContextcore (provider fetch + derived amounts/protocolFees, zeroquoteOrRatebranching),getXStepDatareturningResult<…>(quote arm returnstransactionData; rate arm is fee-only).This removes scattered
quoteOrRate === 'quote'checks,TradeQuoteStep | TradeRateStepcasts, and per-swapper step-field sprawl.Metadata split. The old per-swapper
*Specific/*Metadatastep fields collapse into two typed channels:transactionData(build-time) andswapperMetadata(status/tracking, a nested tagged union read viagetSwapMetadata).Shared execution utils.
evm-utils/utxo-utils/solana-utils/cosmossdk-utilsreplace hand-rolled per-swapper execution, wired as bare readers off the storedtransactionData.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 fromaccountNumber.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-Risklabel; requires 2 approvals + a full swap regression pass.Wire-format note for public-api consumers:
utxo_psbt/utxo_depositschemas removed and the cosmos extract shape changed (judged acceptable — only Relay-utxo functioned before).Testing
Engineering
pnpm type-checkgreen;packages/swapper(tsc -p tsconfig.esm.json) at 0 errors.MsgDepositsell, 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.Known open items before merge (draft):
CowSwapper.test.ts > getUnsignedEvmMessage— stale fixture from the CowSwap executable-quote migration (runtime correct; test needs thetransactionData: { type: 'cowswap' }fixture)./ratesapprovalTarget +/quoteneedsApproval error, solana compute-limit baking,deadlineonTradeQuote.Operations
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