mcpField
S
swap-integration

swap-integration

github.com/uniswap开发2362.2k 次安装2 天前更新
代码仓库

关于 swap-integration

用于在前端、后端、脚本和智能合约中集成 Uniswap 兑换的技能,涵盖 Trading API、Universal Router SDK 和路由器调用,并说明路由、校验与交易确认。

主要功能

  1. 01Trading API 兑换流程
  2. 02Universal Router SDK 指引
  3. 03直接调用智能合约路由器
  4. 04路由与报价处理
  5. 05输入校验和交易确认

适用场景

  1. 01为 React 或 Next.js 前端添加兑换
  2. 02构建后端兑换脚本或机器人
  3. 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_V2UniswapX 荷兰式拍卖 V2Ethereum、Arbitrum、Base、Unichain
PRIORITY受 MEV 保护的优先订单Base、Unichain
WRAPETH 转换为 WETH所有链
UNWRAPWETH 转换为 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"),不能是数字。

关键参数:

参数说明
typeEXACT_INPUT 或 EXACT_OUTPUT
slippageTolerance0-100 的百分比
protocols可选:["V2", "V3", "V4"]
routingPreferenceBEST_PRICE、FASTEST、CLASSIC
autoSlippagetrue 表示自动计算滑点(会覆盖 slippageTolerance)
urgencynormal 或 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_V2UniswapX 荷兰式拍卖 V2
DUTCH_V3UniswapX 荷兰式拍卖 V3
PRIORITY受 MEV 保护的优先订单(Base、Unichain)
DUTCH_LIMITUniswapX 荷兰式限价订单
LIMIT_ORDER限价订单
WRAPETH 转换为 WETH
UNWRAPWETH 转换为 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 路由:

场景signaturepermitData
标准交换(不使用 Permit2)省略省略
Permit2 交换必需必需
无效存在缺失
无效缺失存在
无效(API 错误)任意null

UniswapX 路由(DUTCH_V2/V3/PRIORITY):

场景signaturepermitData
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命令操作标识符
交换命令
代码命令描述
0x00V3_SWAP_EXACT_IN使用精确输入进行 v3 交换
0x01V3_SWAP_EXACT_OUT使用精确输出进行 v3 交换
0x08V2_SWAP_EXACT_IN使用精确输入进行 v2 交换
0x09V2_SWAP_EXACT_OUT使用精确输出进行 v2 交换
0x10V4_SWAPv4 交换
代币操作
CodeCommandDescription
0x04SWEEP清除路由器的代币余额
0x05TRANSFER发送指定数量的代币
0x0bWRAP_ETH将 ETH 转为 WETH
0x0cUNWRAP_WETH将 WETH 转为 ETH
Permit2 命令
CodeCommandDescription
0x02PERMIT2_TRANSFER_FROM单笔代币转账
0x03PERMIT2_PERMIT_BATCH批量授权
0x0aPERMIT2_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 流程(前端由用户签名):

  1. 用户将代币授权给 Permit2 合约(一次性)
  2. 每次兑换:用户签署 EIP-712 许可消息
  3. Universal Router 使用签名,通过 Permit2 转移代币

旧版流程(后端服务、ERC-4337 智能账户):

  1. 直接将代币授权给 Universal Router 地址(一次性)
  2. 每次兑换:无需额外授权
  3. 对于无法签署 EIP-712 消息的自动化系统来说更简单

使用 Trading API 的 /check_approval 端点——它会根据路由类型返回正确的授权目标。

工作原理
  1. 用户对 Permit2 合约进行一次授权(无限授权)
  2. 每次兑换时,用户签署授权转账的消息
  3. 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 发送到服务器:

  1. /quote 返回 permitData(用于 Permit2 授权的 EIP-712 类型化数据)
  2. 用户在本地签署 permitData → 生成 signature
  3. /swap 请求体同时包含 signature 和 permitData — Universal Router 合约需要 permitData,以便在链上重建并验证 Permit2 授权

UniswapX 流程(DUTCH_V2/V3/PRIORITY) — 将 permitData 保留在本地:

  1. /quote 返回 permitData(用于 Dutch 订单的 EIP-712 类型化数据)
  2. 用户在本地签署 permitData → 生成 signature
  3. /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)
费率等级
等级数值百分比
LOWEST1000.01%
LOW5000.05%
MEDIUM30000.30%
HIGH100001.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 兑换。模式如下:

  1. 从 Trading API 获取兑换 calldata(标准的三步流程)
  2. 将 calldata 封装到委托赎回执行中
  3. 通过 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地址
Ethereum10x66a9893cc07d91d95644aedd05d03f95e1dba8af
Unichain1300xef740bf23acae26f6492b10de645d6b98dc8eaf3
Optimism100x851116d9223fabed8e56c0e6b8ad0c31d98b3507
Base84530x6ff5693b99212da76ad316178a184ab56d299b43
Arbitrum421610xa51afafe0263b40edaef0df8781ea9aa03e381a3
Polygon1370x1095692a6237d83c6a72f3f5efedb9a670c49223
Blast814570xeabbcb3e8e415306207ef514f660a3f820025be3
BNB560x1906c1d672b88cd1b9ac7593301ca990f94eae07
Zora77777770x3315ef7ca28db74abadc6c44570efdf06b04b020
World Chain4800x8ac7bee993bb44dab564ea4bc9ea67bf9eb5e743
Avalanche431140x94b75331ae8d42c1b61065089b7d48fe14aa73b7
Celo422200xcb695bc5d3aa22cad1e6df07801b061a05a0233a
Soneium18680x4cded7edf52c8aa5259a54ec6a3ce7c6d2a455df
Ink570730x112908dac86e20e7241b0927479ea3bf935d1fa0
Monad1430x0d97dc33264bfc1c226207428a79b26757fb9dc3

测试网地址请参阅 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请求参数无效(参见上方验证错误)
401API 密钥无效或缺失(包括 /permissions)
404未找到该交易对的路由
429超出速率限制
500API 错误——实施指数退避重试
广播前检查清单

向区块链发送兑换交易前:

  1. 验证 swap.data 不为空十六进制值(不是 '',也不是 '0x')
  2. 验证地址——swap.to 和 swap.from 均有效
  3. 检查报价时效——超过 30 秒则重新获取
  4. 验证 Gas——在估算值上增加 10-20% 的缓冲
  5. 确认余额——用户拥有足够的代币余额

其他资源

评论

支持 Markdown