从 0x 到 Jupiter:在一款桌面监控软件里构建全链 SWAP 引擎的完整技术复盘

0 阅读17分钟

一、SWAP 在桌面软件中的独特定位

绝大多数链上 SWAP 功能运行在 Web 端——浏览器原生支持 MetaMask / Phantom 等钱包扩展,window.ethereum 和 window.solana 对象随手可得,签名、广播一气呵成。

但在桌面软件里,这一切前提都不存在。

Web3多链监控软件基于 pywebview 构建,内核是 Edge WebView2。WebView2 和完整的 Edge 浏览器有着本质区别——它只是渲染引擎,没有扩展机制。这意味着 window.ethereum 永远不会被注入,用户无法在软件内直接连接 MetaMask 完成交易签名。

210b5d2f74b7a61bed53c736fd5e0229.png 这就是桌面软件集成 SWAP 的第一个硬约束:必须在外部浏览器中完成签名

但紧接着就引出了第二个问题:签名页面(dex.html)运行在系统浏览器中,而报价数据在 Python 后端生成,二者之间原本没有任何通信渠道。如何把后端算好的最优报价、交易参数、甚至超长的 base64 交易数据,完整无损地传递给浏览器的签名页面?

我的解决方案是一套本地 HTTP 回调服务 + 极简化签名页面的组合架构:

  1. 软件启动时,在 127.0.0.1:19999 启动一个本地 HTTP 服务器,同时提供静态文件服务(dex.html)和回调接口(/callback/
  2. 用户确认报价后,后端通过 webbrowser.open 打开带完整 URL 参数的 dex.html,超长交易数据通过本地 HTTP 接口拉取
  3. 用户在外部浏览器完成签名后,dex.html 将交易哈希通过 POST /callback/ 发送回本地服务,软件收到后更新前端状态

整个通信链路是:报价 → 本地 HTTP Server → 外部浏览器签名 → 回调 → 状态更新。所有数据传递都在 127.0.0.1 闭环内完成,不经过任何外部服务器。

这套架构形态上是一种折中方案,但它解决了桌面软件 SWAP 最棘手的问题:在无法嵌入钱包扩展的前提下,如何让软件和外部浏览器安全、高效地交换交易数据和结果。

image.png

二、报价链的架构设计:为什么单个聚合器不可靠

在任何一条链上获取最优报价,本质上是一个概率问题。0x 可能没有收录某个刚上线 30 分钟的代币;ParaSwap 可能因为流动性池太浅而拒绝报价;OKX 可能因为端点版本问题返回空数据。如果只依赖一个聚合器,用户会频繁看到“无流动性”的错误提示,而实际上这个代币在其他聚合器上完全可以报价。

因此,从第一天开始,报价引擎就被设计为一条多层接力链,而不是一个单一的 API 调用。

完整链路如下:

前端点击"获取报价" ↓ 0x API(覆盖最广的聚合器,交易数据构建完整)→ 成功则直接返回 ↓ 失败 ParaSwap API(免费公开,大范围覆盖,实际主力备用)→ 成功则直接返回 ↓ 失败 OKX DEX API(免费额度,支持 EVM + Solana 全链)→ 成功则直接返回 ↓ 失败 链上合约轮询(40+ 个 V2 Router / V3 Quoter 地址逐条尝试)→ 成功则返回 ↓ 失败 最终兜底(swapapi.dev,最后的通用聚合器)→ 返回或报错

任何一环成功,后续全部跳过。全部失败时,前端会收到“该代币在当前网络的所有 DEX 上均无流动性”的友好提示,而非原始链上错误。

每个聚合器的返回格式各不相同,需要在后端做统一的数据映射,确保前端收到的 JSON 结构完全一致(expectedOutminOutdecimalsapiSource 四个核心字段)。以下是我在实现过程中整理的各聚合器参数差异:

聚合器:0x、ParaSwap、OKX DEX、Jupiter 链 ID 格式: 数字(1, 56, 137, 42161, 8453) 数字(1, 56, 137, 42161, 8453) 数字(1, 56, 137, 42161, 8453, 501) 不需要链 ID,走独立的 Solana 端点 滑点单位: bps(100 = 1%) 小数(0.01 = 1%) 小数(0.01 = 1%) bps(100 = 1%) 请求方式:GET、GET、GET、GET + POST 关键差异: 需要 API Key;返回完整交易数据 to/data/value/gas 免费无 Key;必须指定 srcDecimals 和 destDecimals 需要 API Key;端点用 api/v6/dex/aggregator/swap 两步流程:/quote 报价 → /swap 生成交易

链 ID 的映射是第一个坑。同一个 EVM 链,在不同聚合器中的 ID 可能不同。BSC 在 0x 和 ParaSwap 中是 56,但在 OKX DEX 中也是 56,尚算统一。Solana 则是特殊存在:它只在 Jupiter 和 OKX 中有报价(OKX 中 Solana 的 ID 为 501),0x 和 ParaSwap 完全不支持。

滑点单位的差异是第二个坑。0x 和 Jupiter 使用 bps(basis points,100 表示 1%),ParaSwap 使用小数(0.01 表示 1%),OKX 也使用小数。如果给 0x 传 0.01,它会把滑点设成 0.01%,几乎等于没设滑点保护,交易极大概率失败。这个问题在初期测试中花了大量时间才定位到根因。

三、链上合约轮询的工程深度

聚合器 API 覆盖不到的核心原因通常是:代币太新(未被收录)、流动性极浅(被过滤)、或代币合约有恶意权限(被聚合器主动屏蔽)。此时最后一道可靠的报价防线是直接调用链上合约的只读接口来模拟报价

V2 Router(如 Uniswap V2 Router)提供了一个核心方法 getAmountsOut,其函数签名为:

getAmountsOut(uint256 amountIn, address[] calldata path)
returns (uint256[] memory amounts)

它接受一个输入金额和一条兑换路径(如 [WETH, USDC, TARGET]),返回路径上每一步的输出量。最终得到的是 amounts[length-1],即经过整条路径后的最终输出。

然而,大多数新代币只在 Uniswap V3 池子中存在。V3 的流动性分散在不同的费率层级(Fee Tier)中,必须使用专门的 Quoter 合约来计算,其核心方法为:

quoteExactInputSingle(address tokenIn, address tokenOut, uint24 fee, uint256 amountIn, uint160 sqrtPriceLimitX96)
returns (uint256 amountOut, ...)

与 V2 不同,V3 的查询必须指定一个费率层级。常用的 fee tier 有三个:100(0.01%)、500(0.05%)、3000(0.3%)、10000(1%)。由于不知道代币到底在哪个费率层级上有池子,代码实现中需要逐层遍历:对每个层级尝试 quoteExactInputSingle,只要有一个返回有效结果就使用它。

revert 解码是合约轮询中最容易被忽视的工程细节。当 getAmountsOut 或 quoteExactInputSingle 失败时,失败原因可能完全不同:

  1. "Could not transact with/call contract function" :合约在当前链上根本不存在(如 BSC 链上不存在 Uniswap V3 Quoter)。这类错误需要被记为“非有效错误”,继续尝试下一个合约。
  2. "execution reverted: insufficient_output_amount" :路径存在但流动性不足。返回给用户的提示应该是“流动性不足,无法按预期兑换”。
  3. "execution reverted: 0x" :合约回滚但未返回有意义的数据。可能是因为该费率层在 V3 中不存在对应的池子。

最终,只有当所有合约(每条链 3-4 个 Router/Quoter 地址)全部失败,且没有任何有意义的错误信息时,才返回统一的“无流动性”提示。

路径构造策略同样重要。一条路径不应只是一个 [WETH, TARGET] 的直接对。为了模拟聚合器行为,代码中构建了多条路径:

  1. 直接路径:[sell_token, buy_token]
  2. 单跳路径(经过一个中间币):[sell_token, USDC, buy_token]
  3. 路径中不会包含无效的中间币(如 sell_token 和 buy_token 恰好就是中间币本身时跳过)

这种穿透策略最大化地提高了在链上直接找到报价的概率。

四、代币精度的地狱

代币的 decimals 是整个 SWAP 报价中最容易被忽视、出错后果最严重的变量。

EVM 上的 ERC-20 代币小数位不统一。18 是主流,但 USDC 是 6,USDT 是 6,还有一些极端代币是 0(不可分割)。当后端通过链上合约查到了错误的 decimals,所有计算结果都会产生数量级的偏差。输入 0.1 SOL 预期换到几千万个代币,结果只显示 0.000866 个——不是因为价格不对,而是小数位错了 9 位。

Solana 的情况更加棘手。Solana 代币的 decimals 不像 EVM 那样通过标准化的 decimals() 方法直接获取,它存储在 Mint 账户数据的固定偏移位置(第 44 字节)。我的系统最初使用 Helius RPC 的 getAsset 方法获取,但 Helius 对新代币的索引存在延迟,偶尔会返回空数据,导致 fallback 到默认值 9。这正是早期测试中 XChat 等代币兑换数量完全失真的直接原因。

随后切换到 Solana 公共 RPC 的 getTokenSupply 方法,利用其返回值中直接包含 decimals 字段的特点,无需手动切字节解析。但极个别新代币在 Solana 公共节点中可能未经索引,仍然可能导致失败。

最终的解决方案,是采用一个双重兜底机制

  1. 第一优先级:直接查询 Jupiter 官方 Token 列表(token.jup.ag/tokens?mints=...),Jupiter 维护了全网代币的 decimals 信息,成功率远高于通用 RPC
  2. 第二优先级:如果 Jupiter 列表未命中,再回退到 Solana 公共 RPC 的 getTokenSupply 方法
  3. 最终兜底:如果上述两步全部失败,默认值设为 9(Solana 大多数代币的真实 decimals)

五、买入与卖出的双向逻辑

一个完整的 SWAP 功能,必须具备买入和卖出两个方向。背后意味着每一个核心组件都必须为这两种方向实现两套逻辑。

后端 get_swap_quote 的判断入口仅需一行代码:

is_sell = (token_out.upper() == 'ETH')

当用户希望卖出代币换回原生币时,前端会传 token_out='ETH',后端据此判断方向,并动态设置买卖代币:

  • 买入:sell_token = wrapped_nativebuy_token = token_out
  • 卖出:sell_token = token_inbuy_token = wrapped_native

这一行代码的表述极其简单,但背后是整个报价链和签名页的全链路适配。买入和卖出在代币精度来源、代币余额检查、路径构造方向、以及合约方法调用上全部不同。

EVM 卖出需要额外的一步:approve 授权。

这是卖出实践中极容易遗漏的关键步骤。买入是用原生币(ETH/BNB)换代币,原生币天然可以直接支付。但卖出是花代币,代币是 ERC-20 合约,Router 合约没有权限直接扣用户的代币。用户必须先调用代币合约的 approve 方法,授权 Router 从他账户里划转指定数量的代币。

这个逻辑在签名页面 dex.html 中有完整的实现:

  1. 构造代币合约实例(ABI 包含 approveallowancebalanceOf
  2. 检查已有授权额度 allowance,如果不足,提示用户先签署一笔授权交易
  3. 授权成功后(approveTx.wait()),再构造 swapExactTokensForETH 的兑换路径并发起交易

买入方向则相对直接:swapExactETHForTokens 附带原生币即可执行,不需要授权步骤。

六、前端模态框与数据解耦架构

GeckoTerminal、DexScreener、CoinMarketCap 三个数据源各自提供了完全不同的代币列表渲染方式和数据结构,但它们都内嵌了 SWAP 功能。

为了实现解耦,核心架构设计遵循一个原则:每个数据源只负责携带三个数据(chain + tokenAddress + symbol)触发 SWAP 按钮事件,余下的所有工作(报价、签名、回调)全部由后端 + 签名页统一完成。

三个数据源的交互逻辑差异如下表:

数据源:GeckoTerminal、DexScreener、CoinMarketCap 链标识格式:ethereum/bsc 等、Ethereum/BNB Smart Chain 等 代币地址来源: pair.baseTokenAddress p.baseTokenAddress platform.token_address symbol来源:pair.baseToken、p.baseToken、symbol SWAP 按钮所在文件:sol.js(动态模态框)

CMC 的链标识格式与其他两个完全不同:CMC 使用全名(如 Ethereum),而后端 SWAP 引擎需要短名(如 ethereum)。因此在前端渲染 CMC 卡片时,必须通过一个映射表 cmcChainNameMap 将全名转换为短名再传入 SWAP 事件。

此外,签名页 dex.html 需要接收大量的交易参数。由于超长交易数据(如 Solana 的 swapTransaction 字段)可能使得 URL 长度超过系统限制,我选择了两级数据传递的方式:

  1. 基础参数(链、代币地址、金额、滑点、预估兑换额、方向)通过 URL query 传递
  2. 超长交易数据(txData 对象)不放入 URL,dex.html 加载后通过本地 HTTP 接口 GET /get_tx_data?session=... 使用 session_id 拉取

这种方式既保证了数据传递的完整性,也避免了长 URL 被 Windows 系统截断的问题。

七、安全检测与前端的结合

SWAP 功能集成之后,一个无法回避的问题是:用户在监控软件内看到一个新上线的代币,如何快速评估它是否安全?

GoPlus 安全引擎为这个问题提供了一道防线。在 SWAP 和 OKX 兑换按钮旁边,我增加了一个“🛡️ 检测”按钮。点击它,后端会调用 GoPlus API 对代币合约进行深度安全扫描,并将结果以弹窗形式呈现给用户。

image.png EVM 代币的检测分为两层:

  1. address_security 接口:检测地址是否关联已知恶意行为——假代币、蜜罐、洗钱、黑名单等 18 个风险标签
  2. token_security 接口:对合约进行更细粒度的代码层分析——是否开源、是否有增发权限、是否可以暂停交易、买卖税率、所有者是否已放弃所有权等

Solana 代币的检测集中在 token_security 接口,覆盖了:可增发权限(mintable)、可冻结权限(freezable)、余额可变权限(balance_mutable_authority)、代币元数据可变(metadata_mutable)、持币集中度、流动性锁定状态,以及 GoPlus 官方的信任评级(trusted_token)。

后端解析结果后,将逐条风险项(如“合约未开源”、“存在增发权限”、“持币集中度过高: 前 10 名持有者占比 89.3%”)通过 risk_details 数组传递给前端。这些信息直接来自链上合约的实际代码和数据权限,不是提示词的硬编码猜测。

前端弹窗根据风险等级(高/中/低)渲染不同的颜色和行动建议,将具体的风险项逐条列出。这为用户的交易决策提供了一个重要的参考点。值得说明的是,这个安全检测按钮完全是可选的,它不会干扰正常的交易流程,但对于不确定的代币,能在用户投入资金前多提供一层确认。

image.png

八、开发过程中的关键 Bug 复盘

Bug 1:execution reverted: 'no data'

这是在早期 SWAP 测试中最常遇到的错误。0x 和 ParaSwap 均未收录某些刚上线几分钟的新代币,程序回退到合约轮询步骤。当时的链上调用只配置了单一 Uniswap V2 Router。许多新代币只有 V3 池子,V2 中根本没有,合约调用必定 revert,且不附带有效的错误原因。

解决过程:引入 40+ 个 Router/Quoter 地址。每条链至少配置 3-4 个主流 DEX 的合约地址,兼顾 V2 Router 和 V3 Quoter。遍历时采取“找到即退出”的策略,任一合约返回有效结果即可。全部失败时,将晦涩的 execution reverted 替换为用户可见的中文提示。

Bug 2:Solana 卖出数量完全错误

早期测试中,用户输入 0.1 SOL 意图兑换某个 Solana 代币,结果在前端视图显示为 479378359 个 SOL。这个数字极其荒谬,根本原因在于 Solana 代币的 decimals 获取方式。初始使用 Helius RPC 获取,Helius 对新代币存在索引延迟,导致精度 fallback 到错误的默认值。后来切换为标准 Solana 公共 RPC 的 getTokenSupply 方法 + Jupiter Token 列表双重兜底后才彻底解决。

Bug 3:Base 链 DEX 页面参数为空

测试中发现,其他所有 EVM 链买入和卖出均正常跳转,唯独 Base 链的 DEX 页面会显示全部参数为空。调试发现,问题不在代码逻辑,而在 webbrowser.open 调用。Base 链某些低单价代币的 expectedOut 数值极大(零点后带有十几个零的小数),str() 输出不带科学计数法,导致 URL 长度轻松超过 Windows 系统的默认限制(约 2048 字符)。系统截断了 URL,浏览器只收到不完整的参数。

最终修复:在 open_swap_page_with_quote 和 open_jupiter_swap_page 方法中,将 {expected_out} 和 {min_out} 统一改为 {float(expected_out):.6f} 和 {float(min_out):.6f} 的格式化字符串,保留 6 位有效小数。改动虽小,但它直接解决了 Base 链上类似代币的 URL 超长问题。

Bug 4:卖出代币的符号显示

在所有链的卖出测试中,DEX 页面“支付”一栏都只能看到截断的合约地址(如 0x464ec1...),而不是可读的代币名称。这是因为签名页面收到的 URL 参数中只有代币地址(token),缺少代币符号。

解决过程:在 open_swap_page_with_quote 和 open_jupiter_swap_page 的参数列表中增加 token_symbol 字段,并拼入 URL。前端 dex.html 通过 params.get('tokenSymbol') 正确显示代币符号,同时做 fallback:如果 symbol 为空,才显示地址的截断。

结语:桌面软件 SWAP 功能的工程验收标准

在完成了报价引擎搭建、多链适配、双向交易、合约轮询、精度处理、安全检测集成、以及三个数据源的前端解耦之后,可以总结出在桌面软件中实现生产级 SWAP 功能的验收标准。

第一,报价覆盖率必须依赖多层接力架构。不能只依赖单一聚合器——新代币没有被收录是常态,必须有多聚合器 + 链上合约兜底的能力。我的系统配置了 40+ 个链上合约地址、4 个聚合器 API、1 个最终兜底端点,确保了报价的高覆盖。

第二,买卖双向的精细支持。前端买入和卖出在同一模态框内提供切换,后端在代币精度来源、代币余额检查、路径构造方向和合约方法调用上全部正确区分。EVM 卖出增加对应的 approve 授权流程,Solana 卖出适配其特殊的 decimals 获取机制。

第三,精度处理必须稳健。EVM 代币小数位不统一,Solana 代币精度获取更加依赖多重数据源。我的系统为 Solana 代币设置了两层精度兜底。任何一处精度错误,用户看到的数量都会出现数量级偏差。

第四,签名页面数据传递的健壮性。基础参数通过 URL query 传递,超长交易数据通过本地 HTTP 接口拉取,两者互不依赖。同时需要做好长 URL 被操作系统截断的防御式处理。

第五,安全检测与交易的结合。GoPlus 引擎能对 EVM 和 Solana 代币进行多维度的安全扫描,从恶意标签到合约权限,从持币集中度到流动性锁仓状态。这些检测能力通过按钮和弹窗的形式与 SWAP 功能结合,为交易决策提供了重要的链上参考。

一套完整的报价链为:0x → ParaSwap → OKX DEX → 40+ 个链上合约轮询 → 最终兜底。全部失败才输出“无流动性”的友好提示,而非晦涩的链上错误。

从架构决策到参数适配,从合约轮询到精度处理,从前后端解耦到安全检测整合,每一个环节都是一次具体工程问题的解决。这也是桌面软件 SWAP 功能从概念到落地的完整路径。

GITHUB:github.com/pingdj/Web3