diff --git a/.claude/contracts/swapper-integration.md b/.claude/contracts/swapper-integration.md index c16f0360451..eff988df388 100644 --- a/.claude/contracts/swapper-integration.md +++ b/.claude/contracts/swapper-integration.md @@ -6,14 +6,14 @@ All integration points required when adding a new DEX aggregator, swapper, or br Every new swapper must be registered in all of the following locations: -1. **SwapperName enum** - `packages/swapper/src/constants.ts` +1. **SwapperName enum** - `packages/swapper/src/types.ts` - Add enum entry: `[SwapperName] = '[Display Name]'` 2. **Swappers record** - `packages/swapper/src/constants.ts` - - Register `{ swapper, swapperApi }` pair under `SwapperName.[SwapperName]` + - One barrel import per swapper; register `{ ...[swapperName]Swapper, ...[swapperName]Api }` under `SwapperName.[SwapperName]` 3. **Default slippage** - `packages/swapper/src/constants.ts` - - Add entry to `DEFAULT_SLIPPAGE_DECIMAL_PERCENTAGE_BY_SWAPPER` + - Add a case to `getDefaultSlippageDecimalPercentageForSwapper` if it differs from the default 4. **CSP headers** - `headers/csps/defi/swappers/[SwapperName].ts` - All external API domains in `connect-src` @@ -40,23 +40,25 @@ Every new swapper must be registered in all of the following locations: 9. **SwapperConfig type** - `packages/swapper/src/types.ts` - Add `VITE_[SWAPPER]_API_KEY` (and any other config fields) -10. **Export** - `packages/swapper/src/index.ts` - - Export `[swapperName]Api` and `[swapperName]Swapper` +10. **Barrel + export** - swapper `index.ts` exports `{ [swapperName]Api, [swapperName]Swapper }` at minimum; root `packages/swapper/src/index.ts` re-exports the swapper directory -### If deposit-to-address model (Chainflip, NEAR Intents, etc.): +11. **Canonical structure** - the swapper follows the context split (`utils/helpers.ts` pure, `utils/get[X]TradeContext.ts` shared core with zero quoteOrRate checks, `utils/get[X]StepData.ts` discriminated + overloaded + no-throw, thin `getTradeQuote`/`getTradeRate` wrappers returning `Trade[]`) with scoped `[X]Trade{Quote,Rate}Input` aliases cast at the endpoint boundary. Rubric: `.claude/skills/swapper-rate-quote-review/SKILL.md` -11. **TradeQuoteStep metadata** - `packages/swapper/src/types.ts` - - Add `[swapperName]Specific` field to `TradeQuoteStep` type - - Add to `SwapperSpecificMetadata` type +### If status/execution needs provider tracking data (deposit address, order/swap id): -12. **Metadata wiring** - TWO places, BOTH required: - - `src/components/MultiHopTrade/components/TradeConfirm/hooks/useTradeButtonProps.tsx` - Pass metadata from step to swap - - `src/lib/tradeExecution.ts` - Pass metadata from step to swap +12. **SwapperMetadata union** - `packages/swapper/src/types.ts` + - Add a `[Swapper]Metadata` member (`{ name: '[swapperName]', ... }`) to the `SwapperMetadata` union; set `step.swapperMetadata` at quote time; read via `getSwapMetadata(...)` in `checkTradeStatus`/execution. NO web-side wiring - `buildSwapMetadata` carries it automatically + +### Public API + swap widget (separate, deliberate decisions): + +13. **Public api enablement** - add to `ENABLED_SWAPPER_NAMES` (`packages/public-api/src/constants.ts`) only after verifying the quote's `transactionData` variant is serialized by `extractTransactionData.ts` + the zod schemas + +14. **Swap widget enablement** - the widget's restricted `SwapperName` enum (`packages/swap-widget/src/types/index.ts`) is its allowlist; add icon/color entries in `constants/swappers.ts` if enabling ## Testing Checklist ### Automated Checks (MUST pass) -- [ ] `pnpm run type-check` - All type checks pass +- [ ] `npx tsc --noEmit -p packages/swapper/tsconfig.esm.json` passes (the root `-p packages/swapper` solution config checks ZERO files - never trust it) - [ ] `pnpm run lint` - All lint checks pass - [ ] `pnpm run build:swapper` - Swapper package builds - [ ] No `any` types used @@ -98,7 +100,7 @@ These are the most frequent bugs in swapper integrations. Check each one proacti 5. **Native token marker** - Verify the marker address matches what the API expects (commonly `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE`). -6. **Gas estimation** - Take max of API estimate and node estimate, add 15% buffer. +6. **EVM gasLimit invariant** - every EVM quote's `transactionData.gasLimit` ends up set: route ALL EVM fee math through `getEvmNetworkFeeCryptoBaseUnit` (prices provider gas as-is, or estimates-and-sets the buffered limit in place). Quotes hard-fail on estimation failure; only rates fall back to the provider fee. 7. **Dummy address in executable quotes** - Block executable quotes when taker address is the dummy address used for rates. @@ -106,6 +108,10 @@ These are the most frequent bugs in swapper integrations. Check each one proacti 9. **Type safety** - Use `Address` and `Hex` types from viem, not bare strings. -10. **Error handling** - ALWAYS return `Result`, NEVER throw from quote/rate functions. +10. **Error handling** - ALWAYS return `Result`, NEVER throw from quote/rate/step-data/context functions. Estimation failures on the quote arm use `makeNetworkFeeEstimationFailedErr`; unbuildable provider payloads use `makeTradeStepBuildFailedErr`. + +11. **Rate steps carry the wallet's accountNumber** - propagate `input.accountNumber` (undefined only when walletless); never hardcode `accountNumber: undefined`. Rates never carry `transactionData`. + +12. **Quote addresses** - `assertQuoteAddresses(input)` before any provider request; rate-only address defaults must never leak into quotes. For detailed implementation patterns, see `.claude/skills/swapper-integration/SKILL.md`. diff --git a/.claude/skills/swapper-integration/SKILL.md b/.claude/skills/swapper-integration/SKILL.md index 6918622ffda..eb0e3e90a67 100644 --- a/.claude/skills/swapper-integration/SKILL.md +++ b/.claude/skills/swapper-integration/SKILL.md @@ -24,10 +24,11 @@ ShapeShift Web is a decentralized crypto exchange aggregator that supports multi **Core Architecture**: - **Location**: `packages/swapper/src/swappers/` - **Interfaces**: `Swapper` (execution) + `SwapperApi` (quotes/rates/status) -- **Types**: Strongly typed with chain-specific adaptations +- **Rate/quote split**: rates are display-only best effort; quotes are executable artifacts carrying `transactionData` (a `TxBuildData` variant) built at quote time. Execution and the public api consume the quote payload as-is — static data is set at quote time, only dynamic data (gas price, solana priority fee, nonce, blockhash) is fetched at execution. +- **Canonical shape**: every swapper follows the context split — pure `helpers.ts`, shared `getXTradeContext.ts`, discriminated `getXStepData.ts`, thin `getTradeQuote`/`getTradeRate` arm wrappers. `AcrossSwapper` is the spec in code form; the authoritative conventions rubric lives in `.claude/skills/swapper-rate-quote-review/SKILL.md` — read it alongside this skill. - **Feature Flags**: All swappers behind runtime flags for gradual rollout -**Your Role**: Research → Implement → Test → Document, following battle-tested patterns from 13+ existing swapper integrations. +**Your Role**: Research → Implement → Test → Document, following battle-tested patterns from 18 existing swapper integrations. --- @@ -140,81 +141,86 @@ AskUserQuestion({ #### Step 1: Identify Swapper Category -Based on API research, determine the swapper type: +Based on API research, determine the swapper type. Every category produces the same canonical +structure — the category only changes what the quote's `transactionData` variant is and how the +context/step data derive it. **EVM Direct Transaction** (Most Common): -- Characteristics: Single EVM chain, returns transaction data, user signs & broadcasts -- Examples: Bebop, 0x, Portals -- Key Files: `bebopTransactionMetadata`, `zrxTransactionMetadata`, `portalsTransactionMetadata` +- Characteristics: EVM chain(s), API returns transaction data, user signs & broadcasts +- Canonical examples: `ZrxSwapper`, `PortalsSwapper`, `BebopSwapper` (EVM arm), `DebridgeSwapper`, `AcrossSwapper` +- Quote carries: `transactionData: { type: 'evm', chainId, to, data, value, gasLimit }` — the + gasLimit is ALWAYS set (provider-supplied, or estimated-and-set by `getEvmNetworkFeeCryptoBaseUnit`) - **Choose this if**: API returns `{to, data, value, gas}` transaction object **Deposit-to-Address (Cross-Chain/Async)**: -- Characteristics: User sends to deposit address, swapper handles execution asynchronously -- Examples: Chainflip, NEAR Intents, THORChain -- Key Files: Uses `[swapper]Specific` metadata with `depositAddress` -- **Choose this if**: API returns deposit address and swap ID for tracking +- Characteristics: user sends a plain transfer to a provider deposit address; provider executes + asynchronously; status tracked by a provider-side id +- Canonical examples: `BobGatewaySwapper` (order resolved once up front), `ChainflipSwapper` + (deposit channel opened quote-side), `NearIntentsSwapper` +- Quote carries: a normal chain-namespace `transactionData` (the transfer we build) PLUS a + `swapperMetadata` union member holding the tracking id / deposit address +- **Choose this if**: API returns a deposit address and an id for tracking **Gasless Order-Based**: -- Characteristics: Sign message not transaction, relayer executes, no gas -- Examples: CowSwap -- Key Files: Uses `cowswapQuoteResponse`, custom `executeEvmMessage` -- **Choose this if**: Uses EIP-712 message signing + order submission +- Characteristics: sign an EIP-712 message (not a tx); order submitted to the provider; no broadcast +- Canonical example: `CowSwapper` — `transactionData: { type: 'cowswap', chainId, orderToSign }`, + `getUnsignedEvmMessage` is a thin reader, `executeEvmMessage` signs + POSTs the order +- **Choose this if**: uses EIP-712 message signing + order submission -**Solana-Only**: -- Characteristics: Solana transaction with instructions and ALTs -- Examples: Jupiter -- Key Files: `jupiterQuoteResponse`, `solanaTransactionMetadata` -- **Choose this if**: Solana ecosystem only +**Solana**: +- Instruction-based routes: `transactionData: { type: 'solana_instructions', instructions, + addressLookupTableAddresses }` with the static compute unit limit set at quote time via + `withComputeUnitLimit` (measured simulation × per-swapper margin); execution fetches only the + dynamic priority fee. Canonical: the solana arms of `AcrossSwapper`/`ButterSwap`/`RelaySwapper`. +- Sealed RFQ txs (maker pre-signed, blockhash pinned): `transactionData: + { type: 'solana_serialized_tx', serializedTx }` — co-sign as-is, never rebuild. Canonical: + `BebopSwapper` solana arm. -**Chain-Specific (Sui/Tron/etc.)**: -- Characteristics: Custom transaction format for specific blockchain -- Examples: Cetus (Sui) -- Key Files: Chain-specific adapters and transaction metadata -- **Choose this if**: Non-EVM, non-Solana blockchain with custom SDK +**Multi-Chain**: +- One swapper spanning namespaces: a single `switch (chainNamespace)` in step data with BOTH arms + inline per case. Canonical: `ButterSwap` (evm/utxo/solana/tron), `RelaySwapper`, `NearIntentsSwapper`. -#### Step 2: Study 2-3 Similar Swappers IN DEPTH +**Chain-Specific (Sui/Tron/Starknet/TON)**: +- Un-migrated namespaces: quotes are fee-only (no `transactionData`); execution re-derives from + `swapperMetadata` or provider re-fetch. Canonical: `CetusSwapper` (sui), `SunioSwapper` (tron — + the one migrated tron example), `AvnuSwapper` (starknet), `StonfiSwapper` (ton). New chain-specific + swappers still get the full context split (Cetus/Stonfi prove it applies without an executable payload). -**Read these files for your chosen swapper type**: +#### Step 2: Study the Canonical Architecture IN DEPTH -```bash -# For EVM Direct Transaction (e.g., Bebop): -packages/swapper/src/swappers/BebopSwapper/ -├── BebopSwapper.ts # Swapper interface (usually just executeEvmTransaction) -├── endpoints.ts # SwapperApi implementation -├── types.ts # API request/response types -├── getBebopTradeQuote/ -│ └── getBebopTradeQuote.ts # Quote logic (WITH fee estimation) -├── getBebopTradeRate/ -│ └── getBebopTradeRate.ts # Rate logic (withOUT wallet, may use dummy address) -└── utils/ - ├── constants.ts # Supported chains, native marker, defaults - ├── bebopService.ts # HTTP client with cache + API key injection - ├── fetchFromBebop.ts # API wrappers (fetchQuote, fetchPrice) - └── helpers/ - └── helpers.ts # Validation, rate calc, address helpers -``` +**Read the conventions rubric first**: `.claude/skills/swapper-rate-quote-review/SKILL.md` — it is +the authoritative spec for the structure below and its edge cases. + +**Then read Across — the reference implementation**: -**Read these files for deposit-to-address (e.g., NEAR Intents)**: ```bash -packages/swapper/src/swappers/NearIntentsSwapper/ -├── endpoints.ts # checkTradeStatus uses depositAddress from metadata -├── swapperApi/ -│ ├── getTradeQuote.ts # Stores depositAddress in nearIntentsSpecific -│ └── getTradeRate.ts +packages/swapper/src/swappers/AcrossSwapper/ +├── index.ts # Barrel: exports { acrossApi, acrossSwapper } at minimum +├── AcrossSwapper.ts # Swapper interface (shared executors) +├── endpoints.ts # SwapperApi: scoped input casts + shared chain exec utils +├── getTradeQuote/ +│ └── getTradeQuote.ts # Quote arm wrapper: assertQuoteAddresses → context → step data → Trade[] +├── getTradeRate/ +│ └── getTradeRate.ts # Rate arm wrapper: owns ?? default-address fallbacks → Trade[] └── utils/ - ├── oneClickService.ts # OneClick SDK initialization - └── helpers/ - └── helpers.ts # Asset mapping, status translation + ├── types.ts # API types + scoped AcrossTrade{Quote,Rate}Input aliases + ├── helpers.ts # PURE helpers: assertValidTrade, address mappers, fee fallbacks + ├── acrossService.ts # HTTP client with cache + API key injection + ├── fetchAcrossTrade.ts # API wrappers + ├── getAcrossTradeContext.ts # Shared core: fetch + derivations, ZERO quoteOrRate checks + └── getAcrossStepData.ts # Discriminated rate/quote step data (StepDataArgs, overloaded) ``` +**Then read 1-2 swappers of your category** (see canonical examples above). + **Critical things to note while reading**: -1. How do they call the API? (HTTP service pattern? SDK? Direct axios?) -2. How do they handle errors? (Monadic `Result` pattern) -3. How do they calculate rates? (`getInputOutputRate` util vs custom) -4. What metadata do they store in `TradeQuoteStep`? -5. How do they validate inputs? (Supported chains? Asset compatibility?) -6. How do they handle native tokens? (Marker address vs special field) -7. How do they convert API responses to our types? +1. How the context splits from the arm wrappers (what is shared vs arm-specific) +2. The `StepDataArgs` generic and the overloaded step data returns +3. How errors flow: no throws in step data/context — scoped try/catch mapping to + `makeNetworkFeeEstimationFailedErr` / `makeTradeStepBuildFailedErr` / `makeSwapErrorRight` +4. What `transactionData` variant the quote carries, and what (if anything) goes in `swapperMetadata` +5. How rates estimate fees (best effort, provider-fee fallback) vs quotes (hard fail) +6. How the API is called (HTTP service pattern, native marker, checksumming, slippage format) #### Step 3: Review Common Patterns @@ -305,26 +311,27 @@ Follow this EXACT order to avoid rework: #### Step 1: Create Directory Structure ```bash -mkdir -p packages/swapper/src/swappers/[SwapperName]Swapper/{get[SwapperName]TradeQuote,get[SwapperName]TradeRate,utils/helpers} +mkdir -p packages/swapper/src/swappers/[SwapperName]Swapper/{getTradeQuote,getTradeRate,utils} ``` -**Standard structure** (EVM swappers): +**Canonical structure** (mirror Across exactly): ``` [SwapperName]Swapper/ -├── index.ts -├── [SwapperName]Swapper.ts -├── endpoints.ts -├── types.ts -├── get[SwapperName]TradeQuote/ -│ └── get[SwapperName]TradeQuote.ts -├── get[SwapperName]TradeRate/ -│ └── get[SwapperName]TradeRate.ts +├── index.ts # Barrel: { [swapperName]Api, [swapperName]Swapper } at minimum +├── [SwapperName]Swapper.ts # Swapper interface (shared executors) +├── endpoints.ts # SwapperApi wiring +├── types.ts # Scoped input aliases + metadata type (or utils/types.ts) +├── getTradeQuote/ +│ └── getTradeQuote.ts # Quote arm wrapper +├── getTradeRate/ +│ └── getTradeRate.ts # Rate arm wrapper └── utils/ - ├── constants.ts - ├── [swapperName]Service.ts - ├── fetchFrom[SwapperName].ts - └── helpers/ - └── helpers.ts + ├── constants.ts # Supported chains, native marker, defaults + ├── helpers.ts # PURE helpers only (flat file, not helpers/helpers.ts) + ├── [swapperName]Service.ts # HTTP client with cache + API key injection + ├── fetch[SwapperName]Trade.ts # API wrappers + ├── get[SwapperName]TradeContext.ts # Shared core + └── get[SwapperName]StepData.ts # Discriminated rate/quote step data ``` #### Step 2: Implement Files in Order @@ -395,7 +402,7 @@ export const DUMMY_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as Add export const DEFAULT_SLIPPAGE_PERCENTAGE = '0.5' // 0.5% ``` -**2c. `utils/helpers/helpers.ts` - Helper Functions** +**2c. `utils/helpers.ts` - Pure Helper Functions (incl. `assertValidTrade`)** ```typescript import { fromAssetId, type AssetId } from '@shapeshiftoss/caip' @@ -477,7 +484,7 @@ import { makeSwapErrorRight } from '../../../utils' import { TradeQuoteError, type SwapErrorRight } from '../../../types' import type { [Swapper]Service } from './[swapperName]Service' import type { [Swapper]QuoteRequest, [Swapper]QuoteResponse } from '../types' -import { assetIdToToken, chainIdToChainRef } from './helpers/helpers' +import { assetIdToToken, chainIdToChainRef } from './helpers' // Base URL for API const BASE_URL = 'https://api.[swapper].com' @@ -597,591 +604,286 @@ export const fetchPrice = async ( } ``` -**2f. `get[SwapperName]TradeQuote/get[SwapperName]TradeQuote.ts` - Quote Logic** +**2f. `utils/get[SwapperName]TradeContext.ts` - Shared Core** -This is the MEAT of the implementation. It must: -1. Validate inputs (chain support, asset compatibility) -2. Fetch quote from API -3. Estimate network fees using chain adapter -4. Build complete TradeQuote object with all required fields -5. Handle errors monadic-ally +The context holds everything BOTH arms share: the provider fetch (when both arms hit the same +endpoint - Across/Debridge model) or just the assembly (when arms fetch differently - Zrx/Portals +model), error mapping, derived amounts, protocolFees, and the step data args. It contains ZERO +`quoteOrRate` checks and takes already-resolved addresses as params. ```typescript -import { type AssetId } from '@shapeshiftoss/caip' -import { bn } from '@shapeshiftoss/utils' -import { Err, Ok, type Result } from '@sniptt/monads' -import { makeSwapErrorRight } from '../../../utils' -import { - type CommonTradeQuoteInput, - type GetEvmTradeQuoteInput, - type SwapErrorRight, - type SwapperDeps, - type TradeQuote, - TradeQuoteError -} from '../../../types' -import { fetchQuote } from '../utils/fetchFromBebop' -import { [swapperName]ServiceFactory } from '../utils/[swapperName]Service' -import { - getInputOutputRate, - isSupportedChainId -} from '../utils/helpers/helpers' -import { DUMMY_ADDRESS } from '../utils/constants' - -export const get[SwapperName]TradeQuote = async ( - input: GetEvmTradeQuoteInput | CommonTradeQuoteInput, - deps: SwapperDeps -): Promise> => { - try { - const { - sellAsset, - buyAsset, - sellAmountIncludingProtocolFeesCryptoBaseUnit, - sendAddress, - receiveAddress, - accountNumber, - affiliateBps, - slippageTolerancePercentageDecimal - } = input - - const { config, assertGetEvmChainAdapter } = deps - - // Validation: Check chain support - if (!isSupportedChainId(sellAsset.chainId)) { - return Err( - makeSwapErrorRight({ - message: `[${SwapperName.[SwapperName]}] Unsupported chainId: ${sellAsset.chainId}`, - code: TradeQuoteError.UnsupportedChain, - details: { chainId: sellAsset.chainId } - }) - ) - } - - // Validation: Must be same chain - if (sellAsset.chainId !== buyAsset.chainId) { - return Err( - makeSwapErrorRight({ - message: `[${SwapperName.[SwapperName]}] Cross-chain not supported`, - code: TradeQuoteError.CrossChainNotSupported - }) - ) - } - - // Validation: Prevent executable quotes with dummy address - const takerAddress = sendAddress ?? receiveAddress - if (takerAddress === DUMMY_ADDRESS) { - return Err( - makeSwapErrorRight({ - message: 'Cannot execute trade with dummy address', - code: TradeQuoteError.UnknownError - }) - ) - } - - // Fetch quote from API - const service = [swapperName]ServiceFactory(config) - const maybeQuoteResponse = await fetchQuote( - { - sellAssetId: sellAsset.assetId, - buyAssetId: buyAsset.assetId, - sellAmountCryptoBaseUnit: sellAmountIncludingProtocolFeesCryptoBaseUnit, - chainId: sellAsset.chainId, - takerAddress, - receiverAddress: receiveAddress, - slippageTolerancePercentageDecimal: - slippageTolerancePercentageDecimal ?? DEFAULT_SLIPPAGE_PERCENTAGE, - affiliateBps - }, - service - ) - - if (maybeQuoteResponse.isErr()) { - return Err(maybeQuoteResponse.unwrapErr()) - } - - const quoteResponse = maybeQuoteResponse.unwrap() +type [Swapper]TradeContext = { + tradeCommon: TradeCommon // id, rate, affiliateBps, slippage, swapperName... + stepCommon: Omit // amounts, assets, allowanceContract, source... + protocolFees: QuoteFeeData['protocolFees'] + stepDataArgs: Omit // also omit arm-divergent extras +} +``` - // Get chain adapter for fee estimation - const adapter = assertGetEvmChainAdapter(sellAsset.chainId) +Rules: +- `allowanceContract` is `''` when there is no approval target, never `undefined` +- `swapperMetadata` (if any) is set here or in the quote wrapper - see Step 3 +- Return `Result` - provider errors map to `TradeQuoteError` codes (`QueryFailed`, `NoRouteFound`, + `SellAmountBelowMinimum`...), never throw - // Estimate network fees - const { average: { gasPrice } } = await adapter.getGasFeeData() +**2g. `utils/get[SwapperName]StepData.ts` - Discriminated Step Data** - const networkFeeCryptoBaseUnit = bn(quoteResponse.transaction.gas ?? '0') - .times(gasPrice) - .toFixed(0) +The heart of the rate/quote split. Uses the shared `StepDataArgs` +generic from `types.ts`: `Base` carries `deps` + `sellAsset` + everything derived in the context; +the Rate/Quote generics carry arm-specific extras derived in the wrappers (e.g. chainflip's quote +`depositAddress`). Declare TWO overloads over one implementation so callers get precise per-arm +types: - // Calculate rate - const rate = getInputOutputRate({ - sellAmountCryptoBaseUnit: sellAmountIncludingProtocolFeesCryptoBaseUnit, - buyAmountCryptoBaseUnit: quoteResponse.buyAmount, - sellAsset, - buyAsset - }) +```typescript +type [Swapper]RateStepData = { networkFeeCryptoBaseUnit: string } +type [Swapper]QuoteStepData = { transactionData: TxBuildData; networkFeeCryptoBaseUnit: string } + +export function get[Swapper]StepData( + args: Extract, +): Promise> +export function get[Swapper]StepData( + args: Extract, +): Promise> +export async function get[Swapper]StepData( + args: Get[Swapper]StepDataArgs, +): Promise> { ... } +``` - // Build TradeQuote - const tradeQuote: TradeQuote = { - id: crypto.randomUUID(), - quoteOrRate: 'quote', - rate, - slippageTolerancePercentageDecimal, - receiveAddress, - affiliateBps, - steps: [ - { - buyAmountBeforeFeesCryptoBaseUnit: quoteResponse.buyAmount, - buyAmountAfterFeesCryptoBaseUnit: quoteResponse.buyAmount, // or minus protocol fees - sellAmountIncludingProtocolFeesCryptoBaseUnit, - feeData: { - networkFeeCryptoBaseUnit, - protocolFees: {}, // or add protocol fees if any - }, - rate, - source: SwapperName.[SwapperName], - buyAsset, - sellAsset, - accountNumber, - allowanceContract: isNativeEvmAsset(sellAsset.assetId) - ? undefined - : quoteResponse.approvalTarget, // or constant approval contract - estimatedExecutionTimeMs: undefined, // or from API - // Store transaction metadata - [swapperName]TransactionMetadata: { - to: quoteResponse.transaction.to, - data: quoteResponse.transaction.data, - value: quoteResponse.transaction.value, - gas: quoteResponse.transaction.gas - } - } - ], - swapperName: SwapperName.[SwapperName] - } +The non-negotiable rules (see the review skill for full nuance): +- **Rates NEVER return `transactionData`** - `TradeRateStep` bans it at the type level +- **Rate arm**: best-effort fee - try the real estimation, catch to the provider-fee fallback. + Provider-built routes can't be placeholder-estimated; self-built transfers can +- **Quote arm**: ANY estimation/pricing failure fails the quote via + `makeNetworkFeeEstimationFailedErr(context, cause)` - NEVER a provider-fee fallback (execution + needs the same fee data). Unbuildable provider payloads (decode failure, missing fields) fail via + `makeTradeStepBuildFailedErr(context, cause)` +- **No `throw`** - validation misses and unsupported-namespace `default` cases return `Err`; + `try/catch` is scoped ONLY around the external adapter/estimation call +- **EVM quote invariant**: `transactionData.gasLimit` is always set - pass the transactionData to + `getEvmNetworkFeeCryptoBaseUnit` (utils/evm), which prices a provider-supplied gasLimit as-is or + estimates-and-sets the buffered limit in place. Route ALL EVM fee math through it +- **Solana instruction quotes**: strip any provider budget instructions + (`omitComputeBudgetInstructions`), estimate via `getSolanaNetworkFeeCryptoBaseUnit`, then set the + static compute unit limit with `withComputeUnitLimit({ instructions, computeUnits, + includeComputeBudget, computeBudget })` using a per-swapper exported + `[SWAPPER]_SOLANA_COMPUTE_BUDGET` (margin measured against live drift) +- **UTXO quotes**: `{ type: 'utxo', to, opReturnData?, value }` via `getUtxoNetworkFeeCryptoBaseUnit`; + guard genuinely-optional memo fields (estimation won't catch their absence) +- **Multi-namespace swappers**: one `switch (chainNamespace)` with both arms inline per case + (ButterSwap canonical) - never a separate rate helper that re-switches on namespace + +**2h. Arm Wrappers - `getTradeQuote/getTradeQuote.ts` + `getTradeRate/getTradeRate.ts`** + +Thin assembly, returning `Trade[]` (`Ok([trade])`) so endpoints wire them directly: - return Ok(tradeQuote) - } catch (error) { - return Err( - makeSwapErrorRight({ - message: 'Failed to get trade quote', - code: TradeQuoteError.UnknownError, - cause: error - }) - ) +```typescript +// Quote wrapper: addresses guarded BEFORE any provider request +export const getTradeQuote = async ( + input: [Swapper]TradeQuoteInput, // the scoped alias - see types.ts below + deps: SwapperDeps, +): Promise> => { + const { accountNumber } = input + + const maybeAddresses = assertQuoteAddresses(input) + if (maybeAddresses.isErr()) return Err(maybeAddresses.unwrapErr()) + const { sendAddress, receiveAddress } = maybeAddresses.unwrap() + + const maybeContext = await get[Swapper]TradeContext({ input, deps, from: sendAddress, ... }) + if (maybeContext.isErr()) return Err(maybeContext.unwrapErr()) + const { tradeCommon, stepCommon, protocolFees, stepDataArgs } = maybeContext.unwrap() + + const maybeStepData = await get[Swapper]StepData({ ...stepDataArgs, type: 'quote', input }) + if (maybeStepData.isErr()) return Err(maybeStepData.unwrapErr()) + const { transactionData, networkFeeCryptoBaseUnit } = maybeStepData.unwrap() + + const tradeQuote: TradeQuote = { + ...tradeCommon, + quoteOrRate: 'quote', + receiveAddress, + steps: [{ + ...stepCommon, + accountNumber, + transactionData, + feeData: { networkFeeCryptoBaseUnit, protocolFees }, + }], } + + return Ok([tradeQuote]) } ``` -**2g. `get[SwapperName]TradeRate/get[SwapperName]TradeRate.ts` - Rate Logic** +Rate wrapper differences: +- Owns the `?? default/dummy address` fallbacks (rate-only - a quote must NEVER request a provider + route with a defaulted address) +- Steps carry `accountNumber` from the input (`input.accountNumber` - set when a wallet is + connected, `undefined` walletless; this feeds approval-before-quote flows). Do NOT hardcode + `accountNumber: undefined` +- No `transactionData` on the step, `quoteOrRate: 'rate'` -Similar to quote but: -- No wallet address required (use dummy or undefined) -- accountNumber is undefined -- May skip network fee estimation (or use cached/estimated) +Result: no `TradeQuoteStep | TradeRateStep` unions, no `as TradeQuoteStep` casts, no scattered +`input.quoteOrRate === 'quote'` checks anywhere. -```typescript -import { Err, Ok, type Result } from '@sniptt/monads' -import { makeSwapErrorRight } from '../../../utils' -import { - type GetTradeRateInput, - type SwapErrorRight, - type SwapperDeps, - type TradeRate, - TradeQuoteError -} from '../../../types' -import { fetchPrice } from '../utils/fetchFromBebop' -import { [swapperName]ServiceFactory } from '../utils/[swapperName]Service' -import { getInputOutputRate, isSupportedChainId } from '../utils/helpers/helpers' -import { DEFAULT_SLIPPAGE_PERCENTAGE } from '../utils/constants' - -export const get[SwapperName]TradeRate = async ( - input: GetTradeRateInput, - deps: SwapperDeps -): Promise> => { - try { - const { - sellAsset, - buyAsset, - sellAmountIncludingProtocolFeesCryptoBaseUnit, - receiveAddress, - affiliateBps, - slippageTolerancePercentageDecimal - } = input - - const { config } = deps - - // Same validation as quote - if (!isSupportedChainId(sellAsset.chainId)) { - return Err( - makeSwapErrorRight({ - message: `[${SwapperName.[SwapperName]}] Unsupported chainId: ${sellAsset.chainId}`, - code: TradeQuoteError.UnsupportedChain - }) - ) - } - - if (sellAsset.chainId !== buyAsset.chainId) { - return Err( - makeSwapErrorRight({ - message: `[${SwapperName.[SwapperName]}] Cross-chain not supported`, - code: TradeQuoteError.CrossChainNotSupported - }) - ) - } +**2i. Scoped Input Aliases - `types.ts`** - // Fetch rate (uses dummy address if no receiveAddress) - const service = [swapperName]ServiceFactory(config) - const maybeRateResponse = await fetchPrice( - { - sellAssetId: sellAsset.assetId, - buyAssetId: buyAsset.assetId, - sellAmountCryptoBaseUnit: sellAmountIncludingProtocolFeesCryptoBaseUnit, - chainId: sellAsset.chainId, - receiveAddress, - slippageTolerancePercentageDecimal: - slippageTolerancePercentageDecimal ?? DEFAULT_SLIPPAGE_PERCENTAGE, - affiliateBps - }, - service - ) +EVERY swapper (even single-chain) defines scoped input aliases - unions of ONLY the supported +`GetTrade{Quote,Rate}Input` members - and casts ONCE at the endpoint boundary: - if (maybeRateResponse.isErr()) { - return Err(maybeRateResponse.unwrapErr()) - } - - const rateResponse = maybeRateResponse.unwrap() - - // Calculate rate - const rate = getInputOutputRate({ - sellAmountCryptoBaseUnit: sellAmountIncludingProtocolFeesCryptoBaseUnit, - buyAmountCryptoBaseUnit: rateResponse.buyAmount, - sellAsset, - buyAsset - }) - - // Build TradeRate (similar to quote but accountNumber = undefined) - const tradeRate: TradeRate = { - id: crypto.randomUUID(), - quoteOrRate: 'rate', - rate, - slippageTolerancePercentageDecimal, - receiveAddress, - affiliateBps, - steps: [ - { - buyAmountBeforeFeesCryptoBaseUnit: rateResponse.buyAmount, - buyAmountAfterFeesCryptoBaseUnit: rateResponse.buyAmount, - sellAmountIncludingProtocolFeesCryptoBaseUnit, - feeData: { - networkFeeCryptoBaseUnit: undefined, // Unknown for rate - protocolFees: {} - }, - rate, - source: SwapperName.[SwapperName], - buyAsset, - sellAsset, - accountNumber: undefined, // CRITICAL: Must be undefined for rate - allowanceContract: isNativeEvmAsset(sellAsset.assetId) - ? undefined - : rateResponse.approvalTarget, - estimatedExecutionTimeMs: undefined - } - ], - swapperName: SwapperName.[SwapperName] - } - - return Ok(tradeRate) - } catch (error) { - return Err( - makeSwapErrorRight({ - message: 'Failed to get trade rate', - code: TradeQuoteError.UnknownError, - cause: error - }) - ) - } -} +```typescript +export type [Swapper]TradeQuoteInput = GetEvmTradeQuoteInput | GetSolanaTradeQuoteInput +export type [Swapper]TradeRateInput = GetEvmTradeRateInput | GetSolanaTradeRateInput ``` -**2h. `endpoints.ts` - SwapperApi Implementation** +The wrappers and context take the scoped alias; step data's `input` stays the wide +`GetTradeRateInput`/`GetTradeQuoteInput` (dictated by `StepDataArgs`). `'supportsEIP1559' in input` +narrowing discriminates EVM members from the rest (chainId comparison does NOT narrow the union). -```typescript -import { isNativeEvmAsset } from '@shapeshiftoss/utils' -import { bn } from '@shapeshiftoss/utils' -import { fromHex, type Hex } from 'viem' -import { checkEvmSwapStatus } from '../../utils' -import type { - CommonTradeQuoteInput, - GetEvmTradeQuoteInput, - GetTradeRateInput, - GetUnsignedEvmTransactionArgs, - SwapperApi, - SwapperDeps, - TradeQuote, - TradeRate, - TradeQuoteResult, - TradeRateResult -} from '../../types' -import { get[SwapperName]TradeQuote } from './get[SwapperName]TradeQuote/get[SwapperName]TradeQuote' -import { get[SwapperName]TradeRate } from './get[SwapperName]TradeRate/get[SwapperName]TradeRate' +**2j. `endpoints.ts` - SwapperApi Wiring** +```typescript export const [swapperName]Api: SwapperApi = { - getTradeQuote: async ( - input: GetEvmTradeQuoteInput | CommonTradeQuoteInput, - deps: SwapperDeps - ): Promise => { - const maybeTradeQuote = await get[SwapperName]TradeQuote(input, deps) - return maybeTradeQuote.map(quote => [quote]) - }, - - getTradeRate: async ( - input: GetTradeRateInput, - deps: SwapperDeps - ): Promise => { - const maybeTradeRate = await get[SwapperName]TradeRate(input, deps) - return maybeTradeRate.map(rate => [rate]) - }, + getTradeQuote: (input, deps) => getTradeQuote(input as [Swapper]TradeQuoteInput, deps), + getTradeRate: (input, deps) => getTradeRate(input as [Swapper]TradeRateInput, deps), - getUnsignedEvmTransaction: async ( - args: GetUnsignedEvmTransactionArgs - ) => { - const { - tradeQuote, - chainId, - from, - stepIndex, - assertGetEvmChainAdapter - } = args - - const step = tradeQuote.steps[stepIndex] - const metadata = step.[swapperName]TransactionMetadata - - if (!metadata) { - throw new Error('Missing transaction metadata') - } + // Use the SHARED per-chain executors - do not hand-roll unless the swapper genuinely deviates + getUnsignedEvmTransaction, // from '../../utils/evm' - appends permit2 signature if present + getEvmTransactionFees, // from '../../utils/evm' + getUnsignedUtxoTransaction, // from '../../utils/utxo' + getUtxoTransactionFees, + getUnsignedSolanaTransaction, // from '../../utils/solana' - reads the static limit, fetches priority fee + getSolanaTransactionFees, - const adapter = assertGetEvmChainAdapter(chainId) + checkTradeStatus: async ({ config, swap }) => { + if (!swap) throw new Error('Missing swap') - // Convert hex values to decimal strings (CRITICAL!) - const value = metadata.value - ? fromHex(metadata.value as Hex, 'bigint').toString() - : '0' + // Read tracking data via getSwapMetadata (throws on mismatch - matches all swappers) + const { swapId } = getSwapMetadata(swap.metadata.swapperMetadata, '[swapperName]') - const gasLimit = metadata.gas - ? fromHex(metadata.gas as Hex, 'bigint').toString() - : undefined + // ...poll the provider... - // Build EVM transaction return { - chainId: Number(fromChainId(chainId).chainReference), - to: metadata.to, - from, - data: metadata.data, - value, - gasLimit, // or use adapter.getFeeData() if not provided + status, // TxStatus + buyTxHash, + // The protocol's own tracker page, constructed HERE next to the provider response: + swapperTxId, // display id (native swap id, relayer hash, order uid) + swapperTxLink, // fully-formed URL (e.g. scan.chainflip.io/swaps/) + message, } }, - - getEvmTransactionFees: async (args: GetUnsignedEvmTransactionArgs) => { - const { tradeQuote, chainId, assertGetEvmChainAdapter, stepIndex } = args - - const step = tradeQuote.steps[stepIndex] - const adapter = assertGetEvmChainAdapter(chainId) - - // Get current gas price - const { average: { gasPrice } } = await adapter.getGasFeeData() - - // Use API gas estimate or node estimate - const metadata = step.[swapperName]TransactionMetadata - const apiGasEstimate = metadata?.gas - ? fromHex(metadata.gas as Hex, 'bigint').toString() - : '0' - - // Take max of API and node estimates (with buffer) - const networkFeeCryptoBaseUnit = bn - .max(step.feeData.networkFeeCryptoBaseUnit ?? '0', apiGasEstimate) - .times(1.15) // 15% buffer - .toFixed(0) - - return networkFeeCryptoBaseUnit - }, - - checkTradeStatus: checkEvmSwapStatus // Standard EVM status check } ``` -**2i. `[SwapperName]Swapper.ts` - Swapper Interface** +For plain same-chain EVM swappers, `checkTradeStatus: checkEvmSwapStatus` (shared) suffices. -For most EVM swappers, this is simple: +**2k. `[SwapperName]Swapper.ts` - Swapper Interface** ```typescript -import { executeEvmTransaction } from '../utils' +import { executeEvmTransaction } from '../../utils' import type { Swapper } from '../../types' export const [swapperName]Swapper: Swapper = { - executeEvmTransaction + executeEvmTransaction, // and/or executeSolanaTransaction etc. - shared executors } ``` -For deposit-to-address or custom execution, implement custom logic here. +Custom execution logic (e.g. CowSwap's order POST) lives here. -**2j. `index.ts` - Exports** +**2l. `index.ts` - Barrel** + +Every swapper barrel exports at minimum its api + swapper def, so `constants.ts` imports one line +per swapper: ```typescript export { [swapperName]Api } from './endpoints' export { [swapperName]Swapper } from './[SwapperName]Swapper' export * from './types' -export * from './utils/constants' ``` -#### Step 3: Add Swapper-Specific Metadata (ONLY if needed!) - -**Skip this step if your swapper is a direct transaction swapper** (like Bebop, 0x, Portals). +#### Step 3: Add Swapper Metadata (ONLY if needed!) -**Implement this step if**: -- Swapper uses deposit-to-address model (Chainflip, NEAR Intents) -- Need to track order IDs or swap IDs between quote and execution -- Status polling requires data beyond transaction hash +**Skip this step** if execution and status tracking need nothing beyond the transaction hash +(plain same-chain EVM swappers). -**Three places to modify**: +**Implement it if** status polling or execution needs a provider-side identifier (deposit address, +order id, swap id, quote id). -**a. `packages/swapper/src/types.ts` - Add to TradeQuoteStep**: -```typescript -export type TradeQuoteStep = { - // ... existing fields - [swapperName]Specific?: { - depositAddress: string - swapId: string | number - memo?: string - deadline?: string - // ... other tracking fields - } -} -``` +The mechanism is the `SwapperMetadata` discriminated union - a single `swapperMetadata` field on the +step. There is NO web-side wiring: `buildSwapMetadata` carries it onto the persisted swap +automatically, and consumers read it with `getSwapMetadata`. -**b. `packages/swapper/src/types.ts` - Add to SwapperSpecificMetadata**: +**a. Define the union member** in the swapper's `types.ts`: ```typescript -export type SwapperSpecificMetadata = { - chainflipSwapId: number | undefined - nearIntentsSpecific?: { ... } - // Add your swapper: - [swapperName]Specific?: { - depositAddress: string - swapId: string | number - memo?: string - deadline?: string - } - relayTransactionMetadata: RelayTransactionMetadata | undefined - // ... -} -``` - -**c. Populate in quote** (`get[SwapperName]TradeQuote.ts`): -```typescript -const tradeQuote: TradeQuote = { - // ... - steps: [{ - // ... - [swapperName]Specific: { - depositAddress: quoteResponse.depositAddress, - swapId: quoteResponse.id, - memo: quoteResponse.memo, - deadline: quoteResponse.deadline - } - }] +export type [Swapper]Metadata = { + name: '[swapperName]' // the union discriminant + swapId: string // whatever tracking data status/exec needs - keep it minimal, + depositAddress: string // every field must have a read site (no write-only fields) } ``` -**d. Extract into swap** (TWO places - BOTH required!): +**b. Register it** in `packages/swapper/src/types.ts`'s `SwapperMetadata` union. -**Place 1**: `src/components/MultiHopTrade/components/TradeConfirm/hooks/useTradeButtonProps.tsx` +**c. Set it at quote time** (context or quote wrapper): ```typescript -// Around line 114-126 -metadata: { - chainflipSwapId: firstStep?.chainflipSpecific?.chainflipSwapId, - nearIntentsSpecific: firstStep?.nearIntentsSpecific, - [swapperName]Specific: firstStep?.[swapperName]Specific, // ADD THIS - relayTransactionMetadata: firstStep?.relayTransactionMetadata, - // ... -} -``` - -**Place 2**: `src/lib/tradeExecution.ts` -```typescript -// Around line 156-161 -metadata: { - ...swap.metadata, - chainflipSwapId: tradeQuote.steps[0]?.chainflipSpecific?.chainflipSwapId, - nearIntentsSpecific: tradeQuote.steps[0]?.nearIntentsSpecific, - [swapperName]Specific: tradeQuote.steps[0]?.[swapperName]Specific, // ADD THIS - relayTransactionMetadata: tradeQuote.steps[0]?.relayTransactionMetadata, - // ... -} +steps: [{ ...stepCommon, accountNumber, transactionData, swapperMetadata: { name: '[swapperName]', swapId, depositAddress }, ... }] ``` -**e. Use in status check** (`endpoints.ts`): +**d. Read it** wherever needed - status polling and chain-specific execution: ```typescript -checkTradeStatus: async ({ swap, config }) => { - const { [swapperName]Specific } = swap?.metadata ?? {} - - if (![swapperName]Specific?.depositAddress) { - throw new Error('Missing depositAddress in swap metadata') - } - - // Poll API using metadata - const status = await pollSwapStatus( - [swapperName]Specific.depositAddress, - [swapperName]Specific.swapId, - config - ) - - return { - status: mapApiStatusToTxStatus(status.state), - buyTxHash: status.outputTxHash, - message: status.message - } -} +const { swapId } = getSwapMetadata(swap.metadata.swapperMetadata, '[swapperName]') // status +const { depositAddress } = getSwapMetadata(step.swapperMetadata, '[swapperName]') // exec ``` #### Step 4: Register the Swapper -**4a. `packages/swapper/src/types.ts` - Add Config Fields** +**4a. `packages/swapper/src/types.ts` - Add Config Fields + SwapperName** ```typescript +export enum SwapperName { + // ... existing + [SwapperName] = '[Display Name]', +} + export type SwapperConfig = { // ... existing fields VITE_[SWAPPER]_API_KEY: string - VITE_[SWAPPER]_BASE_URL?: string // if configurable } ``` +(`SwapperName` lives in `types.ts`, not `constants.ts`.) + **4b. `packages/swapper/src/constants.ts` - Register Swapper** +One barrel import per swapper, spread into the record: + ```typescript -export enum SwapperName { - // ... existing - [SwapperName] = '[Display Name]', -} +import { [swapperName]Api, [swapperName]Swapper } from './swappers/[SwapperName]Swapper' -export const swappers: Record = { +export const swappers: Record = { // ... existing [SwapperName.[SwapperName]]: { - swapper: [swapperName]Swapper, - swapperApi: [swapperName]Api - } -} - -export const DEFAULT_SLIPPAGE_DECIMAL_PERCENTAGE_BY_SWAPPER: Record< - SwapperName, - string | undefined -> = { - // ... existing - [SwapperName.[SwapperName]]: '0.005', // 0.5% + ...[swapperName]Swapper, + ...[swapperName]Api, + }, } ``` -**4c. `packages/swapper/src/index.ts` - Export** +Also add the swapper's default slippage to `getDefaultSlippageDecimalPercentageForSwapper` if it +differs from the default. -```typescript -export { [swapperName]Api, [swapperName]Swapper } from './swappers/[SwapperName]Swapper' -``` +**4c. `packages/swapper/src/index.ts` - Root Barrel** + +Re-export the swapper directory: `export * from './swappers/[SwapperName]Swapper'` + +**4c-bis. Public API + Swap Widget enablement (deliberate, separate decisions)** + +- **Public API**: a new swapper is NOT served by the public api until added to + `ENABLED_SWAPPER_NAMES` in `packages/public-api/src/constants.ts`. Before enabling, confirm the + quote's `transactionData` variant is serialized by + `packages/public-api/src/routes/quote/extractTransactionData.ts` + the zod schemas - a variant + the extractor doesn't handle ships silently non-executable quotes. +- **Swap widget**: the widget's own restricted `SwapperName` enum + (`packages/swap-widget/src/types/index.ts` - members commented out = disabled) is the + widget allowlist; also add icon/color entries in `packages/swap-widget/src/constants/swappers.ts` + if enabling there. Only enable swappers the widget can actually execute. **4d. CSP Headers** (if swapper calls external API) @@ -1323,12 +1025,17 @@ export const getConfig = (): Config => ({ 4. **Response Parsing**: Log actual API response, verify structure matches types 5. **Affiliate Fees**: Pass same `affiliateBps` to BOTH quote and rate endpoints 6. **Native Token Marker**: Verify marker address matches API requirements -7. **Gas Estimation**: Take max of API and node estimates, add buffer -8. **Dummy Address**: Block executable quotes with dummy address -9. **Error Handling**: Don't reject quote if some routes fail (e.g., dual routing) -10. **Type Safety**: Use `Address` and `Hex` types from viem, not strings - ---- +7. **EVM gasLimit invariant**: every EVM quote's `transactionData.gasLimit` ends up set - via + `getEvmNetworkFeeCryptoBaseUnit`, never inline gas math +8. **Quote addresses**: `assertQuoteAddresses` before any provider request; rate-only address + defaults never leak into quotes +9. **Rate vs quote fee semantics**: rate falls back to the provider fee, quote hard-fails + estimation - never the other way around +10. **No throws**: step data and context return `Err`, `try/catch` scoped to adapter calls only +11. **Trust the provider payload type**: don't guard fields the type marks required; guard only + genuinely-optional fields whose absence isn't caught downstream (e.g. utxo memo) +12. **Comment vernacular**: "set/supplied/quote-time", "throws at execution" - never "bake(d)" or + "fail closed" ### Phase 4: Testing & Validation @@ -1463,11 +1170,12 @@ all pass before the integration is complete. Before considering integration complete: **Code Quality**: -- [ ] All type checks pass (`pnpm run type-check`) +- [ ] Package type check passes (`npx tsc --noEmit -p packages/swapper/tsconfig.esm.json` - the + root `-p packages/swapper` config checks ZERO files and always passes; never trust it) - [ ] All lint checks pass (`pnpm run lint`) -- [ ] Build succeeds (`pnpm run build:swapper`) - [ ] No `any` types used -- [ ] All errors handled monadically +- [ ] All errors handled monadically; no throws in step data/context +- [ ] Rates carry no transactionData; quote wrapper guards addresses via assertQuoteAddresses **Functionality**: - [ ] Can fetch quotes successfully @@ -1479,13 +1187,17 @@ Before considering integration complete: - [ ] Error cases handled gracefully **Integration**: -- [ ] Registered in constants.ts -- [ ] Exported from index.ts +- [ ] SwapperName added in types.ts; registered in constants.ts via the swapper barrel +- [ ] Barrel exports { api, swapper }; root index.ts re-exports the directory +- [ ] Scoped [Swapper]Trade{Quote,Rate}Input aliases with the cast at the endpoint boundary +- [ ] SwapperMetadata union member registered (if tracking data needed) - [ ] CSP headers added - [ ] Feature flag implemented - [ ] Test mocks updated - [ ] Swapper icon added to UI - [ ] Environment variables configured +- [ ] Public api enablement decided (ENABLED_SWAPPER_NAMES + wire variant serialization verified) +- [ ] Swap widget enablement decided (widget SwapperName enum + icon map) **Documentation**: - [ ] INTEGRATION.md created @@ -1513,6 +1225,10 @@ Before considering integration complete: **Large rate vs quote delta** → Pass same `affiliateBps` to both `/quote` and `/price` endpoints +**Quote succeeds but execution throws 'missing gas limit in evm transaction'** +→ The quote arm didn't route through `getEvmNetworkFeeCryptoBaseUnit` with the transactionData - it +estimates-and-sets the buffered gasLimit in place when the provider omits gas + **"$0 showing in UI"** → Response parsing bug, log actual response and verify structure diff --git a/.claude/skills/swapper-integration/common-gotchas.md b/.claude/skills/swapper-integration/common-gotchas.md index 7d52f5ada07..8b5803fbf94 100644 --- a/.claude/skills/swapper-integration/common-gotchas.md +++ b/.claude/skills/swapper-integration/common-gotchas.md @@ -297,7 +297,7 @@ export const assetIdToToken = (assetId: AssetId): string => { - Sell native token (ETH → USDC) - Sell wrapped token (WETH → USDC) -**Affected Files**: `utils/helpers/helpers.ts`, `types.ts` +**Affected Files**: `utils/helpers.ts`, `types.ts` --- @@ -542,7 +542,7 @@ export const assetIdToTronToken = (assetId: AssetId): string => { } ``` -**Affected Files**: `utils/helpers/helpers.ts` +**Affected Files**: `utils/helpers.ts` --- diff --git a/.claude/skills/swapper-integration/examples.md b/.claude/skills/swapper-integration/examples.md index 2ecd56a6d37..03dfe8a6f32 100644 --- a/.claude/skills/swapper-integration/examples.md +++ b/.claude/skills/swapper-integration/examples.md @@ -1,718 +1,47 @@ -# Code Examples & Templates +# Canonical Examples + +The in-repo swappers ARE the templates - every one follows the canonical context split, so copy +from a living implementation instead of frozen snippets (they can't drift). Pick by what your +swapper resembles: + +## By overall shape + +| Need | Copy from | Why | +| --- | --- | --- | +| The reference implementation | `AcrossSwapper` | Simplest full canonical shape (EVM + Solana, context fetches) | +| Deposit-to-address + provider order | `BobGatewaySwapper` | Order resolved ONCE up front into a discriminated `{type:'rate'} \| {type:'quote'; orderResponse}` value; multi-namespace switch | +| Multi-namespace (evm/utxo/solana/tron) | `ButterSwap` | Single `switch (chainNamespace)` with BOTH arms inline per case; pure provider-fee fallback in helpers | +| Rate and quote hit DIFFERENT endpoints | `ZrxSwapper` | Context assembles but does NOT fetch - each wrapper fetches its own endpoint, hands a `NormalizedZrxQuote` to the context | +| Same endpoint, different params per arm | `PortalsSwapper` | Quote: real sender + validate:true + autoslippage retry; rate: dummy sender + validate:false | +| Two provider flows, one shape | `DebridgeSwapper` | Normalize-then-assemble: each flow maps its response to a shared `NormalizedDebridgeQuote`, context assembles once | +| Deposit channels / per-variant quotes | `ChainflipSwapper` | Context returns an ARRAY of contexts (regular+boost+DCA); channel creation is quote-wrapper-side | +| Gasless EIP-712 order | `CowSwapper` | `{type:'cowswap', chainId, orderToSign}` set at quote; `getUnsignedEvmMessage` thin reader; `executeEvmMessage` signs + POSTs | +| Sealed RFQ solana tx | `BebopSwapper` (solana arm) | `{type:'solana_serialized_tx', serializedTx}` - co-sign as-is, never rebuild | +| Multi-step provider routes | `RelaySwapper` | Per-step inputs from context; wrappers own the step map with fail-fast Result combining | +| Un-migrated chain namespace (fee-only) | `CetusSwapper` (sui), `StonfiSwapper` (ton), `AvnuSwapper` (starknet) | Full context split with no executable payload; exec re-derives | +| Migrated non-EVM chain | `SunioSwapper` (tron) | Real fee estimation + executable payload on a chain-specific variant | +| Shared thor-family internals | `utils/thorchain/` | Generic over two swappers + longtail two-phase rebuild | + +## By specific mechanism + +- **`assertValidTrade`** returning narrowed values: `AcrossSwapper/utils/helpers.ts`, `DebridgeSwapper` (returns both provider chain ids, killing double lookups) +- **`StepDataArgs` arm extras**: Chainflip (`{ depositAddress?: undefined }` rate / `{ depositAddress: string }` quote), ButterSwap (`buildTx` quote extra), Across (`{ from: string }` rate extra) +- **EVM fee + gasLimit invariant**: any EVM quote arm -> `getEvmNetworkFeeCryptoBaseUnit` (`utils/evm`) +- **Solana static compute limit**: `withComputeUnitLimit` call sites in Across/ButterSwap/Chainflip/NearIntents/Relay step data + their exported `[X]_SOLANA_COMPUTE_BUDGET` margins +- **UTXO fees + opReturn sizing**: `getUtxoNetworkFeeCryptoBaseUnit` (`utils/utxo`), ButterSwap utxo arm +- **SwapperMetadata union member + reads**: `ChainflipMetadata` (status polling by swap id), `NearIntentsMetadata` (deposit address for status + un-migrated exec), read via `getSwapMetadata` +- **checkTradeStatus with tracker links**: Chainflip (native id -> scan link), Relay (origin-tx link), CowSwapper (order uid link) - return `swapperTxId`/`swapperTxLink` constructed next to the provider response +- **HTTP service**: any `utils/[x]Service.ts` (`createCache` + `makeSwapperAxiosServiceMonadic`) +- **State-override gas estimation** (unapproved/unfunded sender still estimates): pass `stateOverride: { sellAsset, sellAmountCryptoBaseUnit, spenderAddress }` to `getEvmNetworkFeeCryptoBaseUnit` in the quote arm (`spenderAddress` = the step's `allowanceContract`, `''` when no approval is involved) — see ButterSwap/Portals/Relay step data; `utils/evm/stateOverride.ts` handles slot discovery + +## Registration example (one line per swapper) + +```typescript +// packages/swapper/src/constants.ts +import { acrossApi, acrossSwapper } from './swappers/AcrossSwapper' -Generic code templates for swapper integrations. Adapt these based on your specific swapper's API. - ---- - -## File Structure Template - -``` -packages/swapper/src/swappers/[SwapperName]Swapper/ -├── index.ts -├── [SwapperName]Swapper.ts -├── endpoints.ts -├── types.ts -├── get[SwapperName]TradeQuote/ -│ └── get[SwapperName]TradeQuote.ts -├── get[SwapperName]TradeRate/ -│ └── get[SwapperName]TradeRate.ts -└── utils/ - ├── constants.ts - ├── [swapperName]Service.ts - ├── fetchFrom[SwapperName].ts - └── helpers/ - └── helpers.ts -``` - ---- - -## 1. index.ts - -```typescript -export * from './[SwapperName]Swapper' -export * from './endpoints' -export * from './types' -``` - ---- - -## 2. types.ts - -```typescript -import { KnownChainIds } from '@shapeshiftoss/types' -import type { Address, Hex } from 'viem' - -// Supported chains -export const [swapperName]SupportedChainIds = [ - KnownChainIds.EthereumMainnet, - KnownChainIds.PolygonMainnet, - KnownChainIds.ArbitrumMainnet, - // Add other supported chains -] as const - -export type [SwapperName]SupportedChainId = (typeof [swapperName]SupportedChainIds)[number] - -// Chain name mapping (if API uses string names instead of IDs) -export const chainIdTo[SwapperName]Chain: Record<[SwapperName]SupportedChainId, string> = { - [KnownChainIds.EthereumMainnet]: 'ethereum', - [KnownChainIds.PolygonMainnet]: 'polygon', - [KnownChainIds.ArbitrumMainnet]: 'arbitrum', - // Map all supported chains -} - -// Native token marker (if API requires special address for native tokens) -// Common value: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE -// Check API docs! -export const [SWAPPER]_NATIVE_MARKER = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' - -// Dummy address for rate quotes (when no wallet connected) -// Commonly Vitalik's address - used for price-only quotes -export const [SWAPPER]_DUMMY_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as Address - -// API Response types - CUSTOMIZE based on actual API response! -export type [SwapperName]QuoteResponse = { - quoteId: string - // Add other fields from API -} -``` - ---- - -## 3. utils/constants.ts - -```typescript -export const DEFAULT_[SWAPPER]_SLIPPAGE_DECIMAL_PERCENTAGE = '0.005' // 0.5% -export const [SWAPPER]_API_BASE_URL = 'https://api.example.com' -``` - ---- - -## 4. utils/helpers/helpers.ts - -```typescript -import type { AssetId, ChainId } from '@shapeshiftoss/caip' -import { fromAssetId } from '@shapeshiftoss/caip' -import type { Asset } from '@shapeshiftoss/types' -import { bn, convertPrecision, isToken } from '@shapeshiftoss/utils' -import { Err, Ok } from '@sniptt/monads' -import { getAddress } from 'viem' - -import { TradeQuoteError } from '../../../../types' -import { makeSwapErrorRight } from '../../../../utils' -import type { [SwapperName]SupportedChainId } from '../../types' -import { [SWAPPER]_NATIVE_MARKER, [swapperName]SupportedChainIds } from '../../types' - -// Convert AssetId to swapper's token format -export const assetIdTo[SwapperName]Token = (assetId: AssetId): string => { - if (!isToken(assetId)) return [SWAPPER]_NATIVE_MARKER - const { assetReference } = fromAssetId(assetId) - return getAddress(assetReference) // Returns checksummed address -} - -// Check if chain is supported -export const isSupportedChainId = (chainId: ChainId): chainId is [SwapperName]SupportedChainId => { - return [swapperName]SupportedChainIds.includes(chainId as [SwapperName]SupportedChainId) -} - -// Validate trade pair is possible -export const assertValidTrade = ({ - buyAsset, - sellAsset, -}: { - buyAsset: Asset - sellAsset: Asset -}) => { - const sellAssetChainId = sellAsset.chainId - const buyAssetChainId = buyAsset.chainId - - // Check sell asset chain is supported - if (!isSupportedChainId(sellAssetChainId)) { - return Err( - makeSwapErrorRight({ - message: `unsupported chainId`, - code: TradeQuoteError.UnsupportedChain, - details: { chainId: sellAsset.chainId }, - }), - ) - } - - // Check buy asset chain is supported - if (!isSupportedChainId(buyAssetChainId)) { - return Err( - makeSwapErrorRight({ - message: `unsupported chainId`, - code: TradeQuoteError.UnsupportedChain, - details: { chainId: buyAsset.chainId }, - }), - ) - } - - // Check if cross-chain (reject if swapper doesn't support cross-chain) - if (sellAssetChainId !== buyAssetChainId) { - return Err( - makeSwapErrorRight({ - message: `cross-chain not supported - both assets must be on chainId ${sellAsset.chainId}`, - code: TradeQuoteError.CrossChainNotSupported, - details: { buyAsset, sellAsset }, - }), - ) - } - - return Ok(true) -} - -// Calculate exchange rate -export const calculateRate = ({ - buyAmount, - sellAmount, - buyAsset, - sellAsset, -}: { - buyAmount: string - sellAmount: string - buyAsset: Asset - sellAsset: Asset -}) => { - return convertPrecision({ - value: buyAmount, - inputExponent: buyAsset.precision, - outputExponent: sellAsset.precision, - }) - .dividedBy(bn(sellAmount)) - .toFixed() -} -``` - ---- - -## 5. utils/[swapperName]Service.ts - -```typescript -import axios from 'axios' -import { makeSwapperAxiosServiceMonadic } from '../../../utils' - -export const [swapperName]ServiceFactory = ({ apiKey }: { apiKey: string }) => { - const axiosInstance = axios.create({ - timeout: 10000, - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - // Adjust header name based on API: - 'x-api-key': apiKey, - // OR: 'Authorization': `Bearer ${apiKey}`, - // OR: no auth header needed - }, - }) - - return makeSwapperAxiosServiceMonadic(axiosInstance) -} -``` - ---- - -## 6. utils/fetchFrom[SwapperName].ts - -```typescript -import type { Asset } from '@shapeshiftoss/types' -import { bn } from '@shapeshiftoss/utils' -import type { Result } from '@sniptt/monads' -import { Err, Ok } from '@sniptt/monads' -import type { Address } from 'viem' -import { getAddress } from 'viem' - -import type { SwapErrorRight } from '../../../types' -import { TradeQuoteError } from '../../../types' -import { makeSwapErrorRight } from '../../../utils' -import type { [SwapperName]QuoteResponse, [SwapperName]SupportedChainId } from '../types' -import { [SWAPPER]_DUMMY_ADDRESS, chainIdTo[SwapperName]Chain } from '../types' -import { [swapperName]ServiceFactory } from './[swapperName]Service' -import { assetIdTo[SwapperName]Token } from './helpers/helpers' - -export const fetch[SwapperName]Quote = async ({ - buyAsset, - sellAsset, - sellAmountIncludingProtocolFeesCryptoBaseUnit, - takerAddress, - receiverAddress, - slippageTolerancePercentageDecimal, - affiliateBps, - apiKey, -}: { - buyAsset: Asset - sellAsset: Asset - sellAmountIncludingProtocolFeesCryptoBaseUnit: string - takerAddress: Address - receiverAddress: Address - slippageTolerancePercentageDecimal: string - affiliateBps?: string - apiKey: string -}): Promise> => { - try { - const sellToken = assetIdTo[SwapperName]Token(sellAsset.assetId) - const buyToken = assetIdTo[SwapperName]Token(buyAsset.assetId) - const checksummedTakerAddress = getAddress(takerAddress) - const checksummedReceiverAddress = getAddress(receiverAddress) - const chainName = chainIdTo[SwapperName]Chain[sellAsset.chainId as [SwapperName]SupportedChainId] - const sellAmountFormatted = bn(sellAmountIncludingProtocolFeesCryptoBaseUnit).toFixed(0) - - // CRITICAL: Adjust slippage format based on API! - // Option 1: API expects percentage (1 = 1%) - const slippagePercentage = bn(slippageTolerancePercentageDecimal ?? 0.005) - .times(100) - .toNumber() - - // Option 2: API expects decimal (0.01 = 1%) - // const slippageDecimal = bn(slippageTolerancePercentageDecimal ?? 0.005).toNumber() - - // Option 3: API expects basis points (100 = 1%) - // const slippageBps = bn(slippageTolerancePercentageDecimal ?? 0.005).times(10000).toNumber() - - // Build URL and params based on API structure - const url = `https://api.example.com/${chainName}/v1/quote` - - const params = new URLSearchParams({ - sell_token: sellToken, - buy_token: buyToken, - sell_amount: sellAmountFormatted, - taker_address: checksummedTakerAddress, - receiver_address: checksummedReceiverAddress, - slippage: slippagePercentage.toString(), // Adjust based on API - // Add other required params - }) - - // Add affiliate fee if provided - if (affiliateBps && affiliateBps !== '0') { - params.set('fee', affiliateBps) - } - - const service = [swapperName]ServiceFactory({ apiKey }) - const maybeResponse = await service.get<[SwapperName]QuoteResponse>(`${url}?${params}`) - - if (maybeResponse.isErr()) { - return Err( - makeSwapErrorRight({ - message: 'Failed to fetch quote', - cause: maybeResponse.unwrapErr().cause, - code: TradeQuoteError.QueryFailed, - }), - ) - } - - const response = maybeResponse.unwrap() - - // Validate response structure - if (!response.data /* add validation logic */) { - return Err( - makeSwapErrorRight({ - message: 'Invalid response', - code: TradeQuoteError.InvalidResponse, - }), - ) - } - - return Ok(response.data) - } catch (error) { - return Err( - makeSwapErrorRight({ - message: 'Unexpected error fetching quote', - cause: error, - code: TradeQuoteError.QueryFailed, - }), - ) - } -} - -// Rate fetch function (uses dummy address when no wallet) -export const fetch[SwapperName]Price = ({ - buyAsset, - sellAsset, - sellAmountIncludingProtocolFeesCryptoBaseUnit, - receiveAddress, - affiliateBps, - apiKey, -}: { - buyAsset: Asset - sellAsset: Asset - sellAmountIncludingProtocolFeesCryptoBaseUnit: string - receiveAddress: string | undefined - affiliateBps?: string - apiKey: string -}): Promise> => { - const address = (receiveAddress as Address | undefined) || [SWAPPER]_DUMMY_ADDRESS - - return fetch[SwapperName]Quote({ - buyAsset, - sellAsset, - sellAmountIncludingProtocolFeesCryptoBaseUnit, - takerAddress: address, - receiverAddress: address, - slippageTolerancePercentageDecimal: '0.01', - affiliateBps, // IMPORTANT: Pass affiliate fees to BOTH quote and rate! - apiKey, - }) -} -``` - ---- - -## 7. get[SwapperName]TradeQuote.ts - -```typescript -import type { ChainId } from '@shapeshiftoss/caip' -import type { EvmChainAdapter } from '@shapeshiftoss/chain-adapters' -import { evm } from '@shapeshiftoss/chain-adapters' -import type { AssetsByIdPartial } from '@shapeshiftoss/types' -import type { Result } from '@sniptt/monads' -import { Err, Ok } from '@sniptt/monads' -import { v4 as uuid } from 'uuid' -import type { Address } from 'viem' -import { fromHex, isAddress } from 'viem' - -import { getDefaultSlippageDecimalPercentageForSwapper } from '../../../constants' -import type { - GetEvmTradeQuoteInputBase, - SingleHopTradeQuoteSteps, - SwapErrorRight, - TradeQuote, - TradeQuoteStep, -} from '../../../types' -import { SwapperName, TradeQuoteError } from '../../../types' -import { makeSwapErrorRight } from '../../../utils' -import { isNativeEvmAsset } from '../../utils/helpers/helpers' -import { [SWAPPER]_DUMMY_ADDRESS } from '../types' -import { fetch[SwapperName]Quote } from '../utils/fetchFrom[SwapperName]' -import { assertValidTrade, calculateRate } from '../utils/helpers/helpers' - -export async function get[SwapperName]TradeQuote( - input: GetEvmTradeQuoteInputBase, - assertGetEvmChainAdapter: (chainId: ChainId) => EvmChainAdapter, - _assetsById: AssetsByIdPartial, - apiKey: string, -): Promise> { - const { - sellAsset, - buyAsset, - accountNumber, - sendAddress, - receiveAddress, - affiliateBps, - chainId, - supportsEIP1559, - sellAmountIncludingProtocolFeesCryptoBaseUnit, - } = input - - // Validate trade is possible - const assertion = assertValidTrade({ buyAsset, sellAsset }) - if (assertion.isErr()) return Err(assertion.unwrapErr()) - - const takerAddress = (sendAddress || receiveAddress) as Address - - // Prevent using dummy address for executable quotes - if (takerAddress === [SWAPPER]_DUMMY_ADDRESS) { - return Err( - makeSwapErrorRight({ - message: 'Cannot execute quote with dummy address - wallet required', - code: TradeQuoteError.UnknownError, - }), - ) - } - - const slippageTolerancePercentageDecimal = - input.slippageTolerancePercentageDecimal ?? - getDefaultSlippageDecimalPercentageForSwapper(SwapperName.[SwapperName]) - - // Fetch quote from API - const maybeQuoteResponse = await fetch[SwapperName]Quote({ - buyAsset, - sellAsset, - sellAmountIncludingProtocolFeesCryptoBaseUnit, - takerAddress, - receiverAddress: receiveAddress as Address, - slippageTolerancePercentageDecimal, - affiliateBps, - apiKey, - }) - - if (maybeQuoteResponse.isErr()) return Err(maybeQuoteResponse.unwrapErr()) - const quoteResponse = maybeQuoteResponse.unwrap() - - // Extract amounts from response - // CUSTOMIZE based on actual API response structure! - const sellAmount = /* extract from quoteResponse */ - const buyAmount = /* extract from quoteResponse */ - - // Build transaction metadata - const transactionMetadata: TradeQuoteStep['[swapperName]TransactionMetadata'] = { - to: /* from response */, - data: /* from response */, - value: /* from response */, - gas: /* from response */, - } - - const rate = calculateRate({ buyAmount, sellAmount, buyAsset, sellAsset }) - - try { - const adapter = assertGetEvmChainAdapter(chainId) - const { average } = await adapter.getGasFeeData() - - // Convert gas limit from hex if API returns hex - // const gasLimitFromQuote = quote.tx.gas - // ? fromHex(quote.tx.gas as Hex, 'bigint').toString() - // : '0' - - const networkFeeCryptoBaseUnit = evm.calcNetworkFeeCryptoBaseUnit({ - ...average, - supportsEIP1559: Boolean(supportsEIP1559), - gasLimit: /* gas limit from quote or calculation */, - }) - - return Ok({ - id: uuid(), - quoteOrRate: 'quote' as const, - receiveAddress, - affiliateBps, - slippageTolerancePercentageDecimal, - rate, - swapperName: SwapperName.[SwapperName], - steps: [ - { - estimatedExecutionTimeMs: 0, - allowanceContract: isNativeEvmAsset(sellAsset.assetId) - ? undefined - : /* approval contract from response or constant */, - buyAsset, - sellAsset, - accountNumber, - rate, - feeData: { - protocolFees: {}, // Or calculate from response - networkFeeCryptoBaseUnit, - }, - buyAmountBeforeFeesCryptoBaseUnit: buyAmount, - buyAmountAfterFeesCryptoBaseUnit: buyAmount, - sellAmountIncludingProtocolFeesCryptoBaseUnit, - source: SwapperName.[SwapperName], - [swapperName]TransactionMetadata: transactionMetadata, - }, - ] as SingleHopTradeQuoteSteps, - }) - } catch (err) { - return Err( - makeSwapErrorRight({ - message: 'Failed to get fee data', - cause: err, - code: TradeQuoteError.NetworkFeeEstimationFailed, - }), - ) - } -} -``` - ---- - -## 8. get[SwapperName]TradeRate.ts - -Similar to getTradeQuote but: -- Returns `'rate'` instead of `'quote'` -- No transaction metadata needed -- Can use API gas estimates directly -- Uses dummy address if no wallet - -```typescript -// Structure similar to getTradeQuote -// Key differences: -return Ok({ - // ... same fields ... - quoteOrRate: 'rate' as const, - accountNumber: undefined, // Rates don't have accounts -}) -``` - ---- - -## 9. endpoints.ts - -```typescript -import { evm } from '@shapeshiftoss/chain-adapters' -import BigNumber from 'bignumber.js' -import type { GetEvmTradeQuoteInputBase, GetEvmTradeRateInput, SwapperApi } from '../../types' -import { checkEvmSwapStatus, getExecutableTradeStep, isExecutableTradeQuote } from '../../utils' -import { get[SwapperName]TradeQuote } from './get[SwapperName]TradeQuote/get[SwapperName]TradeQuote' -import { get[SwapperName]TradeRate } from './get[SwapperName]TradeRate/get[SwapperName]TradeRate' - -export const [swapperName]Api: SwapperApi = { - getTradeQuote: async (input, { assertGetEvmChainAdapter, config, assetsById }) => { - const tradeQuoteResult = await get[SwapperName]TradeQuote( - input as GetEvmTradeQuoteInputBase, - assertGetEvmChainAdapter, - assetsById, - config.VITE_[SWAPPER]_API_KEY, - ) - - return tradeQuoteResult.map(tradeQuote => [tradeQuote]) - }, - - getTradeRate: async (input, { config, assetsById }) => { - const tradeRateResult = await get[SwapperName]TradeRate( - input as GetEvmTradeRateInput, - assetsById, - config.VITE_[SWAPPER]_API_KEY, - ) - - return tradeRateResult.map(tradeRate => [tradeRate]) - }, - - getUnsignedEvmTransaction: async ({ - from, - stepIndex, - tradeQuote, - supportsEIP1559, - assertGetEvmChainAdapter, - }) => { - if (!isExecutableTradeQuote(tradeQuote)) { - throw new Error('Unable to execute a trade rate quote') - } - - const step = getExecutableTradeStep(tradeQuote, stepIndex) - - const { accountNumber, sellAsset, [swapperName]TransactionMetadata } = step - if (![swapperName]TransactionMetadata) { - throw new Error('Transaction metadata is required') - } - - const { value, to, data, gas } = [swapperName]TransactionMetadata - - const adapter = assertGetEvmChainAdapter(sellAsset.chainId) - - const feeData = await evm.getFees({ adapter, data, to, value, from, supportsEIP1559 }) - - return adapter.buildCustomApiTx({ - accountNumber, - data, - from, - to, - value, - ...feeData, - gasLimit: BigNumber.max(feeData.gasLimit, gas || '0').toFixed(), - }) - }, - - getEvmTransactionFees: async ({ - from, - stepIndex, - tradeQuote, - supportsEIP1559, - assertGetEvmChainAdapter, - }) => { - if (!isExecutableTradeQuote(tradeQuote)) { - throw new Error('Unable to execute a trade rate quote') - } - - const step = getExecutableTradeStep(tradeQuote, stepIndex) - - const { sellAsset, [swapperName]TransactionMetadata } = step - if (![swapperName]TransactionMetadata) { - throw new Error('Transaction metadata is required') - } - - const { value, to, data } = [swapperName]TransactionMetadata - - const adapter = assertGetEvmChainAdapter(sellAsset.chainId) - - const feeData = await evm.getFees({ adapter, data, to, value, from, supportsEIP1559 }) - - return feeData.networkFeeCryptoBaseUnit - }, - - checkTradeStatus: checkEvmSwapStatus, -} -``` - ---- - -## 10. [SwapperName]Swapper.ts - -```typescript -import type { Swapper } from '../../types' -import { executeEvmTransaction } from '../../utils' - -export const [swapperName]Swapper: Swapper = { - executeEvmTransaction, -} -``` - ---- - -## Registration in constants.ts - -```typescript -// In packages/swapper/src/constants.ts - -import { [swapperName]Swapper } from './swappers/[SwapperName]Swapper/[SwapperName]Swapper' -import { [swapperName]Api } from './swappers/[SwapperName]Swapper/endpoints' - -// Add to SwapperName enum -export enum SwapperName { - // ... existing swappers - [SwapperName] = '[SwapperName]', -} - -// Add to swappers record export const swappers: Record = { - // ... existing swappers - [SwapperName.[SwapperName]]: { - ...[swapperName]Swapper, - ...[swapperName]Api, - }, -} - -// Add default slippage -const DEFAULT_[SWAPPER]_SLIPPAGE_DECIMAL_PERCENTAGE = '0.005' // 0.5% - -// Update getDefaultSlippageDecimalPercentageForSwapper -export const getDefaultSlippageDecimalPercentageForSwapper = ( - swapperName: SwapperName | undefined, -): string => { - switch (swapperName) { - // ... existing cases - case SwapperName.[SwapperName]: - return DEFAULT_[SWAPPER]_SLIPPAGE_DECIMAL_PERCENTAGE - default: - return DEFAULT_SLIPPAGE_DECIMAL_PERCENTAGE - } + [SwapperName.Across]: { ...acrossSwapper, ...acrossApi }, + // ... } ``` - ---- - -## Types Registration - -```typescript -// In packages/swapper/src/types.ts - -// Add to SwapperConfig -export type SwapperConfig = { - // ... existing config - VITE_[SWAPPER]_API_KEY: string - VITE_[SWAPPER]_BASE_URL: string -} - -// Add to TradeQuoteStep (if using custom metadata) -export type TradeQuoteStep = { - // ... existing fields - [swapperName]TransactionMetadata?: { - to: string - from?: string - data: string - value: string - gas?: string - } -} -``` - ---- - -These templates provide a starting point. Always **adapt based on**: -- Actual API response structure -- Swapper-specific requirements -- Patterns from similar existing swappers diff --git a/.claude/skills/swapper-integration/reference.md b/.claude/skills/swapper-integration/reference.md index 36114bc97f6..a47f9035e80 100644 --- a/.claude/skills/swapper-integration/reference.md +++ b/.claude/skills/swapper-integration/reference.md @@ -55,12 +55,15 @@ These are combined and registered in `packages/swapper/src/constants.ts`. - Account creation fees - Compute unit calculations -**Examples**: JupiterSwapper +**Examples**: the solana arms of AcrossSwapper, ButterSwap, RelaySwapper (instruction routes); +BebopSwapper solana (sealed RFQ serialized tx) **Key differences:** - Implements `executeSolanaTransaction` -- Uses `solanaTransactionMetadata` -- Different fee calculations +- Quote carries `transactionData: { type: 'solana_instructions', instructions, addressLookupTableAddresses }` + with the static compute unit limit set via `withComputeUnitLimit` (or + `{ type: 'solana_serialized_tx', serializedTx }` for maker pre-signed RFQ txs - co-sign as-is, never rebuild) +- Execution fetches only the dynamic priority fee (adapter.getPriorityFees) - Account lookup tables (ALTs) **Use this pattern when**: Your swapper is Solana-specific. @@ -206,19 +209,23 @@ Every step must include: buyAmountAfterFeesCryptoBaseUnit: string sellAmountIncludingProtocolFeesCryptoBaseUnit: string feeData: { - protocolFees: Record - networkFeeCryptoBaseUnit: string + protocolFees: QuoteFeeData['protocolFees'] + networkFeeCryptoBaseUnit: string | undefined } rate: string source: SwapSource buyAsset: Asset sellAsset: Asset - accountNumber: number | undefined - allowanceContract: string | undefined + accountNumber: number // quote steps; rate steps: number | undefined (from input) + allowanceContract: string // '' when there is no approval target estimatedExecutionTimeMs: number | undefined - // Swapper-specific metadata (pick one): - xyzTransactionMetadata?: { ... } + // Executable payload (quote steps only - TradeRateStep bans it): + transactionData?: TxBuildData // evm | utxo | solana_instructions | solana_serialized_tx | + // cosmossdk_msg_send | cosmossdk_msg_deposit | cowswap | ton | tron + + // Provider tracking data (only if status/exec needs it - a SwapperMetadata union member): + swapperMetadata?: SwapperMetadata } ``` diff --git a/.env b/.env index 4dd4f90ced3..859d961f7c0 100644 --- a/.env +++ b/.env @@ -12,9 +12,6 @@ VITE_FEATURE_MAYACHAIN=true VITE_FEATURE_BASE=true VITE_FEATURE_OPTIMISM=true VITE_FEATURE_ZCASH=true -VITE_FEATURE_SAVERS_VAULTS=true -VITE_FEATURE_SAVERS_VAULTS_DEPOSIT=false -VITE_FEATURE_SAVERS_VAULTS_WITHDRAW=false VITE_FEATURE_WALLET_CONNECT_TO_DAPPS_V2=true VITE_FEATURE_WALLET_CONNECT_TO_DAPPS=true VITE_FEATURE_ADVANCED_SLIPPAGE=true @@ -23,9 +20,6 @@ VITE_FEATURE_TREZOR_WALLET=true VITE_FEATURE_VULTISIG_WALLET=true VITE_FEATURE_WALLET_CONNECT_V2=true VITE_FEATURE_WC_DIRECT_CONNECTION=false -VITE_FEATURE_THORCHAIN_LENDING=true -VITE_FEATURE_THORCHAIN_LENDING_BORROW=false -VITE_FEATURE_THORCHAIN_LENDING_REPAY=false VITE_FEATURE_THORCHAINSWAP_LONGTAIL=true VITE_FEATURE_THORCHAINSWAP_L1_TO_LONGTAIL=true VITE_FEATURE_THORCHAIN_LP=true diff --git a/.gitignore b/.gitignore index 79c806ad266..026435889ea 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ yarn-error.log* # translation benchmark data scripts/translations/benchmark/ .gemini/ + +# agent tooling working docs (superpowers specs/plans, local notes) +docs/superpowers/ diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 0ea7cbbcd7b..00000000000 --- a/TODO.md +++ /dev/null @@ -1,527 +0,0 @@ -# TODO - -Untracked working notes — pending work pulled from auto-memory plus the in-flight -refactor identified in this session. Not for commit. - ---- - -## Refactor: split `*TransactionMetadata` from `SwapperSpecificMetadata` - -**Origin**: 2026-05-08 review of `packages/swapper/src/types.ts` while threading -THORChain transaction data through the public-api. - -**Problem**: today's `*TransactionMetadata` structs (e.g. `RelayTransactionMetadata`, -`DebridgeTransactionMetadata`, `nearIntentsSpecific`) bag two unrelated concerns -into a single shape: - -1. **Build-time tx params** — `to`, `data`, `value`, `gasLimit`, `psbt`, `opReturnData` -2. **Post-submission tracking tokens** — `relayId`, `orderId`, `isSameChainSwap`, etc. - -The same struct lives on both `TradeQuoteStep` (where the build half is consumed) -and on `Swap.metadata` / `StoredQuote.metadata` via `SwapperSpecificMetadata` (where -only the tracking half is consumed). Each phase ignores the irrelevant half. - -**Cruft confirmed**: `acrossTransactionMetadata` is copied into -`SwapperSpecificMetadata` (`packages/public-api/src/routes/quote/getQuote.ts:202`) -but `AcrossSwapper.checkTradeStatus` (`endpoints.ts:71-140`) never reads it — it -polls by tx hash. Field is fully dead post-submission. - -**Plan**: - -1. Split each `*TransactionMetadata` into `*TxBuild` (step-only) and - `*Tracking` (swap-only). -2. Keep build halves on `TradeQuoteStep`. Keep tracking halves on - `SwapperSpecificMetadata`. -3. Update all `step.TransactionMetadata` consumers - (`packages/swapper/src/swappers/*/endpoints.ts`, - `packages/public-api/src/routes/quote/extractTransactionData.ts`) to read - from the build half. -4. Update all `swap.metadata.TransactionMetadata` consumers - (`RelaySwapper/endpoints.ts:189-223`, `DebridgeSwapper/endpoints.ts:140`, - `useSwapActionSubscriber.tsx:256`) to read from the tracking half. -5. Drop `acrossTransactionMetadata` from `SwapperSpecificMetadata` entirely + - stop copying it in `getQuote.ts`. - -**Why bother**: real, modest readability win — types tell the truth about -lifecycle. Step has only what's needed to build a tx; swap has only what's needed -to track one. `thorchainTransactionMetadata` (just added) is already on the clean -side of this and stays step-only. - -**Cost**: medium churn. Touches all `*Swapper` packages plus `public-api`. -Defer until the THORChain widget integration work has settled. - ---- - -## Quote expiry enforcement (widget + public-api contract) - -**Origin**: 2026-05-08 discussion of why the swap-widget doesn't call -`swapper.getUnsigned*Transaction` at sign time. Conclusion: trusting the -public-api response is correct (external consumers can't reach the swapper -directly), but staleness must be guarded by an enforced quote expiry. - -**Problem**: today's expiry data is plumbed but not load-bearing. - -- THORNode returns an `expiry` on `/quote/swap`; we capture it on - `thorchainTransactionMetadata.expiry` - (`packages/swapper/src/types.ts:427`, set in - `packages/swapper/src/thorchain-utils/getL1RateOrQuote.ts:301,348`). -- Public-api response carries an envelope-level `expiresAt` - (`packages/public-api/src/routes/quote/types.ts:152`), but it's the - **server cache TTL** (`QuoteStore.QUOTE_TTL_MS` = 15 min), not the - THORChain vault expiry. Server TTL can outlast the network's accept window. -- `QuoteStepSchema` exposes no expiry — external API consumers can't see - the binding deadline even if they want to honor it. -- Widget mirrors `expiresAt` in its types - (`packages/swap-widget/src/types/index.ts:222`) but never reads it: no - countdown, no pre-sign guard, no auto re-quote. - -**Risk**: signing against a rotated THORChain vault (funds lost/delayed) -or against stale fees. - -**Plan**: - -1. Surface per-step expiry on the public-api response. Either lift - `thorchainTransactionMetadata.expiry` onto the step, or compute envelope - `expiresAt = min(server TTL, all step expiries)` so the envelope is the - binding deadline. Latter keeps the contract surface small. -2. Widget: block sign when `Date.now() >= expiresAt`, auto-refetch quote, - show a countdown in the UI. -3. Document the contract for external API consumers in the public-api - README: "do not sign after `expiresAt`; re-fetch the quote." - -**Note**: the existing "getQuote: quote expiry mismatch" item in the -edge-cases list (response says 1 min, stored 15 min) is a related but -distinct bug — this section is the broader design fix that subsumes it. - ---- - -## THORChain widget integration — remaining gaps - -- **swap-widget execution hook**: `useSwapExecution.ts` throws "not yet - supported" for `utxo_psbt` and `cosmos` `transactionData.type` values. -- **THORChain native (RUNE/TCY/RUJI)** — uses `MsgDeposit` with no `to` - address. Current `CosmosTransactionData` type doesn't model this shape, so - cosmos execution can't dispatch THORChain native swaps. - ---- - -## swap-widget execution/approval refactor (deferred) - -`useSwapExecution.ts` and `useSwapApproval.ts` should move from `useEffect`-driven -("watch for `stateValue === 'executing'` and react") to imperative async functions -returned for direct `onClick` invocation. Local `isPending` replaces the -`executingRef`/`approvingRef` guards; try/catch at the call site replaces the -actor-send error dispatch. - -**Why**: execution is a user-initiated mutation. Modeling it as effect-driven -inverts the call flow, requires refs to guard against dep-array re-fires that -wouldn't exist outside an effect, and forces awkward eslint dep choices. -Discussed and parked 2026-05-07 after cleaning up the existing hook (THORChain -pass-through + dispatch on `transactionData.type` + `getErrorMessage` helper). - -**Before refactoring, verify**: - -1. Who currently sends `FETCH_QUOTE` / `APPROVE`? The machine auto-transitions - `quoting → executing` on `QUOTE_SUCCESS` and `approving → executing` on - `APPROVAL_SUCCESS` (`packages/swap-widget/src/machines/swapMachine.ts`). If - the UX is "quote success auto-fires execution" with no second confirm click, - the dispatcher of `QUOTE_SUCCESS` / `APPROVAL_SUCCESS` must also invoke the - new imperative `executeSwap()`, or the user gains a click they didn't have. -2. Is the `executing` machine state referenced for UI elsewhere (e.g. - "Submitting..." in `SwapWidget.tsx`)? If so, either keep tracking via local - `isPending` on the button or send an `EXECUTE_START` event into the machine - before awaiting. - -**Target shape**: - -```ts -export const useSwapExecution = () => { - const actorRef = SwapMachineCtx.useActorRef() - const { walletClient, walletAddress, bitcoin, solana } = useSwapWallet() - return useCallback(async () => { /* same body */ }, [actorRef, walletClient, walletAddress, bitcoin, solana]) -} -``` - -Caller: `const executeSwap = useSwapExecution()` → -`