关于 swap-integration
用于在前端、后端、脚本和智能合约中集成 Uniswap 兑换的技能,涵盖 Trading API、Universal Router SDK 和路由器调用,并说明路由、校验与交易确认。
主要功能
- 01Trading API 兑换流程
- 02Universal Router SDK 指引
- 03直接调用智能合约路由器
- 04路由与报价处理
- 05输入校验和交易确认
适用场景
- 01为 React 或 Next.js 前端添加兑换
- 02构建后端兑换脚本或机器人
- 03直接控制交易构造
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ uniswap/ uniswap-ai --skill swap-integration安装后在 Claude Code 中输入/swap-integration确认已加载。
附带文件references/advanced-patterns.md
详细介绍
交换集成
将 Uniswap 兑换集成到前端、后端和智能合约中。
前提条件
本技能假设你熟悉 viem 基础知识(客户端设置、账户管理、合约交互、交易签名)。安装 uniswap-viem 插件,获取全面的 viem/wagmi 指南:claude plugin add @uniswap/uniswap-viem
快速决策指南
| 正在构建…… | 使用此方法 |
|---|---|
| React/Next.js 前端 | Trading API |
| 后端脚本或机器人 | Trading API |
| 智能合约集成 | 直接调用 Universal Router |
| 需要完全控制路由 | Universal Router SDK |
路由类型速查
| 类型 | 描述 | 链 |
|---|---|---|
| CLASSIC | 通过 Uniswap 池进行标准 AMM 兑换 | 所有支持的链 |
| DUTCH_V2 | UniswapX 荷兰式拍卖 V2 | Ethereum、Arbitrum、Base、Unichain |
| PRIORITY | 受 MEV 保护的优先订单 | Base、Unichain |
| WRAP | ETH 转换为 WETH | 所有链 |
| UNWRAP | WETH 转换为 ETH | 所有链 |
完整列表请参阅路由类型,其中还包括 DUTCH_V3、DUTCH_LIMIT、LIMIT_ORDER、BRIDGE 和 QUICKROUTE。
集成方法
1. Trading API(推荐)
最适合:前端、后端、脚本。自动处理路由优化。
基础 URL:https://trade-api.gateway.uniswap.org/v1
身份验证:需要提供 x-api-key: <your-api-key> 请求头
获取 API 密钥:Trading API 需要 API 密钥进行身份验证。访问 Uniswap Developer Portal 注册并获取 API 密钥。注册后通常可以立即使用。请在所有 API 请求中将其作为 x-api-key 请求头发送。
Agent 归属信息:每个 Trading API 请求还需发送 X-Agent-Info 请求头(下方示例中写作 x-agent-info)。该请求头用于在分析中将流量归属到此集成,不会影响请求。其值是一个紧凑的 JSON 对象,必须且只能包含以下三个字段,且总长度小于 1 KB:
integration_name:始终为"swap-integration"decision_origin:必须选择,不要照抄默认值。 选择与集成匹配的一个值(以下是唯一有效值;其他值均视为格式错误):"human_mediated"— 每次操作执行前都由人工审核或批准"autonomous"— Agent 无需逐次获得人工批准即可执行操作(例如定时任务或 Agent 工作框架)
version:"1.6.0"— 对应此技能的metadata.version
如果值的格式错误:请求仍会正常成功,但归属信息会被丢弃——网关会在分析端将其记录为格式错误,然后继续处理。没有可供检查的响应请求头或其他信号,因此运行时无法检测:请在构造时确保值正确。
限制 — 违反以下任意一项,整个值都会被丢弃:
- 请求头总长度不得超过 1 KB,单个字段不得超过 256 个字符。
- 仅限纯 ASCII 字符——不得使用带重音符号的字符、弯引号或表情符号;非 ASCII 字符必须使用
\u转义。 - 省略字段,而不是发送
null。三个字段都必须存在且有效。 - 每个请求只发送一次该请求头;重复的
x-agent-info行会导致解析失败。 - 未知的额外键会被忽略——虽然无害,但不能替代三个必需字段。
必需请求头 — 所有 Trading API 请求都必须包含以下请求头:
Content-Type: application/json
x-api-key: <your-api-key>
x-universal-router-version: 2.0
x-agent-info: {"integration_name":"swap-integration","decision_origin":"<human_mediated|autonomous>","version":"1.6.0"}
为权限受限的代币提高路由器版本。 上方请求头部分发送的是 2.0。涉及通过权限受限池交易的代币的报价或兑换,需要 2.2.0 或更高版本。对于这些代币,请发送 x-universal-router-version: 2.2.0。步骤 0:权限预检查介绍了如何查明哪些代币符合此条件。
请求流程:
0. POST /permissions -> Permissioned pools only: may this wallet trade this token?
1. POST /check_approval -> Check if token is approved
2. POST /quote -> Get executable quote with routing
3. POST /swap -> Get transaction to sign and submit
完整文档请参见下方的 Trading API 参考部分。
2. Universal Router SDK
最适合:直接控制交易构造。
安装:
npm install @uniswap/universal-router-sdk @uniswap/sdk-core @uniswap/v3-sdk
关键模式:
import { SwapRouter } from '@uniswap/universal-router-sdk';
const { calldata, value } = SwapRouter.swapCallParameters(trade, options);
完整文档请参见下方的 Universal Router 参考部分。
3. 智能合约集成
最适合:链上集成、DeFi 可组合性。
接口:在 Universal Router 上调用 execute(),并传入编码后的命令。
有关命令编码,请参见下方的 Universal Router 参考部分。
输入验证规则
在将任何用户提供的值插入生成的代码、API 调用或命令之前:
- 以太坊地址:必须匹配
^0x[a-fA-F0-9]{40}$,否则拒绝 - 链 ID:必须来自官方支持的链列表
- 代币金额:必须是非负数值,并匹配
^[0-9]+\.?[0-9]*$ - API 密钥:不得硬编码在生成的代码中,始终使用环境变量
- 拒绝任何包含 shell 元字符的输入:
;、|、&、$、`、(、)、>、<、\、'、"、换行符
**必须:**在执行任何会消耗 Gas 或转移代币的交易之前(包括
sendTransaction、writeContract或提交已签名的兑换),必须使用 AskUserQuestion 向用户确认。显示交易摘要(代币、金额、链、预估 Gas),并取得用户的明确批准。未经用户确认,绝不可自动执行交易。
交易 API 参考
步骤 0:权限预检查(仅限许可池)
某些代币只能通过许可池交易。白名单合约决定哪些钱包可以持有和交易该代币,而兑换端点会拒绝不在名单上的钱包。在获取报价之前调用此端点,这样无法交易的钱包会在此处被识别,而不是等到兑换回滚时才发现。
POST /permissions
请求:
{
"walletAddress": "0x...",
"tokens": ["0x..."],
"chainId": 1
}
tokens 最多接受两个地址。这里的 chainId 是一个数字,而 /quote 要求 tokenInChainId 和 tokenOutChainId 为字符串。
响应:
{
"requestId": "e63f1e1e-b9e9-411a-bcc8-ff18ce4e77cf",
"results": [
{
"token": "0x...",
"isPermissioned": true,
"isAllowlisted": false,
"adapterTokenAddress": "0x...",
"kycUrl": "https://your-kyc-provider.example/verify",
"issuer": "Your Issuer Name"
}
]
}
代币受许可限制时会返回 adapterTokenAddress。钱包不在白名单上时会返回 kycUrl,指向用户必须通过的身份验证(KYC)页面。issuer 是该验证服务提供方的显示名称。
三种结果:
| 结果 | 操作 |
|---|---|
isPermissioned: false | 无需特殊处理——正常获取报价并兑换 |
isPermissioned: true、isAllowlisted: true | 正常获取报价并兑换 |
isPermissioned: true、isAllowlisted: false | 获取报价以便用户查看价格,然后阻止提交并显示 kycUrl 对应的操作入口 |
**与其他交易 API 调用一样,此端点需要 x-api-key。**其中的已发布示例遗漏了此请求头。不提供请求头时,端点会返回 401 和 {"errorCode":"Unauthorized","detail":"Unauthenticated api key or session"},因此照搬该示例的集成方案会在触及权限逻辑之前就失败。
**涉及许可代币的报价和兑换需要 Universal Router 2.2.0 或更高版本。**对此类代币的 /quote 和 /swap 调用需发送 x-universal-router-version: 2.2.0。普通的 UniversalRouter 部署是另一种不受许可限制的路由器。只有 #v2.2 部署会在其构造函数中接收权限适配器工厂。
发行许可代币是另一项工作:请参阅 uniswap-permissioned-pools 插件中的 permissioned-pools-issuer 技能。
步骤 1:检查代币授权
POST /check_approval
请求:
{
"walletAddress": "0x...",
"token": "0x...",
"amount": "1000000000",
"chainId": 1
}
响应:
{
"approval": {
"to": "0x...",
"from": "0x...",
"data": "0x...",
"value": "0",
"chainId": 1
}
}
如果 approval 为 null,则代币已获授权。
第 2 步:获取报价
POST /quote
请求:
{
"swapper": "0x...",
"tokenIn": "0x...",
"tokenOut": "0x...",
"tokenInChainId": "1",
"tokenOutChainId": "1",
"amount": "1000000000000000000",
"type": "EXACT_INPUT",
"slippageTolerance": 0.5,
"routingPreference": "BEST_PRICE"
}
注意:
tokenInChainId和tokenOutChainId必须是字符串(例如"1"),不能是数字。
关键参数:
| 参数 | 说明 |
|---|---|
type | EXACT_INPUT 或 EXACT_OUTPUT |
slippageTolerance | 0-100 的百分比 |
protocols | 可选:["V2", "V3", "V4"] |
routingPreference | BEST_PRICE、FASTEST、CLASSIC |
autoSlippage | true 表示自动计算滑点(会覆盖 slippageTolerance) |
urgency | normal 或 fast——影响 UniswapX 拍卖时序 |
响应——响应结构因路由类型而异。在 Ethereum 主网上使用 BEST_PRICE 路由通常会返回 UniswapX(DUTCH_V2),而不是 CLASSIC。
CLASSIC 响应:
{
"routing": "CLASSIC",
"quote": {
"input": { "token": "0x...", "amount": "1000000000000000000" },
"output": { "token": "0x...", "amount": "999000000" },
"slippage": 0.5,
"route": [],
"gasFee": "5000000000000000",
"gasFeeUSD": "0.01",
"gasUseEstimate": "150000"
},
"permitData": null
}
UniswapX(DUTCH_V2/V3/PRIORITY)响应——quote 的结构不同,且没有 quote.output:
{
"routing": "DUTCH_V2",
"quote": {
"orderInfo": {
"reactor": "0x...",
"swapper": "0x...",
"nonce": "...",
"deadline": 1772031054,
"cosigner": "0x...",
"input": {
"token": "0x...",
"startAmount": "1000000000000000000",
"endAmount": "1000000000000000000"
},
"outputs": [
{
"token": "0x...",
"startAmount": "999000000",
"endAmount": "994000000",
"recipient": "0x..."
}
],
"chainId": 1
},
"encodedOrder": "0x...",
"orderHash": "0x..."
},
"permitData": { "domain": {}, "types": {}, "values": {} }
}
UniswapX 输出金额:使用
quote.orderInfo.outputs[0].startAmount获取最佳情况下的成交金额。endAmount是拍卖完全衰减后的底限。UniswapX 响应中没有quote.output.amount——访问它会在运行时抛出错误。显示提示:对于 CLASSIC 路由,使用
gasFeeUSD(包含美元金额的字符串)显示 Gas 费用。请勿用硬编码的 ETH 价格手动换算gasFee(wei)——这会导致估算严重失准(例如,估算为约 $87,而实际约为 $0.01)。对于 UniswapX 路由,换币方无需支付 Gas。
有关不同路由类型的编译时类型安全,请参阅 QuoteResponse TypeScript 类型。
第 3 步:执行换币
POST /swap
请求——将报价响应直接展开到请求正文中:
// CORRECT: Spread the quote response, strip null fields
const quoteResponse = await fetchQuote(params);
// Always strip permitData/permitTransaction — handle them explicitly by routing type
const { permitData, permitTransaction, ...cleanQuote } = quoteResponse;
const swapRequest: Record<string, unknown> = { ...cleanQuote };
const isUniswapX =
quoteResponse.routing === 'DUTCH_V2' ||
quoteResponse.routing === 'DUTCH_V3' ||
quoteResponse.routing === 'PRIORITY';
if (isUniswapX) {
// UniswapX: signature only — permitData must NOT go to /swap
if (permit2Signature) swapRequest.signature = permit2Signature;
} else {
// CLASSIC: both signature and permitData, or neither
if (permit2Signature && permitData && typeof permitData === 'object') {
swapRequest.signature = permit2Signature;
swapRequest.permitData = permitData;
}
}
重要:请勿将报价包装在 {quote: quoteResponse} 中。API 要求将报价响应字段展开到请求正文中。
Permit2 规则(CLASSIC 路由):
signature和permitData必须同时存在,或同时不存在- 切勿设置
permitData: null——应完全省略该字段 - 报价响应通常包含
permitData: null——发送前请移除该字段
UniswapX 路由(DUTCH_V2/V3/PRIORITY):permitData 用于在本地对订单签名,但必须从 /swap 请求正文中排除。请参阅签名与提交流程。
响应(可供签名的交易):
{
"swap": {
"to": "0x...",
"from": "0x...",
"data": "0x...",
"value": "0",
"chainId": 1,
"gasLimit": "250000"
}
}
响应校验——广播前务必进行校验:
function validateSwapResponse(response: SwapResponse): void {
if (!response.swap?.data || response.swap.data === '' || response.swap.data === '0x') {
throw new Error('swap.data is empty - quote may have expired');
}
if (!isAddress(response.swap.to) || !isAddress(response.swap.from)) {
throw new Error('Invalid address in swap response');
}
}
支持的链
当前支持的链及其 ID,请参阅官方支持的链列表。
路由类型
| 类型 | 说明 |
|---|---|
| CLASSIC | 通过 Uniswap 池进行的标准 AMM 换币 |
| DUTCH_V2 | UniswapX 荷兰式拍卖 V2 |
| DUTCH_V3 | UniswapX 荷兰式拍卖 V3 |
| PRIORITY | 受 MEV 保护的优先订单(Base、Unichain) |
| DUTCH_LIMIT | UniswapX 荷兰式限价订单 |
| LIMIT_ORDER | 限价订单 |
| WRAP | ETH 转换为 WETH |
| UNWRAP | WETH 转换为 ETH |
| BRIDGE | 跨链桥接 |
| QUICKROUTE | 快速近似报价 |
UniswapX 可用性:Ethereum(1)、Arbitrum(42161)、Base(8453)和 Unichain(130)支持 UniswapX V2 订单。拍卖机制因链而异——请参阅下文的 UniswapX 拍卖类型。
关键实现说明
以下是在实际 Trading API 集成中发现的常见问题。请遵循这些规则,避免链上交易回滚和 API 错误。
1. Swap 请求体格式
/swap 端点要求将报价响应展开后放入请求体,而不是将其封装在 quote 字段中。
// WRONG - causes "quote does not match any of the allowed types"
const badRequest = {
quote: quoteResponse, // Don't wrap!
signature: '0x...',
};
// CORRECT - spread the quote response
const goodRequest = {
...quoteResponse,
signature: '0x...', // Only if using Permit2
};
2. Null 字段处理
API 会拒绝 permitData: null。此外,permitData 的处理方式因路由类型而异,完整说明请参阅签名与提交流程。
function prepareSwapRequest(quoteResponse: QuoteResponse, signature?: string): object {
// Always strip permitData and permitTransaction from the spread — handle them explicitly
const { permitData, permitTransaction, ...cleanQuote } = quoteResponse;
const request: Record<string, unknown> = { ...cleanQuote };
// UniswapX (DUTCH_V2, DUTCH_V3, PRIORITY): permitData is for LOCAL signing only.
// The /swap body must NOT include permitData — the order is encoded in
// quote.encodedOrder. Only the signature is needed.
const isUniswapX =
quoteResponse.routing === 'DUTCH_V2' ||
quoteResponse.routing === 'DUTCH_V3' ||
quoteResponse.routing === 'PRIORITY';
if (isUniswapX) {
if (signature) request.signature = signature;
} else {
// CLASSIC: both signature and permitData required together, or both omitted.
// The Universal Router contract needs permitData to verify the Permit2
// authorization on-chain.
if (signature && permitData && typeof permitData === 'object') {
request.signature = signature;
request.permitData = permitData;
}
}
return request;
}
3. Permit2 字段规则
/swap 请求体中 signature 和 permitData 的规则取决于路由类型:
CLASSIC 路由:
| 场景 | signature | permitData |
|---|---|---|
| 标准交换(不使用 Permit2) | 省略 | 省略 |
| Permit2 交换 | 必需 | 必需 |
| 无效 | 存在 | 缺失 |
| 无效 | 缺失 | 存在 |
| 无效(API 错误) | 任意 | null |
UniswapX 路由(DUTCH_V2/V3/PRIORITY):
| 场景 | signature | permitData |
|---|---|---|
| UniswapX 订单 | 必需 | 省略(不要发送) |
| 无效 | 任意 | 存在(架构会拒绝) |
4. 广播前验证
发送到区块链之前,务必验证交换响应:
import { isAddress, isHex } from 'viem';
function validateSwapBeforeBroadcast(swap: SwapTransaction): void {
// 1. data must be non-empty hex
if (!swap.data || swap.data === '' || swap.data === '0x') {
throw new Error('swap.data is empty - this will revert on-chain. Re-fetch the quote.');
}
if (!isHex(swap.data)) {
throw new Error('swap.data is not valid hex');
}
// 2. Addresses must be valid
if (!isAddress(swap.to)) {
throw new Error('swap.to is not a valid address');
}
if (!isAddress(swap.from)) {
throw new Error('swap.from is not a valid address');
}
// 3. Value must be present (can be "0" for non-ETH swaps)
if (swap.value === undefined || swap.value === null) {
throw new Error('swap.value is missing');
}
}
5. 浏览器环境设置
在浏览器环境中使用 viem/wagmi 时,需要 Node.js polyfill:
安装 buffer polyfill:
npm install buffer
添加到入口文件中(位于其他导入之前):
// src/main.tsx or src/index.tsx
import { Buffer } from 'buffer';
globalThis.Buffer = Buffer;
// Then your other imports
import React from 'react';
import { WagmiProvider } from 'wagmi';
// ...
Vite 配置(vite.config.ts):
export default defineConfig({
define: {
global: 'globalThis',
},
optimizeDeps: {
include: ['buffer'],
},
resolve: {
alias: {
buffer: 'buffer',
},
},
});
如果未进行此设置,你会看到:ReferenceError: Buffer is not defined
CORS 代理配置
Trading API 不支持浏览器 CORS 预检请求——OPTIONS 请求会返回 415 Unsupported Media Type。从浏览器直接调用 fetch() 始终会失败。你必须通过自己的服务或开发服务代理 API 请求。
Vite 开发代理(合并到上文用于 Buffer polyfill 的同一个 vite.config.ts 中):
export default defineConfig({
server: {
proxy: {
'/api/uniswap': {
target: 'https://trade-api.gateway.uniswap.org/v1',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/uniswap/, ''),
},
},
},
});
然后在前端代码中使用 /api/uniswap/quote,而不是完整 URL。
Vercel 生产代理(vercel.json):
{
"rewrites": [
{
"source": "/api/uniswap/:path*",
"destination": "https://trade-api.gateway.uniswap.org/v1/:path*"
}
]
}
Cloudflare Pages(public/_redirects):
/api/uniswap/* https://trade-api.gateway.uniswap.org/v1/:splat 200
Next.js(next.config.js):
module.exports = {
async rewrites() {
return [
{
source: '/api/uniswap/:path*',
destination: 'https://trade-api.gateway.uniswap.org/v1/:path*',
},
];
},
};
如果不使用代理,你会在预检请求中看到 415 Unsupported Media Type,或在浏览器控制台中看到 CORS 错误。
6. 报价有效期
- 报价很快就会过期(通常为 30 秒)
- 如果用户需要时间审核,请务必重新获取报价
- 使用
deadline参数防止执行过期报价 - 如果
/swap返回空的data,报价很可能已过期
7. QuoteResponse TypeScript 类型
报价响应结构因路由类型而异。使用 routing 字段上的判别联合类型,获得编译时安全性,而不是强制转换为 any:
type ClassicQuoteResponse = {
routing: 'CLASSIC' | 'WRAP' | 'UNWRAP';
quote: {
input: { token: string; amount: string };
output: { token: string; amount: string };
slippage: number;
route: unknown[];
gasFee: string;
gasFeeUSD: string;
gasUseEstimate: string;
};
permitData: Record<string, unknown> | null;
};
type DutchOrderOutput = {
token: string;
startAmount: string;
endAmount: string;
recipient: string;
};
type UniswapXQuoteResponse = {
routing: 'DUTCH_V2' | 'DUTCH_V3' | 'PRIORITY';
quote: {
orderInfo: {
outputs: DutchOrderOutput[];
input: { token: string; startAmount: string; endAmount: string };
deadline: number;
nonce: string;
};
encodedOrder: string;
orderHash: string;
};
// EIP-712 typed data — sign locally, do NOT send to /swap
permitData: Record<string, unknown> | null;
};
type QuoteResponse = ClassicQuoteResponse | UniswapXQuoteResponse;
// Type guard for routing-aware logic
function isUniswapXQuote(q: QuoteResponse): q is UniswapXQuoteResponse {
return q.routing === 'DUTCH_V2' || q.routing === 'DUTCH_V3' || q.routing === 'PRIORITY';
}
// Reading the output amount by routing type
function getOutputAmount(q: QuoteResponse): string {
if (isUniswapXQuote(q)) {
const firstOutput = q.quote.orderInfo.outputs[0];
if (!firstOutput) throw new Error('UniswapX quote has no outputs');
// startAmount = best-case fill; endAmount = floor after auction decay
return firstOutput.startAmount;
}
return q.quote.output.amount;
}
Universal Router 参考
Universal Router 是一个统一接口,可用于在 Uniswap v2、v3 和 v4 上进行交换。
核心函数
function execute(
bytes calldata commands,
bytes[] calldata inputs,
uint256 deadline
) external payable;
命令编码
每个命令占一个字节:
| 位 | 名称 | 用途 |
|---|---|---|
| 0 | 标志 | 允许回滚(1 = 失败时继续) |
| 1-2 | 保留位 | 使用 0 |
| 3-7 | 命令 | 操作标识符 |
交换命令
| 代码 | 命令 | 描述 |
|---|---|---|
| 0x00 | V3_SWAP_EXACT_IN | 使用精确输入进行 v3 交换 |
| 0x01 | V3_SWAP_EXACT_OUT | 使用精确输出进行 v3 交换 |
| 0x08 | V2_SWAP_EXACT_IN | 使用精确输入进行 v2 交换 |
| 0x09 | V2_SWAP_EXACT_OUT | 使用精确输出进行 v2 交换 |
| 0x10 | V4_SWAP | v4 交换 |
代币操作
| Code | Command | Description |
|---|---|---|
| 0x04 | SWEEP | 清除路由器的代币余额 |
| 0x05 | TRANSFER | 发送指定数量的代币 |
| 0x0b | WRAP_ETH | 将 ETH 转为 WETH |
| 0x0c | UNWRAP_WETH | 将 WETH 转为 ETH |
Permit2 命令
| Code | Command | Description |
|---|---|---|
| 0x02 | PERMIT2_TRANSFER_FROM | 单笔代币转账 |
| 0x03 | PERMIT2_PERMIT_BATCH | 批量授权 |
| 0x0a | PERMIT2_PERMIT | 单次授权 |
SDK 用法
import { SwapRouter, UniswapTrade } from '@uniswap/universal-router-sdk'
import { TradeType } from '@uniswap/sdk-core'
// Build trade using v3-sdk or router-sdk
const trade = new RouterTrade({
v3Routes: [...],
tradeType: TradeType.EXACT_INPUT
})
// Get calldata for Universal Router
const { calldata, value } = SwapRouter.swapCallParameters(trade, {
slippageTolerance: new Percent(50, 10000), // 0.5%
recipient: walletAddress,
deadline: Math.floor(Date.now() / 1000) + 1200 // 20 min
})
// Send transaction
const tx = await wallet.sendTransaction({
to: UNIVERSAL_ROUTER_ADDRESS,
data: calldata,
value
})
Permit2 集成
Permit2 支持基于签名的代币授权,无需在链上调用 approve()。
授权目标:Permit2 与旧版(直接授权给路由器)
有两种授权路径。请根据集成类型选择:
| 方式 | 授权对象 | 每次兑换授权 | 适用场景 |
|---|---|---|---|
| Permit2(推荐) | Permit2 合约 | EIP-712 签名 | 有用户交互的前端 |
| 旧版(直接授权) | Universal Router | 无(预先授权) | 后端服务、智能账户 |
Permit2 流程(前端由用户签名):
- 用户将代币授权给 Permit2 合约(一次性)
- 每次兑换:用户签署 EIP-712 许可消息
- Universal Router 使用签名,通过 Permit2 转移代币
旧版流程(后端服务、ERC-4337 智能账户):
- 直接将代币授权给 Universal Router 地址(一次性)
- 每次兑换:无需额外授权
- 对于无法签署 EIP-712 消息的自动化系统来说更简单
使用 Trading API 的 /check_approval 端点——它会根据路由类型返回正确的授权目标。
工作原理
- 用户对 Permit2 合约进行一次授权(无限授权)
- 每次兑换时,用户签署授权转账的消息
- Universal Router 使用签名,通过 Permit2 转移代币
两种模式
| 模式 | 描述 |
|---|---|
| SignatureTransfer | 一次性签名,无链上状态 |
| AllowanceTransfer | 有时限的额度,带有链上状态 |
集成模式
import { getContract, maxUint256, type Address } from 'viem';
const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3' as const;
// Check if Permit2 approval exists
const allowance = await publicClient.readContract({
address: PERMIT2_ADDRESS,
abi: permit2Abi,
functionName: 'allowance',
args: [userAddress, tokenAddress, spenderAddress],
});
// If not approved, user must approve Permit2 first
if (allowance.amount < requiredAmount) {
const hash = await walletClient.writeContract({
address: tokenAddress,
abi: erc20Abi,
functionName: 'approve',
args: [PERMIT2_ADDRESS, maxUint256],
});
await publicClient.waitForTransactionReceipt({ hash });
}
// Then sign permit for the swap
const permitSignature = await signPermit(...);
UniswapX 拍卖类型
UniswapX 通过链下填单者路由兑换,这些填单者相互竞争,以优于链上 AMM 的价格执行订单。拍卖机制因链而异。
独占式荷兰拍卖(Ethereum)
- 以 RFQ(报价请求)阶段开始,获准报价者在此阶段展开竞争
- 获胜的报价者在一段时间内获得独占填单权
- 如果独占填单者未执行订单,则转为开放式荷兰拍卖,价格逐块递减
- 适合对 MEV 保护要求最高的大额兑换
Trading API 路由类型:DUTCH_V2 或 DUTCH_V3
开放式荷兰拍卖(Arbitrum)
- 无需 RFQ 阶段,直接进行开放拍卖
- 填单者通过递减价格机制在链上展开竞争
- 利用 Arbitrum 快速的 0.25 秒出块时间,实现快速价格发现
- Unimind 算法根据历史交易对表现设置拍卖参数
Trading API 路由类型:DUTCH_V2
优先 Gas 拍卖(Base、Unichain)
- 填单者在目标区块提交交易,并通过不同的优先费竞价
- 优先费最高者获得填单权
- 利用 OP Stack 的优先级排序机制
- 适用于区块构建者会遵循优先级排序的链
Trading API 路由类型:PRIORITY
主要特性(适用于所有拍卖类型)
- 用户无需支付 Gas — 填充者支付 Gas 费用,并将其计入最终定价
- 失败时不收费 — 如果兑换未能成交,用户无需支付任何费用
- MEV 保护 — 拍卖机制可防止抢先交易和三明治攻击
- UniswapX V2 目前支持 Ethereum(1)、Arbitrum(42161)、Base(8453)和 Unichain(130)
更多详情请参阅 UniswapX 拍卖类型文档。
UniswapX:签名与提交流程
报价响应中的 permitData 字段会根据路由类型发挥不同作用。混淆这两种情况会导致 /swap 返回 RequestValidationError。
CLASSIC 流程 — 将 permitData 发送到服务器:
/quote返回permitData(用于 Permit2 授权的 EIP-712 类型化数据)- 用户在本地签署
permitData→ 生成signature /swap请求体同时包含signature和permitData— Universal Router 合约需要permitData,以便在链上重建并验证 Permit2 授权
UniswapX 流程(DUTCH_V2/V3/PRIORITY) — 将 permitData 保留在本地:
/quote返回permitData(用于 Dutch 订单的 EIP-712 类型化数据)- 用户在本地签署
permitData→ 生成signature /swap请求体仅包含signature— 订单已完整编码在quote.encodedOrder中,链下填充者系统会直接读取该字段。将permitData发送到/swap会导致架构验证错误。
| 路由类型 | 使用 permitData 签名? | 将 permitData 发送到 /swap? | 将 signature 发送到 /swap? |
|---|---|---|---|
| CLASSIC | 是 | 是(路由器需要它) | 是(如果使用 Permit2) |
| DUTCH_V2/V3/PRIORITY | 是 | 否(架构会拒绝) | 是 |
常见错误:API 错误
"quote" does not match any of the allowed types通常指向quote字段,但实际原因是 UniswapX 路由中包含了permitData。提交前移除permitData— 请参阅空字段处理中的路由感知型prepareSwapRequest。
直接集成 Universal Router(SDK)
如需在不使用 Trading API 的情况下直接集成 Universal Router,请使用 SDK 的高级 API。
安装
npm install @uniswap/universal-router-sdk @uniswap/router-sdk @uniswap/sdk-core @uniswap/v3-sdk viem
高级用法(推荐)
使用 RouterTrade + SwapRouter.swapCallParameters() 自动构建命令:
import { SwapRouter } from '@uniswap/universal-router-sdk';
import { Trade as RouterTrade } from '@uniswap/router-sdk';
import { TradeType, Percent } from '@uniswap/sdk-core';
import { Route as V3Route, Pool } from '@uniswap/v3-sdk';
// 1. Fetch pool data (required to construct routes)
// Using viem to read on-chain pool state:
const slot0 = await publicClient.readContract({
address: poolAddress,
abi: [
{
name: 'slot0',
type: 'function',
stateMutability: 'view',
inputs: [],
outputs: [
{ name: 'sqrtPriceX96', type: 'uint160' },
{ name: 'tick', type: 'int24' },
{ name: 'observationIndex', type: 'uint16' },
{ name: 'observationCardinality', type: 'uint16' },
{ name: 'observationCardinalityNext', type: 'uint16' },
{ name: 'feeProtocol', type: 'uint8' },
{ name: 'unlocked', type: 'bool' },
],
},
],
functionName: 'slot0',
});
const liquidity = await publicClient.readContract({
address: poolAddress,
abi: [
{
name: 'liquidity',
type: 'function',
stateMutability: 'view',
inputs: [],
outputs: [{ type: 'uint128' }],
},
],
functionName: 'liquidity',
});
const pool = new Pool(tokenIn, tokenOut, fee, slot0[0].toString(), liquidity.toString(), slot0[1]);
// 2. Build route and trade
const route = new V3Route([pool], tokenIn, tokenOut);
const trade = RouterTrade.createUncheckedTrade({
route,
inputAmount: amountIn,
outputAmount: expectedOut,
tradeType: TradeType.EXACT_INPUT,
});
// 3. Get calldata
const { calldata, value } = SwapRouter.swapCallParameters(trade, {
slippageTolerance: new Percent(50, 10000), // 0.5%
recipient: walletAddress,
deadline: Math.floor(Date.now() / 1000) + 1800,
});
// 4. Execute with viem
const hash = await walletClient.sendTransaction({
to: UNIVERSAL_ROUTER_ADDRESS,
data: calldata,
value: BigInt(value),
});
低级用法(手动命令)
对于自定义流程(费用收取、复杂路由),请直接使用 RoutePlanner:
import { RoutePlanner, CommandType, ROUTER_AS_RECIPIENT } from '@uniswap/universal-router-sdk';
import { encodeRouteToPath } from '@uniswap/v3-sdk';
// Special addresses
const MSG_SENDER = '0x0000000000000000000000000000000000000001';
const ADDRESS_THIS = '0x0000000000000000000000000000000000000002';
示例:使用手动命令进行 V3 兑换
import { RoutePlanner, CommandType } from '@uniswap/universal-router-sdk';
import { encodeRouteToPath, Route } from '@uniswap/v3-sdk';
async function swapV3Manual(route: Route, amountIn: bigint, amountOutMin: bigint) {
const planner = new RoutePlanner();
// Encode V3 path from route
const path = encodeRouteToPath(route, false); // false = exactInput
planner.addCommand(CommandType.V3_SWAP_EXACT_IN, [
MSG_SENDER, // recipient
amountIn, // amountIn
amountOutMin, // amountOutMin
path, // encoded path
true, // payerIsUser
]);
return executeRoute(planner);
}
示例:ETH 兑换为代币(包装 + 兑换)
async function swapEthToToken(route: Route, amountIn: bigint, amountOutMin: bigint) {
const planner = new RoutePlanner();
const path = encodeRouteToPath(route, false);
// 1. Wrap ETH to WETH (keep in router)
planner.addCommand(CommandType.WRAP_ETH, [ADDRESS_THIS, amountIn]);
// 2. Swap WETH → Token (payerIsUser = false since using router's WETH)
planner.addCommand(CommandType.V3_SWAP_EXACT_IN, [
MSG_SENDER,
amountIn,
amountOutMin,
path,
false,
]);
return executeRoute(planner, { value: amountIn });
}
示例:代币兑换为 ETH(兑换 + 解包)
async function swapTokenToEth(route: Route, amountIn: bigint, amountOutMin: bigint) {
const planner = new RoutePlanner();
const path = encodeRouteToPath(route, false);
// 1. Swap Token → WETH (output to router)
planner.addCommand(CommandType.V3_SWAP_EXACT_IN, [
ADDRESS_THIS,
amountIn,
amountOutMin,
path,
true,
]);
// 2. Unwrap WETH to ETH
planner.addCommand(CommandType.UNWRAP_WETH, [MSG_SENDER, amountOutMin]);
return executeRoute(planner);
}
示例:使用 PAY_PORTION 收取费用
async function swapWithFee(route: Route, amountIn: bigint, feeRecipient: Address, feeBips: number) {
const planner = new RoutePlanner();
const path = encodeRouteToPath(route, false);
const outputToken = route.output.wrapped.address;
// Swap to router (ADDRESS_THIS)
planner.addCommand(CommandType.V3_SWAP_EXACT_IN, [ADDRESS_THIS, amountIn, 0n, path, true]);
// Pay fee portion (e.g., 30 bips = 0.3%)
planner.addCommand(CommandType.PAY_PORTION, [outputToken, feeRecipient, feeBips]);
// Sweep remainder to user
planner.addCommand(CommandType.SWEEP, [outputToken, MSG_SENDER, 0n]);
return executeRoute(planner);
}
路由执行辅助工具
import { UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk';
const ROUTER_ABI = [
{
name: 'execute',
type: 'function',
stateMutability: 'payable',
inputs: [
{ name: 'commands', type: 'bytes' },
{ name: 'inputs', type: 'bytes[]' },
{ name: 'deadline', type: 'uint256' },
],
outputs: [],
},
] as const;
async function executeRoute(planner: RoutePlanner, options?: { value?: bigint }) {
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1800);
const routerAddress = UNIVERSAL_ROUTER_ADDRESS('2.0', 1); // version, chainId
const { request } = await publicClient.simulateContract({
address: routerAddress,
abi: ROUTER_ABI,
functionName: 'execute',
args: [planner.commands, planner.inputs, deadline],
account,
value: options?.value ?? 0n,
});
return walletClient.writeContract(request);
}
命令速查表
| 命令 | 参数 |
|---|---|
| V3_SWAP_EXACT_IN | (recipient, amountIn, amountOutMin, path, payerIsUser) |
| V3_SWAP_EXACT_OUT | (recipient, amountOut, amountInMax, path, payerIsUser) |
| V2_SWAP_EXACT_IN | (recipient, amountIn, amountOutMin, path[], payerIsUser) |
| V2_SWAP_EXACT_OUT | (recipient, amountOut, amountInMax, path[], payerIsUser) |
| WRAP_ETH | (recipient, amount) |
| UNWRAP_WETH | (recipient, amountMin) |
| SWEEP | (token, recipient, amountMin) |
| TRANSFER | (token, recipient, amount) |
| PAY_PORTION | (token, recipient, bips) |
费率等级
| 等级 | 数值 | 百分比 |
|---|---|---|
| LOWEST | 100 | 0.01% |
| LOW | 500 | 0.05% |
| MEDIUM | 3000 | 0.30% |
| HIGH | 10000 | 1.00% |
常见集成模式
前端兑换钩子(React)
注意:请确保已设置 Buffer polyfill 和 CORS 代理(参见关键实现注意事项)。关于 wagmi v2 useWalletClient() 的常见问题,请参见下文的 wagmi v2 集成注意事项。
import { isAddress, isHex } from 'viem';
import { useWalletClient } from 'wagmi';
// In browser apps, use your CORS proxy path instead (see CORS Proxy Configuration)
// e.g., const API_URL = '/api/uniswap';
const API_URL = 'https://trade-api.gateway.uniswap.org/v1';
// REQUIRED: define decision_origin yourself — 'human_mediated' (a human reviews/approves
// each action before it executes) or 'autonomous' (no per-action human approval).
// See Agent Attribution above. There is no default — you must choose.
declare const DECISION_ORIGIN: 'human_mediated' | 'autonomous';
const AGENT_INFO = JSON.stringify({
integration_name: 'swap-integration',
decision_origin: DECISION_ORIGIN,
version: '1.6.0',
});
function useSwap() {
const { data: walletClient } = useWalletClient();
const [quoteResponse, setQuoteResponse] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const getQuote = async (params) => {
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_URL}/quote`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': API_KEY,
'x-universal-router-version': '2.0',
'x-agent-info': AGENT_INFO,
},
body: JSON.stringify(params),
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Quote failed');
setQuoteResponse(data); // Store the FULL response, not just data.quote
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const executeSwap = async (permit2Signature?: string) => {
if (!quoteResponse) throw new Error('No quote available');
// Strip null fields and spread quote response into body
const { permitData, permitTransaction, ...cleanQuote } = quoteResponse;
const swapRequest: Record<string, unknown> = { ...cleanQuote };
// CRITICAL: permitData handling differs by routing type
const isUniswapX =
quoteResponse.routing === 'DUTCH_V2' ||
quoteResponse.routing === 'DUTCH_V3' ||
quoteResponse.routing === 'PRIORITY';
if (isUniswapX) {
// UniswapX: signature only — permitData must NOT be sent to /swap
// (permitData is used locally to sign the order, not submitted to the API)
if (permit2Signature) swapRequest.signature = permit2Signature;
} else {
// CLASSIC: both signature and permitData required together, or both omitted
if (permit2Signature && permitData && typeof permitData === 'object') {
swapRequest.signature = permit2Signature;
swapRequest.permitData = permitData;
}
}
const swapResponse = await fetch(`${API_URL}/swap`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': API_KEY,
'x-universal-router-version': '2.0',
'x-agent-info': AGENT_INFO,
},
body: JSON.stringify(swapRequest),
});
const data = await swapResponse.json();
if (!swapResponse.ok) throw new Error(data.detail || 'Swap failed');
// CRITICAL: Validate response before broadcasting
if (!data.swap?.data || data.swap.data === '' || data.swap.data === '0x') {
throw new Error('Empty swap data - quote may have expired. Please refresh.');
}
// Send transaction via wallet (walletClient from useWalletClient())
if (!walletClient) throw new Error('Wallet not connected');
const tx = await walletClient.sendTransaction(data.swap);
return tx;
};
return { quote: quoteResponse?.quote, loading, error, getQuote, executeSwap };
}
wagmi v2 集成注意事项
即使钱包已连接,wagmi v2 的 useWalletClient() 钩子仍可能返回 undefined,因为它是异步解析的。这会导致兑换时出现“钱包未连接”错误。此外,返回的客户端需要包含 chain,sendTransaction() 才能正常工作。
推荐模式——兑换时使用 @wagmi/core 操作函数,而非钩子:
import { getWalletClient, getPublicClient, switchChain } from '@wagmi/core';
import type { Config } from 'wagmi';
async function executeSwapTransaction(
config: Config,
chainId: number,
swapTx: { to: string; data: string; value: string }
) {
// 1. Ensure the wallet is on the correct chain
await switchChain(config, { chainId });
// 2. Get wallet client with explicit chainId — avoids undefined and missing chain
const walletClient = await getWalletClient(config, { chainId });
// 3. Execute the swap
const hash = await walletClient.sendTransaction({
to: swapTx.to as `0x${string}`,
data: swapTx.data as `0x${string}`,
value: BigInt(swapTx.value || '0'),
});
// 4. Wait for confirmation
const publicClient = getPublicClient(config, { chainId });
if (!publicClient) throw new Error(`No public client configured for chainId ${chainId}`);
return publicClient.waitForTransactionReceipt({ hash });
}
原因:
- 在异步解析期间,即使
useAccount()显示已连接,useWalletClient()钩子也会返回{ data: undefined } getWalletClient(config, { chainId })是一个 Promise,只有客户端就绪时才会解析,并且会包含链信息- 当钱包所在网络与兑换网络不同时,
switchChain()可避免出现“链不匹配”错误
后端兑换脚本(Node.js)
import { createWalletClient, createPublicClient, http, isAddress, isHex, type Address } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { mainnet } from 'viem/chains';
const API_URL = 'https://trade-api.gateway.uniswap.org/v1';
const API_KEY = process.env.UNISWAP_API_KEY!;
// REQUIRED: define decision_origin yourself — 'human_mediated' (a human reviews/approves
// each action before it executes) or 'autonomous' (no per-action human approval).
// See Agent Attribution above. There is no default — you must choose.
declare const DECISION_ORIGIN: 'human_mediated' | 'autonomous';
const AGENT_INFO = JSON.stringify({
integration_name: 'swap-integration',
decision_origin: DECISION_ORIGIN,
version: '1.6.0',
});
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const publicClient = createPublicClient({ chain: mainnet, transport: http() });
const walletClient = createWalletClient({ account, chain: mainnet, transport: http() });
// Helper to prepare /swap request body — routing-aware permitData handling
function prepareSwapRequest(quoteResponse: Record<string, unknown>, signature?: string): object {
const { permitData, permitTransaction, ...cleanQuote } = quoteResponse;
const request: Record<string, unknown> = { ...cleanQuote };
// UniswapX (DUTCH_V2, DUTCH_V3, PRIORITY): permitData is for LOCAL signing only.
// The /swap body must NOT include permitData — the order is already encoded
// in quote.encodedOrder. Only the signature is needed.
const isUniswapX =
quoteResponse.routing === 'DUTCH_V2' ||
quoteResponse.routing === 'DUTCH_V3' ||
quoteResponse.routing === 'PRIORITY';
if (isUniswapX) {
if (signature) request.signature = signature;
} else {
// CLASSIC: both signature and permitData required together, or both omitted
if (signature && permitData && typeof permitData === 'object') {
request.signature = signature;
request.permitData = permitData;
}
}
return request;
}
// Validate swap response before broadcasting
function validateSwap(swap: { data?: string; to?: string; from?: string }): void {
if (!swap?.data || swap.data === '' || swap.data === '0x') {
throw new Error('swap.data is empty - quote may have expired');
}
if (!isHex(swap.data)) {
throw new Error('swap.data is not valid hex');
}
if (!swap.to || !isAddress(swap.to) || !swap.from || !isAddress(swap.from)) {
throw new Error('Invalid address in swap response');
}
}
async function executeSwap(tokenIn: Address, tokenOut: Address, amount: string, chainId: number) {
const ETH_ADDRESS = '0x0000000000000000000000000000000000000000';
// 1. Check approval (for ERC20 tokens, not native ETH)
if (tokenIn !== ETH_ADDRESS) {
const approvalRes = await fetch(`${API_URL}/check_approval`, {
method: 'POST',
headers: {
'x-api-key': API_KEY,
'Content-Type': 'application/json',
'x-universal-router-version': '2.0',
'x-agent-info': AGENT_INFO,
},
body: JSON.stringify({
walletAddress: account.address,
token: tokenIn,
amount,
chainId,
}),
});
const approvalData = await approvalRes.json();
if (approvalData.approval) {
const hash = await walletClient.sendTransaction({
to: approvalData.approval.to,
data: approvalData.approval.data,
value: BigInt(approvalData.approval.value || '0'),
});
await publicClient.waitForTransactionReceipt({ hash });
}
}
// 2. Get quote
const quoteRes = await fetch(`${API_URL}/quote`, {
method: 'POST',
headers: {
'x-api-key': API_KEY,
'Content-Type': 'application/json',
'x-universal-router-version': '2.0',
'x-agent-info': AGENT_INFO,
},
body: JSON.stringify({
swapper: account.address,
tokenIn,
tokenOut,
tokenInChainId: String(chainId),
tokenOutChainId: String(chainId),
amount,
type: 'EXACT_INPUT',
slippageTolerance: 0.5,
}),
});
const quoteResponse = await quoteRes.json(); // Store FULL response
if (!quoteRes.ok) {
throw new Error(quoteResponse.detail || 'Quote failed');
}
// 3. Execute swap - CRITICAL: spread quote response, strip null fields
const swapRequest = prepareSwapRequest(quoteResponse);
const swapRes = await fetch(`${API_URL}/swap`, {
method: 'POST',
headers: {
'x-api-key': API_KEY,
'Content-Type': 'application/json',
'x-universal-router-version': '2.0',
'x-agent-info': AGENT_INFO,
},
body: JSON.stringify(swapRequest),
});
const swapData = await swapRes.json();
if (!swapRes.ok) {
throw new Error(swapData.detail || 'Swap request failed');
}
// 4. Validate before broadcasting
validateSwap(swapData.swap);
const hash = await walletClient.sendTransaction({
to: swapData.swap.to,
data: swapData.swap.data,
value: BigInt(swapData.swap.value || '0'),
});
return publicClient.waitForTransactionReceipt({ hash });
}
智能合约集成(Solidity)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IUniversalRouter {
function execute(
bytes calldata commands,
bytes[] calldata inputs,
uint256 deadline
) external payable;
}
interface IERC20 {
function approve(address spender, uint256 amount) external returns (bool);
}
contract SwapIntegration {
IUniversalRouter public immutable router;
address public constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3;
constructor(address _router) {
router = IUniversalRouter(_router);
}
function swap(
bytes calldata commands,
bytes[] calldata inputs,
uint256 deadline
) external payable {
router.execute{value: msg.value}(commands, inputs, deadline);
}
// Approve token for Permit2 (one-time setup)
function approveToken(address token) external {
IERC20(token).approve(PERMIT2, type(uint256).max);
}
}
高级模式
智能账户集成(ERC-4337)
通过委托,使用 ERC-4337 智能账户执行 Trading API 兑换。模式如下:
- 从 Trading API 获取兑换 calldata(标准的三步流程)
- 将 calldata 封装到委托赎回执行中
- 通过
bundlerClient.sendUserOperation()提交——始终使用 viem bundler 客户端的sendUserOperation方法(不要使用自定义封装函数)
**必需:**最终提交步骤必须使用 viem permissionless/bundler 客户端的
bundlerClient.sendUserOperation()。 不要编写绕过此方法的自定义submitUserOperation封装——bundler 客户端会自动处理 UserOperation 构建、Gas 估算和提交。
// After getting swap calldata from Trading API:
const { to, data, value } = swapResponse.swap;
// Wrap in delegation execution
const execution = {
target: to, // Universal Router
callData: data,
value: BigInt(value),
};
// Submit via bundler — use sendUserOperation directly
const userOpHash = await bundlerClient.sendUserOperation({
account: delegateSmartAccount,
calls: [
{
to: delegationManagerAddress,
data: encodeFunctionData({
abi: delegationManagerAbi,
functionName: 'redeemDelegations',
args: [[[signedDelegation]], [0], [[execution]]],
}),
value: execution.value,
},
],
});
关键注意事项:
- 智能账户应使用旧式授权(直接授权给 Universal Router),而非 Permit2——参见授权目标
- 为 bundler Gas 估算增加 20-30% 的缓冲
- 将 bundler 专属错误代码与标准交易错误分开处理
有关包含类型定义和错误处理的完整实现,请参见高级模式参考。
L2 上的 WETH 处理
在 L2 链(Base、Optimism、Arbitrum)上,输出 ETH 的兑换可能会返回 WETH,而非原生 ETH。兑换后务必检查并解包:
import { parseAbi, type Address } from 'viem';
const WETH_ABI = parseAbi([
'function balanceOf(address) view returns (uint256)',
'function withdraw(uint256)',
]);
const WETH_ADDRESSES: Record<number, Address> = {
1: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
10: '0x4200000000000000000000000000000000000006',
8453: '0x4200000000000000000000000000000000000006',
42161: '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1',
};
// After swap completes on an L2:
const wethAddress = WETH_ADDRESSES[chainId];
if (wethAddress) {
const wethBalance = await publicClient.readContract({
address: wethAddress,
abi: WETH_ABI,
functionName: 'balanceOf',
args: [accountAddress],
});
if (wethBalance > 0n) {
const hash = await walletClient.writeContract({
address: wethAddress,
abi: WETH_ABI,
functionName: 'withdraw',
args: [wethBalance],
});
await publicClient.waitForTransactionReceipt({ hash });
}
}
有关各链的 WETH 地址和集成详情,请参见高级模式参考。
速率限制
Trading API 对每个端点实施速率限制(约 10 次请求/秒)。对于批量操作:
- 在顺序 API 调用之间添加 100-200ms 延迟
- 收到 429 响应时,实施带随机抖动的指数退避
- 缓存授权结果——授权很少会在调用之间发生变化
// Exponential backoff for 429 responses
async function fetchWithRetry(url: string, init: RequestInit, maxRetries = 5): Promise<Response> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, init);
if (response.status !== 429 && response.status < 500) return response;
if (attempt === maxRetries) throw new Error(`Failed after ${maxRetries} retries`);
const delay = Math.min(200 * Math.pow(2, attempt) + Math.random() * 100, 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
throw new Error('Unreachable');
}
有关批量操作模式和完整重试实现,请参见高级模式参考。
主要合约地址
Universal Router(v4)
地址因链而异。旧版 v1 地址 0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD 已弃用。
| 链 | ID | 地址 |
|---|---|---|
| Ethereum | 1 | 0x66a9893cc07d91d95644aedd05d03f95e1dba8af |
| Unichain | 130 | 0xef740bf23acae26f6492b10de645d6b98dc8eaf3 |
| Optimism | 10 | 0x851116d9223fabed8e56c0e6b8ad0c31d98b3507 |
| Base | 8453 | 0x6ff5693b99212da76ad316178a184ab56d299b43 |
| Arbitrum | 42161 | 0xa51afafe0263b40edaef0df8781ea9aa03e381a3 |
| Polygon | 137 | 0x1095692a6237d83c6a72f3f5efedb9a670c49223 |
| Blast | 81457 | 0xeabbcb3e8e415306207ef514f660a3f820025be3 |
| BNB | 56 | 0x1906c1d672b88cd1b9ac7593301ca990f94eae07 |
| Zora | 7777777 | 0x3315ef7ca28db74abadc6c44570efdf06b04b020 |
| World Chain | 480 | 0x8ac7bee993bb44dab564ea4bc9ea67bf9eb5e743 |
| Avalanche | 43114 | 0x94b75331ae8d42c1b61065089b7d48fe14aa73b7 |
| Celo | 42220 | 0xcb695bc5d3aa22cad1e6df07801b061a05a0233a |
| Soneium | 1868 | 0x4cded7edf52c8aa5259a54ec6a3ce7c6d2a455df |
| Ink | 57073 | 0x112908dac86e20e7241b0927479ea3bf935d1fa0 |
| Monad | 143 | 0x0d97dc33264bfc1c226207428a79b26757fb9dc3 |
测试网地址请参阅 Uniswap v4 部署。
Permit2
| 链 | 地址 |
|---|---|
| 所有链 | 0x000000000022D473030F116dDEE9F6B43aC78BA3 |
故障排除
常见问题
| 问题 | 解决方案 |
|---|---|
| "Insufficient allowance" | 先调用 /check_approval 并提交授权交易 |
| "Quote expired" | 延长截止时间或重新获取报价 |
| "Slippage exceeded" | 增大 slippageTolerance 或重试 |
| "Insufficient liquidity" | 尝试减少金额或选择其他路径 |
| "Buffer is not defined" | 添加 Buffer polyfill(参见 Critical Implementation Notes) |
| On-chain revert with empty data | 广播前验证 swap.data 是否为非空十六进制字符串 |
| "permitData must be of type object" | 从请求中移除 permitData: null——完全省略该字段 |
| "quote does not match any of the allowed types" | 不要将 quote 包装在 {quote: ...} 中——将其展开到请求正文中。另外请检查:对于 UniswapX 路径,必须从 /swap 请求正文中省略 permitData(参见 API Validation Errors) |
| Received WETH instead of ETH on L2 | 检查并在兑换后解包 WETH(参见 WETH Handling on L2s) |
| 429 Too Many Requests | 实现指数退避,并在批量请求之间添加延迟(参见 Rate Limiting) |
| 415 on OPTIONS preflight / CORS error | 设置 CORS 代理(参见 Browser Environment Setup 中的 CORS Proxy Configuration) |
| walletClient is undefined when wallet is connected | 使用 @wagmi/core 中的 getWalletClient(),而不是 useWalletClient() 钩子(参见 wagmi v2 Integration Pitfalls) |
| "Please provide a chain with the chain argument" | 将 chainId 传递给 getWalletClient(config, { chainId }) |
| Chain mismatch error on swap | 在调用 getWalletClient() 之前调用 switchChain()(参见 wagmi v2 Integration Pitfalls) |
API 验证错误(400)
| 错误消息 | 原因 | 修复 |
|---|---|---|
"permitData" must be of type object | 发送 permitData: null | 当值为 null 时,完全省略此字段 |
"quote" does not match any of the allowed types | 将报价封装在 {quote: quoteResponse} 中 | 展开报价响应:{...quoteResponse} |
"quote" does not match any of the allowed types | 在 UniswapX(DUTCH_V2/V3/PRIORITY)的 /swap 请求体中包含 permitData | 对于 UniswapX 路由,省略 permitData——参见签名与提交 |
signature and permitData must both be present | 仅包含一个 Permit2 字段(仅适用于 CLASSIC 路由) | 对于 CLASSIC 路由,同时包含两者或都不包含;对于 UniswapX,省略 permitData |
API 错误代码
| 代码 | 含义 |
|---|---|
| 400 | 请求参数无效(参见上方验证错误) |
| 401 | API 密钥无效或缺失(包括 /permissions) |
| 404 | 未找到该交易对的路由 |
| 429 | 超出速率限制 |
| 500 | API 错误——实施指数退避重试 |
广播前检查清单
向区块链发送兑换交易前:
- 验证
swap.data不为空十六进制值(不是'',也不是'0x') - 验证地址——
swap.to和swap.from均有效 - 检查报价时效——超过 30 秒则重新获取
- 验证 Gas——在估算值上增加 10-20% 的缓冲
- 确认余额——用户拥有足够的代币余额