Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/public-api/docs/rest-api-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ X-Partner-Code: your-partner-code

Optional `slippageTolerancePercentageDecimal` (e.g. `0.01` for 1%). The response returns a `rates` array (one entry per swapper, each with its own `swapperName`, amounts, fees, and an optional per-swapper `error`) plus `timestamp` and `expiresAt`. **Rates are indicative**, expire quickly (`expiresAt` ≈ 30s after issue), and are for display/comparison — request a quote to execute.

A non-empty `allowanceContract` on a rate means executing that swapper pulls the sell token from an ERC-20 allowance. Clients that want to handle approvals themselves — checking the current allowance, or setting an unlimited approval ahead of time — can use it directly at this stage; otherwise the quote supplies ready-to-sign approval transactions.

## 3. Get an executable quote

```
Expand All @@ -41,14 +43,14 @@ X-Partner-Code: your-partner-code

- `swapperName` comes from the rate you chose in step 2.
- `slippageTolerancePercentageDecimal` is optional; `accountNumber` is optional (defaults to `0`) and is needed for chains that derive addresses per account index (e.g. UTXO/Cosmos).
- The response includes a `quoteId` (needed for status tracking), an `approval` object (whether an ERC-20 approval is required and the spender to approve), and a `steps` array. Each step may include `transactionData` — a discriminated union on `type` (`evm`, `solana`, `utxo`, `cosmossdk_msg_send`, `cosmossdk_msg_deposit`) — describing exactly what to sign for that chain.
- The response includes a `quoteId` (needed for status tracking), an `approval` object (whether an ERC-20 approval is required, the spender, and ready-to-sign `approvalTxs` when it is), and a `steps` array. Each step may include `transactionData` — a discriminated union on `type` (`evm`, `solana`, `utxo`, `cosmossdk_msg_send`, `cosmossdk_msg_deposit`) — describing exactly what to sign for that chain.
- Quotes expire: honor the `expiresAt` timestamp (≈ 60s after issue). Request a fresh quote rather than submitting an expired one.

## 4. Execute the swap

The API does **not** broadcast transactions — your application signs and broadcasts with the user's wallet:

1. If `approval.isRequired` is true, send an ERC-20 `approve(approval.spender, amount)` transaction for the sell token first (amount ≥ the step's `sellAmountCryptoBaseUnit`) and wait for it to confirm. Quotes are issued before approval exists — network fees are estimated as if the approval were already in place.
1. If `approval.isRequired` is true, sign and broadcast each transaction in `approval.approvalTxs` in order, waiting for each to confirm. These are **exact approvals** — sized to the step's `sellAmountCryptoBaseUnit` and consumed by the swap's execution, so a later swap needs its own approval unless a sufficient allowance is already in place (`approvalTxs` is empty in that case, with `isRequired: false`). Usually it is a single approve; tokens that require resetting a non-zero allowance before changing it (e.g. USDT) get a preceding `approve(spender, 0)`. Clients preferring an unlimited approval can build their own `approve(approval.spender, amount)` instead. Quotes are issued before approval exists — network fees are estimated as if the approval were already in place.
2. For each step with `transactionData`, build, sign, and broadcast the transaction according to its `type` (EVM tx, Solana instructions, UTXO PSBT/deposit, or Cosmos message).
3. Capture the resulting transaction hash for status tracking.

Expand Down
19 changes: 12 additions & 7 deletions packages/public-api/src/routes/quote/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,18 @@ const TransactionDataSchema = z.discriminatedUnion('type', [
export const ApprovalInfoSchema = z.object({
isRequired: z.boolean().openapi({ example: true }),
spender: z.string().openapi({ example: '0xdef1c0ded9bec7f1a1670819833240f027b25eff' }),
approvalTx: z
.object({
to: z.string().openapi({ example: '0xdef1c0ded9bec7f1a1670819833240f027b25eff' }),
data: z.string().openapi({ example: '0x' }),
value: z.string().openapi({ example: '0' }),
})
.optional(),
approvalTxs: z
.array(
z.object({
to: z.string().openapi({ example: '0xdac17f958d2ee523a2206206994597c13d831ec7' }),
data: z.string().openapi({ example: '0x095ea7b3...' }),
value: z.string().openapi({ example: '0' }),
}),
)
.openapi({
description:
"Ready-to-sign approval transactions in broadcast order, empty when the current allowance already covers the amount. Approvals are exact - sized to the step's sellAmountCryptoBaseUnit and consumed by the swap's execution. Usually a single approve; tokens that require resetting a non-zero allowance before changing it (e.g. USDT) get a preceding approve(spender, 0). Sign and broadcast sequentially, waiting for each to confirm. Clients preferring an unlimited approval can build their own approve to `spender` instead.",
}),
})

export const QuoteStepSchema = registry.register(
Expand Down
52 changes: 48 additions & 4 deletions packages/public-api/src/routes/quote/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CHAIN_NAMESPACE, fromAssetId, fromChainId } from '@shapeshiftoss/caip'
import { viemClientByChainId } from '@shapeshiftoss/contracts'
import type { TradeQuoteStep } from '@shapeshiftoss/swapper'
import { isToken } from '@shapeshiftoss/utils'
import { erc20Abi, getAddress } from 'viem'
import { encodeFunctionData, erc20Abi, getAddress } from 'viem'

import { extractTransactionData } from './extractTransactionData'
import type { ApiQuoteStep, ApprovalInfo } from './types'
Expand All @@ -23,21 +23,65 @@ export const buildApprovalInfo = async (
isToken(step.sellAsset.assetId) &&
Boolean(step.allowanceContract)

if (!needsAllowanceCheck) return { isRequired: false, spender: '' }
if (!needsAllowanceCheck) return { isRequired: false, spender: '', approvalTxs: [] }

const spender = step.allowanceContract
const client = viemClientByChainId[step.sellAsset.chainId]
const tokenAddress = getAddress(fromAssetId(step.sellAsset.assetId).assetReference)

const allowance = await client.readContract({
address: getAddress(fromAssetId(step.sellAsset.assetId).assetReference),
address: tokenAddress,
abi: erc20Abi,
functionName: 'allowance',
args: [getAddress(owner), getAddress(spender)],
})

const requiredAmount = BigInt(step.sellAmountIncludingProtocolFeesCryptoBaseUnit)

return { isRequired: allowance < requiredAmount, spender }
if (allowance >= requiredAmount) return { isRequired: false, spender, approvalTxs: [] }

const approveTx = {
to: tokenAddress,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'approve',
args: [getAddress(spender), requiredAmount],
}),
value: '0',
}

// USDT-style tokens require resetting a non-zero allowance before changing it - detected by
// simulating the approve as the owner, so no token list is needed. A revert, a false return,
// a non-standard (return-less) token, or a transient RPC failure all land on the reset side -
// a spurious approve(spender, 0) is a harmless extra transaction, never an unexecutable quote
const needsReset =
allowance > 0n &&
(await client
.simulateContract({
account: getAddress(owner),
address: tokenAddress,
abi: erc20Abi,
functionName: 'approve',
args: [getAddress(spender), requiredAmount],
})
.then(({ result }) => result === false)
.catch(() => true))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const resetTx = {
to: tokenAddress,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'approve',
args: [getAddress(spender), 0n],
}),
value: '0',
}

return {
isRequired: true,
spender,
approvalTxs: needsReset ? [resetTx, approveTx] : [approveTx],
}
}

// Transform quote step to API format
Expand Down
2 changes: 2 additions & 0 deletions packages/public-api/src/routes/rates/getRates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export const getRates = async (req: Request, res: Response): Promise<void> => {
buyAmountCryptoBaseUnit: '0',
sellAmountCryptoBaseUnit,
steps: 0,
allowanceContract: undefined,
estimatedExecutionTimeMs: undefined,
priceImpactPercentageDecimal: undefined,
partnerBps: req.affiliateInfo?.partnerBps,
Expand All @@ -136,6 +137,7 @@ export const getRates = async (req: Request, res: Response): Promise<void> => {
buyAmountCryptoBaseUnit: lastStep.buyAmountAfterFeesCryptoBaseUnit,
sellAmountCryptoBaseUnit: step.sellAmountIncludingProtocolFeesCryptoBaseUnit,
steps: rate.steps.length,
allowanceContract: step.allowanceContract,
estimatedExecutionTimeMs: step.estimatedExecutionTimeMs,
priceImpactPercentageDecimal: rate.priceImpactPercentageDecimal,
partnerBps: req.affiliateInfo?.partnerBps,
Expand Down
5 changes: 5 additions & 0 deletions packages/public-api/src/routes/rates/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ const ApiRateSchema = z.object({
buyAmountCryptoBaseUnit: z.string(),
sellAmountCryptoBaseUnit: z.string(),
steps: z.number(),
allowanceContract: z.string().optional().openapi({
example: '0xdef1c0ded9bec7f1a1670819833240f027b25eff',
description:
'First-hop approval spender for the sell token. Non-empty means executing this swapper pulls the sell token from an approved allowance - clients wanting to check or set an allowance manually before quoting can use it directly. Empty or absent means no approval is involved.',
}),
estimatedExecutionTimeMs: z.number().optional(),
priceImpactPercentageDecimal: z.string().optional(),
...BpsFields,
Expand Down
4 changes: 2 additions & 2 deletions packages/swap-widget/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,11 +199,11 @@ export type ApiQuoteStep = {
export type ApprovalInfo = {
isRequired: boolean
spender: string
approvalTx?: {
approvalTxs: {
to: string
data: string
value: string
}
}[]
}

export type QuoteResponse = {
Expand Down
Loading