交易所钱包系统开发 #4 - 用户提现

  • Tiny熊
  • 发布于 8小时前
  • 阅读 138

在前三篇文章,我们分别介绍了交易所钱包系统的整体架构设计、签名机与用户账户生成的方案以及用户充值,这篇文章介绍如何为交易所增加提现功能。提现流程用户提现流程通常是这样的:用户发起一个提现后端业务判断用户是否有足够的余额,是否大于最小的提现金额风控做异常地址、提现限额等检测,签名许可

在前三篇文章,我们分别介绍了 交易所钱包系统的整体架构设计签名机与用户账户生成的方案 以及用户充值, 这篇文章介绍如何为交易所增加提现功能。

提现流程

用户提现流程通常是这样的:

  1. 用户发起一个提现
  2. 后端业务判断用户是否有足够的余额,是否大于最小的提现金额
  3. 风控做异常地址、提现限额等检测,签名许可交易
  4. 后端选择一个合适的热钱包为用户转账,请求内网热钱包所在的签名机对提现交易签名
  5. 后端业务收到, 将提现交易发送到 RPC 节点
  6. 更新用户的余额以及热钱包的余额

执行时序图大致如下:

sequenceDiagram
    用户->>wallet: 提款(链,Token,数量)
    wallet->>wallet: 余额检查
    wallet->> 风控: 异常检查
    风控 -->> wallet: 签名确认
    wallet->>wallet: 选热钱包、确认nonce、gas、准备交易
    wallet->>签名机: 请求签名(签名交易信息、风控签名)
    签名机-->>wallet: 交易签名
    wallet->>RPC 节点: 签名发送交易到节点
    wallet->>wallet: 更新用户提款状态
    scan->>RPC 节点: 确认提款交易完成

在业务上,我们添加一个 withdraws 更跟踪提现的全生命周期状态:从用户发出提现申请user_withdraw_request 到请求签名机签名signing ,以及发出提现交易和交易的确认,withdraws 各字段如下:

字段 类型 说明
id INTEGER 主键,自增
user_id INTEGER 用户ID,外键关联 users 表
from_address TEXT 热钱包地址(可为空,签名时填充)
to_address TEXT 提现目标地址
token_id INTEGER 代币ID,外键关联 tokens 表
amount TEXT 用户请求的提现金额,最小单位存储
fee TEXT 交易所收取、提现手续费,最小单位存储,默认 '0'
chain_id INTEGER 链ID
chain_type TEXT 链类型:evm/btc/solana
status TEXT 提现状态:user_withdraw_request/signing/pending/processing/confirmed/failed
tx_hash TEXT 交易哈希(签名后填充)
nonce INTEGER 交易 nonce(签名时填充)
gas_used TEXT 实际使用的 gas(确认后填充)
gas_price TEXT Gas 价格(Legacy 交易)
max_fee_per_gas TEXT 最大费用(EIP-1559 交易)
max_priority_fee_per_gas TEXT 优先费用(EIP-1559 交易)
error_message TEXT 错误信息(失败时填充)
created_at DATETIME 创建时间
updated_at DATETIME 更新时间

之前设计资金流水表 (credits) 会关联业务ID,在提现时,关联的是 withdraws 的 id ,当提现时,我们会在资金流水表 (credits) 中,为对应的用户和热钱包地址添加一个条 amount 为负数的记录, 以便资金流水表 (credits) 在汇总时能够准确反映各地址的余额。

提现的业务逻辑是比较清晰的,在这篇文章中,主要讨论在提现中,需要仔细考虑的几个问题有:

  1. 如何选择哪一个热钱包处理提现
  2. 同时多笔提现时热钱包如何管理 nonce
  3. 扣款多少用户手续费,设置多少网络打包的 gas 费
  4. 同时多笔提现时,如何优化 gas

这篇文章暂时不考虑风控(以后风控单独写一篇文章,继续挖个坑)。

我们来看看如何在设计和实现上解决这些问题。

热钱包管理

热钱包和之前的用户地址一样,也是由签名机来管理私钥,而且通常为了分散风险,会安排多个独立的热钱包签名机(甚至是使用安全的硬件环境)。

有多个热钱包,我们需要用数据库把热钱包管理起来,可以添加一个单独的热钱包表,也可以在之前设计的 wallets 中,添加一个 wallet_type 字段,来表示是热钱包还是普通用户钱包。

我在cex-wallet 实现时,使用了是后者,因为在处理用户充值 中实现的 scan 模块会跟踪 wallets 下所有钱包地址的充值,更方便通过资金流水 credits 查询到热钱包的余额。

选择哪一个热钱包处理提现,肯定是要求热钱包有足够给用户转账的余额, 如果可以从自己的数据库中查询到余额,就会更快和方便。

更新后的 wallets 表结构是这样的:

字段 类型 说明
id INTEGER 主键,自增
user_id INTEGER 用户ID,外键关联 users 表
address TEXT 钱包地址,唯一
device TEXT 来自哪个签名机设备地址
path TEXT 推导路径
chain_type TEXT 地址类型:evm、btc、solana
wallet_type TEXT 钱包类型:user(用户钱包)、hot(热钱包)、multisig(多签钱包)、cold(冷钱包)、vault(金库钱包)
is_active INTEGER 是否激活:0-未激活,1-激活
created_at DATETIME 创建时间
updated_at DATETIME 更新时间

wallet_type 可以是 user(用户钱包)、hot(热钱包)、multisig(多签钱包) 等值,由于wallets表有一个外键 user_id, 在 users 表中也添加了一个 user_type 来模拟一些特殊的系统用户。

在选择热钱包处理提现的策略上,在各个钱包都有余额的情况下,最好的方式应该是让所有的热钱包轮流处理提现,这样的设计会更多避免因同一个账户有很多的 pending (待打包)交易,引起可能的 nonce 重复和余额更新不正确的问题。

热钱包的选择,可以和 nonce 管理结合起来,如果在数据库里记录了该地址 nonce 的使用时间,我们总是可以选择一个最久未使用的热钱包处理提现,从而实现“轮流”实现的效果。

热钱包 Nonce 管理

我们先回顾一下 EVM 链账户下 nonce (注意比特币、Solana 是没有 Nonce), nonce 是账户交易的序号,每次处理一笔交易后 nonce 值加 1。

nonce 的主要作用是保证交易顺序防止交易重放,一笔交易会包含以下几个信息:

{
   to:  0xabcd    // 交易发给谁
   value: 100.    // 发送多少 ether 
   input data: 0x60edad   // 通常是合约函数的编码
   nonce: 0      // 交易的序号
   gas: 60000 
   ... 
}

只有交易中的 nonce 与链上的用户的 nonce 匹配时,交易才会被执行。

image-20250928171133965

如上图:如果 nonce 为 4 的交易没有执行, nonce = 5 的交易会被挂起(queued),通常叫做 “nonce gap”(nonce 空洞), 即使愿意给再高的小费,也无法被执行, nonce 决定严格执行顺序(低 nonce 交易没确认,高 nonce 交易永远上不了链)。如果 nonce 为 4 的交易有多个,也只有一个交易被执行,例如图中红色的交易被舍弃。

如果要批量处理提现,就必须自己管理好每个账户的 nonce ,可以创建一张 wallet_nonces 表:

字段 类型 说明
id INTEGER 主键,自增
wallet_id INTEGER 关联 wallets.id
chain_id INTEGER 链ID
nonce INTEGER 当前 nonce 值,用于交易排序
last_used_at DATETIME 最后使用时间
created_at DATETIME 创建时间
updated_at DATETIME 更新时间

nonce 的初始值需要从链上获取。每次使用热钱包发出交易时,都在数据库中将 nonce + 1 并更新 last_used_at。下一次提现时,可以选择一个最久未使用的热钱包,实现多个热钱包负载均衡。

备注:使用 RPC 节点的 eth_getTransactionCount(address, "pending") 也可以获取到待打包的交易数,但不如数据库管理实时。

实现代码参考walletBusinessService.tswithdrawFunds 方法。

手续费设置

作为开发者,我们知道每一笔交易都需要给矿工或验证者支付手续费,手续费是以原生币来支付的,并且手续费是随网络的交易拥堵情况波动的。如果每次由用户设置提现手续费在体验上是不友好的,多数的交易所会选择固定费用扣除的方式。

提现扣除

固定费用扣除,即无论链上实际成本多少,统一收取固定的手续费。这个固定的手续费根据链和 Token 在数据库中配置,这样的好处是用户可预期,操作简单,实际到账的金额总是提现金额减去固定的手续费。

缺点是在网络非常拥堵时,手续费可能不足以覆盖成本,但是可以将固定的手续费设置为覆盖多数情况,再加上 例如 20% 溢价(buffer)。

如果需要对某一些币过推广运营,也可以将手续费设置为 0 。

要实现这个配置, 需要在原来的 Tokens 表 添加 withdraw_fee 字段, 另外也加上一个 min_withdraw_amount 作为最小提现金额, 更新后的 tokens 表结构如下:

字段 类型 说明
id INTEGER 主键
chain_type TEXT 链类型:eth/btc/sol/polygon/bsc 等
chain_id INTEGER 链ID:1(以太坊主网)/5(Goerli)/137(Polygon)/56(BSC) 等
token_address TEXT 代币合约地址(原生代币为空)
token_symbol TEXT 代币符号:USDC/ETH/BTC/SOL 等
token_name TEXT 代币全名:USD Coin/Ethereum/Bitcoin 等
decimals INTEGER 代币精度(小数位数)
is_native BOOLEAN 是否为链原生代币(ETH/BTC/SOL等)
collect_amount TEXT 归集金额阈值,大整数存储
withdraw_fee TEXT 提现手续费,最小单位存储,默认 '0'
min_withdraw_amount TEXT 最小提现金额,最小单位存储,默认 '0'
status INTEGER 代币状态:0-禁用,1-启用
created_at DATETIME 创建时间
updated_at DATETIME 更新时间

例如,可以配置 USDC 的 withdraw_fee 为 0.5 USDC ,最小提现金额 min_withdraw_amount 为 5 USDC, 当用户提取 5 USDC 时, 实际给用户转账金额为 4.5 USDC 。 多数情况下实际消耗的 gas 费 0.35 U 左右,交易所就可以额外赚取到一些手续费。

评估真实 Gas

设置交易的 Gas 需要综合考虑成本、用户体验(到账速度)。

EIP1559 升级后, 交易的手续费计算方式如下:

支付手续费 = Gas *(baseFee + priorityFee) 

交易中需要设置 gas limitmaxFeePerGasmaxPriorityFeePerGas

gas limit : 控制交易的总的运算量,实际的运行量 gas 需要小于 gas limit, 这部分通常是固定的, ETH 转账为 21,000 gas , ERC20 转账通常是 5 万多 gas. (可以在 Tokens 表添加一个 gas 配置)。

maxPriorityFeePerGas : 给矿工单位 gas 的小费, priorityFee 需要小于等于 maxPriorityFeePerGas

maxFeePerGas : 控制单位 gas 消耗费用的总上限,baseFee + priorityFee 需要小于等于 maxFeePerGas

想保证快速打包,又不浪费,常见的做法是采用动态链上费率跟随策略, 通过 rpc 接口 eth_feeHistory 查询在链上查询最近 n 个区块(例如 20 个)的 PriorityFee 的中位数,如果要更快,可以使用超过 70% 或 90% 分位的PriorityFee,baseFeePerGas 可以使用最近的一个区块的 baseFeePerGas。然后将 maxFeePerGas 设置成 baseFeePerGas * 2n + priorityFee, 这样可以适应网络的波动,也不会造成费用的浪费。 实现代码可参考gasEstimation .

这个策略满足大多数的情况,也还可以添加一些优化,笔录根据用户的等级,如 VIP 用户设置更高的 gas , 保证关键客户体验。如果超过 N 分钟未确认,就自动提升 gas 重发交易

在确定好热钱包、nonce、转账金额(扣除手续费后的)、gas、maxFeePerGas、maxPriorityFeePerGas 等参数后,就可以请求相应的签名机获取交易签名,离线签名在 sign 中,关键代码如下:

transaction = {
            to: request.tokenAddress,
            gas: request.gas,
            value: request.tokenAddress ? 0n : BigInt(request.amount)
            nonce,
            type: 'eip1559' as const,
            maxFeePerGas,
            maxPriorityFeePerGas: maxPriorityFee,
            chainId: request.chainId
          }
// account 根据助记词、路径、密码加载而来    
account.signTransaction(transaction);

获取交易签名后,就通过 RPC 节点调用 sendRawTransaction 将交易广播出去 。

批量提现 Gas 优化

如果短时间里提现较多可以使用批处理,批量合并多笔提款转账,以太坊热钱包(EOA 账户)转账一次只能发起一个交易,而且每次转账需要支付基础的 21000 Gas 费, 依旧使用热钱包就会又慢又贵。

今年 3 月份,加入了一个新的特性,可以将 EOA 账户转为 EIP7702 智能账户,直接在原账户上支持批量交易,从而减少 gas 开销。

比特币和 Solana 默认支持批量交易

对于还不支持 EIP7702 的 EVM 链,可以编写一个金库合约,在金库合约中实现批量转账功能,由签名机签名调用金库合约实现批量转账,哈哈,不过我当前的代码中,还没有实现批量提现功能,如果有同学来一起帮忙贡献提交 PR。

总结

提现业务相较于充值更加复杂一些,不仅涉及到 热钱包管理nonce 管理手续费设置,如果是高并发场景下,还要考虑 Gas 优化批量处理

  • 在热钱包设计上,应支持多个热钱包并轮流使用,以避免 nonce 冲突和单点拥堵。
  • 在 nonce 管理上,可以结合链上查询与数据库管理,确保交易顺序的正确性。
  • 在手续费设置上,用户视角采用固定手续费,真实 gas 则动态跟踪链上费率,平衡成本与用户体验。
  • 最后可以考虑使用批量提现,可以利用 EIP7702 或金库合约优化 gas 成本。

希望对大家实现交易所类托管系统有所帮助, 代码已经完全开源, 请参考这里

点赞 0
收藏 2
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Tiny熊
Tiny熊
0xD682...E8AB
登链社区发起人 通过区块链技术让世界变得更好而尽一份力。