| name | swapper-integration |
| description | Integrate new DEX aggregators, swappers, or bridge protocols (like Bebop, Portals, Jupiter, 0x, 1inch, etc.) into ShapeShift Web. Activates when user wants to add, integrate, or implement support for a new swapper. Guides through research, implementation, and testing following established patterns. (project) |
| allowed-tools | Read, Write, Edit, Grep, Glob, WebFetch, WebSearch, Bash(pnpm run test:*), Bash(pnpm run lint:*), Bash(pnpm run type-check), Bash(pnpm run build:*), Bash(gh pr:*), AskUserQuestion |
Swapper Integration Skill
You are an expert at integrating DEX aggregators, swappers, and bridge protocols into ShapeShift Web. This skill guides you through the complete process from API research to production-ready implementation.
When This Skill Activates
Use this skill when the user wants to:
- "Integrate [SwapperName] swapper"
- "Add support for [Protocol]"
- "Implement [DEX] integration"
- "Add [Aggregator] as a swapper"
- "Integrate [new swapper]"
Overview
ShapeShift Web is a decentralized crypto exchange aggregator that supports multiple swap providers through a unified interface. Each swapper implements standardized TypeScript interfaces (Swapper and SwapperApi) but has variations based on blockchain type (EVM, UTXO, Solana, Sui, Tron) and swapper model (direct transaction, deposit-to-address, gasless order-based).
Core Architecture:
- Location:
packages/swapper/src/swappers/
- Interfaces:
Swapper (execution) + SwapperApi (quotes/rates/status)
- Types: Strongly typed with chain-specific adaptations
- 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.
Workflow
Phase 0: Pre-Research (Use WebFetch / WebSearch)
BEFORE asking the user for anything, proactively research the swapper online:
-
Search for official documentation:
Search: "[SwapperName] API documentation"
Search: "[SwapperName] developer docs"
Search: "[SwapperName] swagger api"
-
Find their website and look for:
- API docs link
- Developer portal
- GitHub repos with examples
- Public API endpoints
- Known integrations
-
Fetch their API docs using WebFetch:
- Main documentation page
- Swagger/OpenAPI spec (if available)
- Example requests/responses
-
Research chain support:
Search: "[SwapperName] supported chains"
Search: "[SwapperName] which blockchains"
-
Find existing integrations:
Search: "github [SwapperName] integration example"
Search: "[SwapperName] typescript sdk"
Then, compile what you found and ask the user ONLY for what you couldn't find or need confirmation on.
Phase 1: Information Gathering
Use the AskUserQuestion tool to gather missing information with structured prompts.
Based on your Phase 0 research, ask the user for:
-
API Access (if needed):
- API key for production (or staging)
- Any authentication requirements you found
- Confirmation of API endpoints you discovered
-
Chain Support Confirmation:
- Verify the chains you found are correct
- Ask about any limitations or special requirements per chain
- Confirm chain naming convention (ethereum vs 1 vs mainnet)
-
Critical API Behaviors (if not clear from docs):
- Slippage format: percentage (1=1%), decimal (0.01=1%), or basis points (100=1%)?
- Address format: checksummed required?
- Native token handling: marker address? which one?
- Min/max trade amounts?
- Quote expiration time?
-
Brand Assets:
- Confirm official name and capitalization
- Request logo/icon (128x128+ PNG preferred)
-
Known Issues:
- Any quirks they're aware of?
- Previous integration attempts or examples?
Example Multi-Question Prompt:
AskUserQuestion({
questions: [
{
question: "Do we have an API key for [Swapper]?",
header: "API Key",
multiSelect: false,
options: [
{ label: "Yes, I have it", description: "I'll provide the API key" },
{ label: "No, but we can get one", description: "I'll obtain an API key" },
{ label: "No API key needed", description: "API is public/unauthenticated" }
]
},
{
question: "Which chains should we support initially?",
header: "Chain Support",
multiSelect: true,
options: [
{ label: "Ethereum", description: "Ethereum mainnet" },
{ label: "Polygon", description: "Polygon PoS" },
{ label: "Arbitrum", description: "Arbitrum One" },
{ label: "All supported chains", description: "Enable all chains the API supports" }
]
}
]
})
Phase 2: Deep Research & Pattern Analysis
IMPORTANT: Study existing swappers BEFORE writing any code. This prevents reimplementing solved problems.
Step 1: Identify Swapper Category
Based on API research, determine the swapper type:
EVM Direct Transaction (Most Common):
- Characteristics: Single EVM chain, returns transaction data, user signs & broadcasts
- Examples: Bebop, 0x, Portals
- Key Files:
bebopTransactionMetadata, zrxTransactionMetadata, portalsTransactionMetadata
- 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
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
Solana-Only:
- Characteristics: Solana transaction with instructions and ALTs
- Examples: Jupiter
- Key Files:
jupiterQuoteResponse, solanaTransactionMetadata
- Choose this if: Solana ecosystem only
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
Step 2: Study 2-3 Similar Swappers IN DEPTH
Read these files for your chosen swapper type:
packages/swapper/src/swappers/BebopSwapper/
├── BebopSwapper.ts
├── endpoints.ts
├── types.ts
├── getBebopTradeQuote/
│ └── getBebopTradeQuote.ts
├── getBebopTradeRate/
│ └── getBebopTradeRate.ts
└── utils/
├── constants.ts
├── bebopService.ts
├── fetchFromBebop.ts
└── helpers/
└── helpers.ts
Read these files for deposit-to-address (e.g., NEAR Intents):
packages/swapper/src/swappers/NearIntentsSwapper/
├── endpoints.ts
├── swapperApi/
│ ├── getTradeQuote.ts
│ └── getTradeRate.ts
└── utils/
├── oneClickService.ts
└── helpers/
└── helpers.ts
Critical things to note while reading:
- How do they call the API? (HTTP service pattern? SDK? Direct axios?)
- How do they handle errors? (Monadic
Result<T, SwapErrorRight> pattern)
- How do they calculate rates? (
getInputOutputRate util vs custom)
- What metadata do they store in
TradeQuoteStep?
- How do they validate inputs? (Supported chains? Asset compatibility?)
- How do they handle native tokens? (Marker address vs special field)
- How do they convert API responses to our types?
Step 3: Review Common Patterns
Key Pattern: Monadic Error Handling
import { Err, Ok } from '@sniptt/monads'
import { makeSwapErrorRight } from '../../../utils'
const result = await someOperation()
if (result.isErr()) {
return Err(makeSwapErrorRight({
message: 'What went wrong',
code: TradeQuoteError.QueryFailed,
details: { context: 'here' }
}))
}
return Ok(result.unwrap())
Key Pattern: HTTP Service with Caching
import { createCache, makeSwapperAxiosServiceMonadic } from '../../../utils'
const maxAge = 5 * 1000
const cachedUrls = ['/quote', '/price']
const serviceBase = createCache(maxAge, cachedUrls, {
timeout: 10000,
headers: {
'Accept': 'application/json',
'x-api-key': config.VITE_XYZ_API_KEY
}
})
export const xyzService = makeSwapperAxiosServiceMonadic(serviceBase)
Key Pattern: Rate Limiting and Throttling
For chain adapters and swappers that directly interact with RPC endpoints or APIs:
import PQueue from 'p-queue'
private requestQueue: PQueue = new PQueue({
intervalCap: 1,
interval: 50,
concurrency: 1,
})
const quote = await this.requestQueue.add(() =>
swapperService.get('/quote', { params })
)
const balance = await this.requestQueue.add(() =>
this.provider.getBalance(address)
)
When to use: Any swapper or chain adapter making direct RPC/API calls (especially public endpoints)
Example implementations: MonadChainAdapter, PlasmaChainAdapter
Key Pattern: Rate Calculation
import { getInputOutputRate } from '../../../utils'
const rate = getInputOutputRate({
sellAmountCryptoBaseUnit,
buyAmountCryptoBaseUnit,
sellAsset,
buyAsset
})
Phase 3: Implementation (Step by Step)
Follow this EXACT order to avoid rework:
Step 1: Create Directory Structure
mkdir -p packages/swapper/src/swappers/[SwapperName]Swapper/{get[SwapperName]TradeQuote,get[SwapperName]TradeRate,utils/helpers}
Standard structure (EVM 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
Step 2: Implement Files in Order
2a. types.ts - API TypeScript Types
Define types EXACTLY matching the API response (log actual API responses to verify!):
import type { Address, Hex } from 'viem'
export type [Swapper]QuoteRequest = {
sellToken: Address
buyToken: Address
sellAmount: string
slippage: number
takerAddress: Address
receiverAddress?: Address
chainId: number
}
export type [Swapper]QuoteResponse = {
buyAmount: string
sellAmount: string
transaction: {
to: Address
data: Hex
value: Hex
gas?: Hex
}
}
export const [SWAPPER]_SUPPORTED_CHAIN_IDS: Record<number, string> = {
1: 'ethereum',
137: 'polygon',
42161: 'arbitrum',
}
2b. utils/constants.ts - Configuration
import type { AssetId, ChainId } from '@shapeshiftoss/caip'
import { ethChainId, polygonChainId, arbitrumChainId } from '@shapeshiftoss/caip'
import type { Address } from 'viem'
export const SUPPORTED_CHAIN_IDS = [
ethChainId,
polygonChainId,
arbitrumChainId,
] as const
export type [Swapper]SupportedChainId = (typeof SUPPORTED_CHAIN_IDS)[number]
export const NATIVE_TOKEN_MARKER = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' as Address
export const DUMMY_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as Address
export const DEFAULT_SLIPPAGE_PERCENTAGE = '0.5'
2c. utils/helpers/helpers.ts - Helper Functions
import { fromAssetId, type AssetId } from '@shapeshiftoss/caip'
import { isToken } from '@shapeshiftoss/utils'
import { getAddress, type Address } from 'viem'
import { NATIVE_TOKEN_MARKER, SUPPORTED_CHAIN_IDS } from '../constants'
export const isSupportedChainId = (chainId: string): boolean => {
return SUPPORTED_CHAIN_IDS.includes(chainId as any)
}
export const assetIdToToken = (assetId: AssetId): Address => {
if (!isToken(assetId)) {
return NATIVE_TOKEN_MARKER
}
const { assetReference } = fromAssetId(assetId)
return getAddress(assetReference)
}
export const chainIdToChainRef = (chainId: string): string => {
switch (chainId) {
case ethChainId:
return 'ethereum'
case polygonChainId:
return 'polygon'
default:
throw new Error(`Unsupported chainId: ${chainId}`)
}
}
import { getInputOutputRate } from '../../../../utils'
export { getInputOutputRate }
2d. utils/[swapperName]Service.ts - HTTP Service
import { createCache, makeSwapperAxiosServiceMonadic } from '../../../utils'
import type { SwapperConfig } from '../../../types'
const maxAge = 5 * 1000
const cachedUrls = ['/quote', '/price']
export const [swapperName]ServiceFactory = (config: SwapperConfig) => {
const axiosConfig = {
timeout: 10000,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
...(config.VITE_[SWAPPER]_API_KEY && {
'x-api-key': config.VITE_[SWAPPER]_API_KEY
})
}
}
const serviceBase = createCache(maxAge, cachedUrls, axiosConfig)
return makeSwapperAxiosServiceMonadic(serviceBase)
}
export type [Swapper]Service = ReturnType<typeof [swapperName]ServiceFactory>
2e. utils/fetchFrom[SwapperName].ts - API Wrappers
import { type AssetId } from '@shapeshiftoss/caip'
import { bn } from '@shapeshiftoss/utils'
import { Err, Ok, type Result } from '@sniptt/monads'
import { getAddress, type Address } from 'viem'
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'
const BASE_URL = 'https://api.[swapper].com'
export type FetchQuoteParams = {
sellAssetId: AssetId
buyAssetId: AssetId
sellAmountCryptoBaseUnit: string
chainId: string
takerAddress: string
receiverAddress: string
slippageTolerancePercentageDecimal: string
affiliateBps: string
}
export const fetchQuote = async (
params: FetchQuoteParams,
service: [Swapper]Service
): Promise<Result<[Swapper]QuoteResponse, SwapErrorRight>> => {
try {
const {
sellAssetId,
buyAssetId,
sellAmountCryptoBaseUnit,
chainId,
takerAddress,
receiverAddress,
slippageTolerancePercentageDecimal,
affiliateBps
} = params
const sellToken = assetIdToToken(sellAssetId)
const buyToken = assetIdToToken(buyAssetId)
const chainRef = chainIdToChainRef(chainId)
const slippagePercentage = bn(slippageTolerancePercentageDecimal)
.times(100)
.toNumber()
const checksummedTakerAddress = getAddress(takerAddress)
const checksummedReceiverAddress = getAddress(receiverAddress)
const requestBody: [Swapper]QuoteRequest = {
sellToken,
buyToken,
sellAmount: sellAmountCryptoBaseUnit,
slippage: slippagePercentage,
takerAddress: checksummedTakerAddress,
receiverAddress: checksummedReceiverAddress,
chainId: chainRef,
...(affiliateBps !== '0' && { affiliateBps })
}
const maybeResponse = await service.post<[Swapper]QuoteResponse>(
`${BASE_URL}/quote`,
requestBody
)
if (maybeResponse.isErr()) {
return Err(maybeResponse.unwrapErr())
}
const { data: response } = maybeResponse.unwrap()
if (!response.buyAmount || !response.transaction) {
return Err(
makeSwapErrorRight({
message: 'Invalid response from API',
code: TradeQuoteError.InvalidResponse,
details: { response }
})
)
}
return Ok(response)
} catch (error) {
return Err(
makeSwapErrorRight({
message: 'Failed to fetch quote',
code: TradeQuoteError.QueryFailed,
cause: error
})
)
}
}
export type FetchPriceParams = Omit<FetchQuoteParams, 'takerAddress' | 'receiverAddress'> & {
receiveAddress: string | undefined
}
export const fetchPrice = async (
params: FetchPriceParams,
service: [Swapper]Service
): Promise<Result<[Swapper]QuoteResponse, SwapErrorRight>> => {
const address = params.receiveAddress
? getAddress(params.receiveAddress)
: DUMMY_ADDRESS
return fetchQuote(
{
...params,
takerAddress: address,
receiverAddress: address
},
service
)
}
2f. get[SwapperName]TradeQuote/get[SwapperName]TradeQuote.ts - Quote Logic
This is the MEAT of the implementation. It must:
- Validate inputs (chain support, asset compatibility)
- Fetch quote from API
- Estimate network fees using chain adapter
- Build complete TradeQuote object with all required fields
- Handle errors monadic-ally
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<Result<TradeQuote, SwapErrorRight>> => {
try {
const {
sellAsset,
buyAsset,
sellAmountIncludingProtocolFeesCryptoBaseUnit,
sendAddress,
receiveAddress,
accountNumber,
affiliateBps,
slippageTolerancePercentageDecimal
} = input
const { config, assertGetEvmChainAdapter } = deps
if (!isSupportedChainId(sellAsset.chainId)) {
return Err(
makeSwapErrorRight({
message: `[${SwapperName.[SwapperName]}] Unsupported chainId: ${sellAsset.chainId}`,
code: TradeQuoteError.UnsupportedChain,
details: { chainId: sellAsset.chainId }
})
)
}
if (sellAsset.chainId !== buyAsset.chainId) {
return Err(
makeSwapErrorRight({
message: `[${SwapperName.[SwapperName]}] Cross-chain not supported`,
code: TradeQuoteError.CrossChainNotSupported
})
)
}
const takerAddress = sendAddress ?? receiveAddress
if (takerAddress === DUMMY_ADDRESS) {
return Err(
makeSwapErrorRight({
message: 'Cannot execute trade with dummy address',
code: TradeQuoteError.UnknownError
})
)
}
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()
const adapter = assertGetEvmChainAdapter(sellAsset.chainId)
const { average: { gasPrice } } = await adapter.getGasFeeData()
const networkFeeCryptoBaseUnit = bn(quoteResponse.transaction.gas ?? '0')
.times(gasPrice)
.toFixed(0)
const rate = getInputOutputRate({
sellAmountCryptoBaseUnit: sellAmountIncludingProtocolFeesCryptoBaseUnit,
buyAmountCryptoBaseUnit: quoteResponse.buyAmount,
sellAsset,
buyAsset
})
const tradeQuote: TradeQuote = {
id: crypto.randomUUID(),
quoteOrRate: 'quote',
rate,
slippageTolerancePercentageDecimal,
receiveAddress,
affiliateBps,
steps: [
{
buyAmountBeforeFeesCryptoBaseUnit: quoteResponse.buyAmount,
buyAmountAfterFeesCryptoBaseUnit: quoteResponse.buyAmount,
sellAmountIncludingProtocolFeesCryptoBaseUnit,
feeData: {
networkFeeCryptoBaseUnit,
protocolFees: {},
},
rate,
source: SwapperName.[SwapperName],
buyAsset,
sellAsset,
accountNumber,
allowanceContract: isNativeEvmAsset(sellAsset.assetId)
? undefined
: quoteResponse.approvalTarget,
estimatedExecutionTimeMs: undefined,
[swapperName]TransactionMetadata: {
to: quoteResponse.transaction.to,
data: quoteResponse.transaction.data,
value: quoteResponse.transaction.value,
gas: quoteResponse.transaction.gas
}
}
],
swapperName: SwapperName.[SwapperName]
}
return Ok(tradeQuote)
} catch (error) {
return Err(
makeSwapErrorRight({
message: 'Failed to get trade quote',
code: TradeQuoteError.UnknownError,
cause: error
})
)
}
}
2g. get[SwapperName]TradeRate/get[SwapperName]TradeRate.ts - Rate Logic
Similar to quote but:
- No wallet address required (use dummy or undefined)
- accountNumber is undefined
- May skip network fee estimation (or use cached/estimated)
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<Result<TradeRate, SwapErrorRight>> => {
try {
const {
sellAsset,
buyAsset,
sellAmountIncludingProtocolFeesCryptoBaseUnit,
receiveAddress,
affiliateBps,
slippageTolerancePercentageDecimal
} = input
const { config } = deps
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
})
)
}
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
)
if (maybeRateResponse.isErr()) {
return Err(maybeRateResponse.unwrapErr())
}
const rateResponse = maybeRateResponse.unwrap()
const rate = getInputOutputRate({
sellAmountCryptoBaseUnit: sellAmountIncludingProtocolFeesCryptoBaseUnit,
buyAmountCryptoBaseUnit: rateResponse.buyAmount,
sellAsset,
buyAsset
})
const tradeRate: TradeRate = {
id: crypto.randomUUID(),
quoteOrRate: 'rate',
rate,
slippageTolerancePercentageDecimal,
receiveAddress,
affiliateBps,
steps: [
{
buyAmountBeforeFeesCryptoBaseUnit: rateResponse.buyAmount,
buyAmountAfterFeesCryptoBaseUnit: rateResponse.buyAmount,
sellAmountIncludingProtocolFeesCryptoBaseUnit,
feeData: {
networkFeeCryptoBaseUnit: undefined,
protocolFees: {}
},
rate,
source: SwapperName.[SwapperName],
buyAsset,
sellAsset,
accountNumber: undefined,
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
})
)
}
}
2h. endpoints.ts - SwapperApi Implementation
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'
export const [swapperName]Api: SwapperApi = {
getTradeQuote: async (
input: GetEvmTradeQuoteInput | CommonTradeQuoteInput,
deps: SwapperDeps
): Promise<TradeQuoteResult> => {
const maybeTradeQuote = await get[SwapperName]TradeQuote(input, deps)
return maybeTradeQuote.map(quote => [quote])
},
getTradeRate: async (
input: GetTradeRateInput,
deps: SwapperDeps
): Promise<TradeRateResult> => {
const maybeTradeRate = await get[SwapperName]TradeRate(input, deps)
return maybeTradeRate.map(rate => [rate])
},
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')
}
const adapter = assertGetEvmChainAdapter(chainId)
const value = metadata.value
? fromHex(metadata.value as Hex, 'bigint').toString()
: '0'
const gasLimit = metadata.gas
? fromHex(metadata.gas as Hex, 'bigint').toString()
: undefined
return {
chainId: Number(fromChainId(chainId).chainReference),
to: metadata.to,
from,
data: metadata.data,
value,
gasLimit,
}
},
getEvmTransactionFees: async (args: GetUnsignedEvmTransactionArgs) => {
const { tradeQuote, chainId, assertGetEvmChainAdapter, stepIndex } = args
const step = tradeQuote.steps[stepIndex]
const adapter = assertGetEvmChainAdapter(chainId)
const { average: { gasPrice } } = await adapter.getGasFeeData()
const metadata = step.[swapperName]TransactionMetadata
const apiGasEstimate = metadata?.gas
? fromHex(metadata.gas as Hex, 'bigint').toString()
: '0'
const networkFeeCryptoBaseUnit = bn
.max(step.feeData.networkFeeCryptoBaseUnit ?? '0', apiGasEstimate)
.times(1.15)
.toFixed(0)
return networkFeeCryptoBaseUnit
},
checkTradeStatus: checkEvmSwapStatus
}
2i. [SwapperName]Swapper.ts - Swapper Interface
For most EVM swappers, this is simple:
import { executeEvmTransaction } from '../utils'
import type { Swapper } from '../../types'
export const [swapperName]Swapper: Swapper = {
executeEvmTransaction
}
For deposit-to-address or custom execution, implement custom logic here.
2j. index.ts - Exports
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).
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
Three places to modify:
a. packages/swapper/src/types.ts - Add to TradeQuoteStep:
export type TradeQuoteStep = {
[swapperName]Specific?: {
depositAddress: string
swapId: string | number
memo?: string
deadline?: string
}
}
b. packages/swapper/src/types.ts - Add to SwapperSpecificMetadata:
export type SwapperSpecificMetadata = {
chainflipSwapId: number | undefined
nearIntentsSpecific?: { ... }
[swapperName]Specific?: {
depositAddress: string
swapId: string | number
memo?: string
deadline?: string
}
relayTransactionMetadata: RelayTransactionMetadata | undefined
}
c. Populate in quote (get[SwapperName]TradeQuote.ts):
const tradeQuote: TradeQuote = {
steps: [{
[swapperName]Specific: {
depositAddress: quoteResponse.depositAddress,
swapId: quoteResponse.id,
memo: quoteResponse.memo,
deadline: quoteResponse.deadline
}
}]
}
d. Extract into swap (TWO places - BOTH required!):
Place 1: src/components/MultiHopTrade/components/TradeConfirm/hooks/useTradeButtonProps.tsx
metadata: {
chainflipSwapId: firstStep?.chainflipSpecific?.chainflipSwapId,
nearIntentsSpecific: firstStep?.nearIntentsSpecific,
[swapperName]Specific: firstStep?.[swapperName]Specific,
relayTransactionMetadata: firstStep?.relayTransactionMetadata,
}
Place 2: src/lib/tradeExecution.ts
metadata: {
...swap.metadata,
chainflipSwapId: tradeQuote.steps[0]?.chainflipSpecific?.chainflipSwapId,
nearIntentsSpecific: tradeQuote.steps[0]?.nearIntentsSpecific,
[swapperName]Specific: tradeQuote.steps[0]?.[swapperName]Specific,
relayTransactionMetadata: tradeQuote.steps[0]?.relayTransactionMetadata,
}
e. Use in status check (endpoints.ts):
checkTradeStatus: async ({ swap, config }) => {
const { [swapperName]Specific } = swap?.metadata ?? {}
if (![swapperName]Specific?.depositAddress) {
throw new Error('Missing depositAddress in swap metadata')
}
const status = await pollSwapStatus(
[swapperName]Specific.depositAddress,
[swapperName]Specific.swapId,
config
)
return {
status: mapApiStatusToTxStatus(status.state),
buyTxHash: status.outputTxHash,
message: status.message
}
}
Step 4: Register the Swapper
4a. packages/swapper/src/types.ts - Add Config Fields
export type SwapperConfig = {
VITE_[SWAPPER]_API_KEY: string
VITE_[SWAPPER]_BASE_URL?: string
}
4b. packages/swapper/src/constants.ts - Register Swapper
export enum SwapperName {
[SwapperName] = '[Display Name]',
}
export const swappers: Record<SwapperName, { swapper: Swapper; swapperApi: SwapperApi }> = {
[SwapperName.[SwapperName]]: {
swapper: [swapperName]Swapper,
swapperApi: [swapperName]Api
}
}
export const DEFAULT_SLIPPAGE_DECIMAL_PERCENTAGE_BY_SWAPPER: Record<
SwapperName,
string | undefined
> = {
[SwapperName.[SwapperName]]: '0.005',
}
4c. packages/swapper/src/index.ts - Export
export { [swapperName]Api, [swapperName]Swapper } from './swappers/[SwapperName]Swapper'
4d. CSP Headers (if swapper calls external API)
Create headers/csps/defi/swappers/[SwapperName].ts:
import type { Csp } from '../../../types'
export const csp: Csp = {
'connect-src': [
'https://api.[swapper].com',
'https://api.[swapper].io',
]
}
Register in headers/csps/index.ts:
import { csp as [swapperName] } from './defi/swappers/[SwapperName]'
export const csps = [
[swapperName],
]
4e. UI - Feature Flag
Add to src/state/slices/preferencesSlice/preferencesSlice.ts:
export type FeatureFlags = {
BebopSwap: boolean
}
const initialState: Preferences = {
featureFlags: {
BebopSwap: getConfig().VITE_FEATURE_BEBOP_SWAP
}
}
4f. Wire Feature Flag
In src/state/helpers.ts:
Add to isCrossAccountTradeSupported (if supported):
export const isCrossAccountTradeSupported = (swapperName: SwapperName): boolean => {
switch (swapperName) {
case SwapperName.Bebop:
return true
}
}
Add to getEnabledSwappers:
export const getEnabledSwappers = (
{
BebopSwap,
}: FeatureFlags,
isCrossAccountTrade: boolean,
isSolBuyAssetId: boolean
): Record<SwapperName, boolean> => {
return {
[SwapperName.Bebop]:
BebopSwap &&
(!isCrossAccountTrade || isCrossAccountTradeSupported(SwapperName.Bebop))
}
}
4g. Test Mocks
In src/test/mocks/store.ts:
featureFlags: {
BebopSwap: false
}
4h. Swapper Icon
In UI:
Add icon: src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/[swapper]-icon.png
Update SwapperIcon.tsx:
import [swapperName]Icon from './[swapper]-icon.png'
const SwapperIcon = ({ swapperName }: Props) => {
switch (swapperName) {
case SwapperName.[SwapperName]:
return <Image src={[swapperName]Icon} />
}
}
4i. Environment Variables
.env (production - both OFF):
VITE_[SWAPPER]_API_KEY=
VITE_FEATURE_[SWAPPER]_SWAP=false
.env.development (development - flag ON):
VITE_[SWAPPER]_API_KEY=your-dev-api-key-here
VITE_FEATURE_[SWAPPER]_SWAP=true
Add to src/config.ts:
export const getConfig = (): Config => ({
VITE_[SWAPPER]_API_KEY: import.meta.env.VITE_[SWAPPER]_API_KEY || '',
VITE_FEATURE_[SWAPPER]_SWAP: parseBoolean(import.meta.env.VITE_FEATURE_[SWAPPER]_SWAP)
})
Step 5: Proactive Gotcha Review
BEFORE testing, check for these critical bugs:
- Slippage Format: Verify API format (percentage, decimal, basis points)
- Address Checksumming: Use
getAddress() from viem
- Hex Conversion: Use
fromHex() for tx.value, tx.gas, tx.gasPrice
- Response Parsing: Log actual API response, verify structure matches types
- Affiliate Fees: Pass same
affiliateBps to BOTH quote and rate endpoints
- Native Token Marker: Verify marker address matches API requirements
- Gas Estimation: Take max of API and node estimates, add buffer
- Dummy Address: Block executable quotes with dummy address
- Error Handling: Don't reject quote if some routes fail (e.g., dual routing)
- Type Safety: Use
Address and Hex types from viem, not strings
Phase 4: Testing & Validation
4a. Automated Checks
pnpm run type-check
pnpm run lint
pnpm run build:swapper
pnpm run build:web
Fix ALL type errors and lint errors before manual testing.
4b. Manual Testing Checklist
4c. Edge Cases
Phase 5: Documentation
Create packages/swapper/src/swappers/[SwapperName]Swapper/INTEGRATION.md:
# [Swapper Name] Integration
## Overview
- **Website**: https://[swapper].com
- **API Docs**: https://docs.[swapper].com
- **Supported Chains**: Ethereum, Polygon, Arbitrum, ...
- **Type**: EVM Direct Transaction / Deposit-to-Address / Gasless
## API Details
- **Base URL**: `https://api.[swapper].com`
- **Authentication**: API key in `x-api-key` header
- **Rate Limiting**: X requests per second
- **Endpoints**:
- `POST /quote` - Get executable quote
- `GET /price` - Get rate without wallet
## Implementation Notes
### Slippage Format
API expects **percentage** (1 = 1%). ShapeShift internal format is decimal (0.01 = 1%), so we multiply by 100.
### Address Format
API requires **EIP-55 checksummed** addresses. We use `getAddress()` from viem.
### Native Token Handling
API uses marker address `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE` for native tokens (ETH, MATIC, etc.).
### Response Format
```json
{
"buyAmount": "1000000",
"sellAmount": "500000000",
"transaction": {
"to": "0x...",
"data": "0x...",
"value": "0x0",
"gas": "0x5208"
}
}
Gotchas
- Gas estimates are in hex, must convert to decimal with
fromHex()
- Affiliate fees must be passed to BOTH
/quote and /price to avoid rate delta
- Some routes may fail (dual routing), this is normal - use
bestPrice route
Testing Notes
- Use USDC/USDT pairs for testing (high liquidity)
- Test both native (ETH) and ERC20 swaps
- Verify slippage is applied correctly (check on-chain vs quoted amount)
Known Issues
References
---
## Contract Enforcement
**After implementation**, verify your work against the contract at
`.claude/contracts/swapper-integration.md`. The contract contains the
authoritative registration, testing, and completion checklists that must
all pass before the integration is complete.
## Critical Success Factors
1. **Research First**: Understand API thoroughly BEFORE coding
2. **Copy Patterns**: Adapt proven patterns from similar swappers
3. **Type Safety**: Use strict TypeScript types, avoid `any`
4. **Monadic Errors**: ALWAYS return `Result<T, SwapErrorRight>`, never throw
5. **Test Gotchas**: Proactively fix known bugs (slippage, checksumming, hex conversion)
6. **Feature Flag**: Always behind flag for gradual rollout
7. **Documentation**: Write INTEGRATION.md with quirks and gotchas
## Completion Checklist
Before considering integration complete:
**Code Quality**:
- [ ] All type checks pass (`pnpm run type-check`)
- [ ] All lint checks pass (`pnpm run lint`)
- [ ] Build succeeds (`pnpm run build:swapper`)
- [ ] No `any` types used
- [ ] All errors handled monadically
**Functionality**:
- [ ] Can fetch quotes successfully
- [ ] Can fetch rates without wallet
- [ ] Approval flow works (if needed)
- [ ] Transaction execution succeeds
- [ ] Status polling works (if applicable)
- [ ] Native token swaps work
- [ ] Error cases handled gracefully
**Integration**:
- [ ] Registered in constants.ts
- [ ] Exported from index.ts
- [ ] CSP headers added
- [ ] Feature flag implemented
- [ ] Test mocks updated
- [ ] Swapper icon added to UI
- [ ] Environment variables configured
**Documentation**:
- [ ] INTEGRATION.md created
- [ ] API quirks documented
- [ ] Known issues listed
- [ ] Testing notes included
**Testing**:
- [ ] Manual testing completed
- [ ] Rate vs quote delta verified (< 0.1%)
- [ ] Cross-account trades tested (if supported)
- [ ] Edge cases tested (min/max amounts, errors)
## Common Errors & Solutions
**"Taker address not checksummed"**
→ Use `getAddress(address)` from viem before sending to API
**"Number '0x...' is not a valid decimal"**
→ Convert hex to decimal: `fromHex(value as Hex, 'bigint').toString()`
**"Sell amount lower than fee"**
→ Check response parsing, likely accessing wrong field structure
**Large rate vs quote delta**
→ Pass same `affiliateBps` to both `/quote` and `/price` endpoints
**"$0 showing in UI"**
→ Response parsing bug, log actual response and verify structure
**"Transaction fails with slippage exceeded"**
→ Wrong slippage format sent to API (check docs for percentage/decimal/bps)
**Type error: "Property 'xyz' does not exist on type"**
→ Define proper TypeScript types matching actual API response
**"Cannot read property 'chainId' of undefined"**
→ Check null safety, add optional chaining or validation
---
## Need Help?
1. Read similar swapper implementations in packages/swapper/src/swappers/
2. Review the gotchas and patterns documented throughout this skill
3. Grep for similar patterns: `grep -r "pattern" packages/swapper/src/swappers/`
4. Ask user for API behavior clarification
5. Test with curl to verify API responses
---
**Remember**: Most bugs come from assumptions about API behavior. ALWAYS verify with actual API calls and log responses!