在前三篇文章,我们分别介绍了交易所钱包系统的整体架构设计、签名机与用户账户生成的方案以及用户充值,这篇文章介绍如何为交易所增加提现功能。提现流程用户提现流程通常是这样的:用户发起一个提现后端业务判断用户是否有足够的余额,是否大于最小的提现金额风控做异常地址、提现限额等检测,签名许可
在前三篇文章,我们分别介绍了 交易所钱包系统的整体架构设计、签名机与用户账户生成的方案 以及用户充值, 这篇文章介绍如何为交易所增加提现功能。
用户提现流程通常是这样的:
执行时序图大致如下:
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) 在汇总时能够准确反映各地址的余额。
提现的业务逻辑是比较清晰的,在这篇文章中,主要讨论在提现中,需要仔细考虑的几个问题有:
这篇文章暂时不考虑风控(以后风控单独写一篇文章,继续挖个坑)。
我们来看看如何在设计和实现上解决这些问题。
热钱包和之前的用户地址一样,也是由签名机来管理私钥,而且通常为了分散风险,会安排多个独立的热钱包签名机(甚至是使用安全的硬件环境)。
有多个热钱包,我们需要用数据库把热钱包管理起来,可以添加一个单独的热钱包表,也可以在之前设计的 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 的使用时间,我们总是可以选择一个最久未使用的热钱包处理提现,从而实现“轮流”实现的效果。
我们先回顾一下 EVM 链账户下 nonce (注意比特币、Solana 是没有 Nonce), nonce
是账户交易的序号,每次处理一笔交易后 nonce
值加 1。
nonce
的主要作用是保证交易顺序和防止交易重放,一笔交易会包含以下几个信息:
{
to: 0xabcd // 交易发给谁
value: 100. // 发送多少 ether
input data: 0x60edad // 通常是合约函数的编码
nonce: 0 // 交易的序号
gas: 60000
...
}
只有交易中的 nonce 与链上的用户的 nonce 匹配时,交易才会被执行。
如上图:如果 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.ts 的 withdrawFunds
方法。
作为开发者,我们知道每一笔交易都需要给矿工或验证者支付手续费,手续费是以原生币来支付的,并且手续费是随网络的交易拥堵情况波动的。如果每次由用户设置提现手续费在体验上是不友好的,多数的交易所会选择固定费用扣除的方式。
固定费用扣除,即无论链上实际成本多少,统一收取固定的手续费。这个固定的手续费根据链和 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 需要综合考虑成本、用户体验(到账速度)。
在 EIP1559 升级后, 交易的手续费计算方式如下:
支付手续费 = Gas *(baseFee + priorityFee)
交易中需要设置 gas limit
、maxFeePerGas
、maxPriorityFeePerGas
:
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
将交易广播出去 。
如果短时间里提现较多可以使用批处理,批量合并多笔提款转账,以太坊热钱包(EOA 账户)转账一次只能发起一个交易,而且每次转账需要支付基础的 21000 Gas 费, 依旧使用热钱包就会又慢又贵。
今年 3 月份,加入了一个新的特性,可以将 EOA 账户转为 EIP7702 智能账户,直接在原账户上支持批量交易,从而减少 gas 开销。
比特币和 Solana 默认支持批量交易
对于还不支持 EIP7702 的 EVM 链,可以编写一个金库合约,在金库合约中实现批量转账功能,由签名机签名调用金库合约实现批量转账,哈哈,不过我当前的代码中,还没有实现批量提现功能,如果有同学来一起帮忙贡献提交 PR。
提现业务相较于充值更加复杂一些,不仅涉及到 热钱包管理、nonce 管理、手续费设置,如果是高并发场景下,还要考虑 Gas 优化 和 批量处理。
希望对大家实现交易所类托管系统有所帮助, 代码已经完全开源, 请参考这里 。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!