梳理了交易所钱包系统中充值处理环节,可能遇到的各种问题。从 如何识别 ETH 和 ERC20 充值、到 应对区块链分叉,再到 数据库的表结构与用户余额的聚合视图,完整地走了一遍充值业务的核心流程。
在前两篇文章中,我们分别介绍了交易所钱包系统的整体架构设计,以及签名机与用户账户生成的方案。本篇我们将继续深入,处理另一个核心环节 —— 处理用户的充值。
处理用户充值可以拆分为几个问题:
处理用户充值,对应着上一篇 独立出来的 scan
模块。具体示例可参考 cex-wallet/scan。
在很多的链上原生代币(如 ETH、SOL) 和用户创建的代币(例如以太坊上的ERC20 和 Solana 上的SPL Token)交易时不一样的,这篇文章仅考虑以太坊系的区块链(所有的EVM 兼容链的逻辑是一样的)。
ETH 转账是直接转账到目标地址,可以用 JSON 来表示,大概是这样:
{
from: 0xabc... // 发送者
to: 0x123... // 接收者
amount: 1 eth // 发送的eth数量
gas: 21000 // ...
...
}
因此,判断交易是不是, 只要判断交易的 to
地址是否属于我们为用户生成的地址。
使用 Viem 解析的关键代码:
const block = await this.getBlock(blockNum); // 获取区块
if (block?.transactions) {
for (const txData of block.transactions) { // 遍历所有区块
if (typeof txData !== 'string') {
// 检查是否是ETH转账到用户地址
if (txData.to &&
userAddresses.some(addr => addr.toLowerCase() === txData.to!.toLowerCase()) &&
txData.value > 0n) {
logger.info('发现ETH转账');
}
}
}
}
这里有注意两个小问题:
to
将是一个合约地址,就无法识别出 ETH 转账,此时,需要调用 RPC 的debug_traceTransaction
查找交易里面的 call
调用来识别 ETH 转账,匹配 trace 中 call 的 to
地址是不是用户地址。不过很多 RPC 节点,尤其是免费的节点,是不支持 debug_traceTransaction
方法的,因此,在业务量不大时,可以提示用户不要通过合约转账,在规模化业务中,建议自建支持 debug_traceTransaction
的节点。ERC20 Token 的充值并不是直接转账到地址,而是通过 ERC20 合约调用transfer(to, value)
完成的。用Json 来表示,大概是这样:
{
from: 0xabc... // 发送者
to: 0x20... // ERC20 合约
input: 0xa9059cbb to 地址 + 金额 编码 // transfer 函数的编码
amount: 0 //
gas: 50000 // ...
...
}
但是, ERC20 充值交易通常不会采用解析交易,而是通过解析Transfer
事件日志来识别,这样可以有效识别合约中的 ERC20 转账,每一笔 ERC20 转账有以下事件标识:
topics[0]
= Transfer(address,address,uint256)
的 Keccak 哈希
topics[1]
= from
地址
topics[2]
= to
地址
data
= 转账金额
另外事件中还会包含触发事件的合约地址(log.address
),因此我们只要匹配 log.address
是交易所支持的 Token 合约以及日志中to
地址是数据库中的用户地址就可以是识别 ERC20 的充值。
要判断一个地址是不是在一个集合(Token 地址集合或用户地址集合)里,比较容易想到的方法是用数组,链表这样的数据结构把元素保存起来,然后依次比较来确定。交易所会支持很多的 ERC20 Token ,还有大量的用户,随着集合的变大,会面临明显的性能问题。
这里就需要使用日志默认支持的布隆过滤器(Bloom Filter), 布隆过滤器可以高效判断一个元素是否在集合中,因此我们可以将地址分组(如每组 1000 个地址)传给 getLogs:
// Transfer(address indexed from, address indexed to, uint256 value)
const transferTopic = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';
const logs = await this.currentClient.getLogs({
fromBlock: typeof fromBlock === 'number' ? `0x${fromBlock.toString(16)}` : fromBlock,
toBlock: typeof toBlock === 'number' ? `0x${toBlock.toString(16)}` : toBlock,
address: tokenAddresses, // 过滤 所有的 Token合约
topics: [
transferTopic, // Transfer事件
null, // from地址(不过滤)
userAddresses.map(addr => `0x${addr.slice(2).padStart(64, '0')}`) // to地址(过滤一批用户地址)
]
});
通过解析转账事件要注意的 ,要防止恶意的 Token ,没有真实转账,但是触发了事件,通常得要求 Token 开源且审计,另外如果是 fee-on-transfer 的 ERC20 , 这种代币会在转账时自动收取手续费,导致日志中的金额和实际到账金额不一致,需要单独适配处理, 必要是使用余额做二次校验 (balanceOf
) 。
了解如何识别解析充值交易, 我们就可以写一个程序,不断获取区块,把解析的区块写入到数据中:
但是,由于区块链是一个分布式的网络, 由众多的节点独立运行,很可能出现,在某一些时间段,网络会出现分叉,但其中的一条延长出块后,另外这被回滚或被重组,如下图:
如果我们解析的交易出现在被回滚的区块上呢? 系统可能误以为充值成功,但是链上真实的充值交易可能被回滚掉了。
最简单的方式是处理方式,scan 程序只处理达到了最终确定性(finalized
)的区块交易,这些交易理论上不会出现回滚。
在 POW 链上,通常我们会看一个区块后,累计了多少算力,算力越低的链,就需要等待约多的后续区块,通常认为比特币追加 6 个区块后几乎不可能重组(约经过 1 个小时)。而低算力的链,就需要追加几十甚至几百个区块。
在 POS 链上,例如以太坊、Solana 等,通常会有最终确定性标记finalized
, 表示2/3 以上的验证签名验证了该区块,这个区块不会被重组。在以太坊是经过 2 个 epoch (每个 epoch 为 32 个区块, 大约 6 分多钟)达到最终确定性。
这个处理方法足够安全,最大的问题体验较差,用户充值需要等待较长的时间,才能看到充值成功。
区块链区块分叉并不是一个大概率事件,而且多少情况下,用户的交易是被重组,而不是被撤销, 因此更好的处理方式是实时处理用户的交易,在区块链上发生重组时,本地数据库同步回滚掉原来的交易,在重组后的区块上重新扫块入库。
区块分叉检测
区块链上每一个区块都包含了上一个区块的 Hash , Scan 模块在扫描区块时,就记录下每一个扫描区块的高度与Hash, 当我们从链上获取到一个最新的区块,出现:
则说明链已经出现了分叉,当出现分叉时, 我们需要向前回溯到本地和区块链有共同 hash 的祖先区块,因为重组很可能有多个区块都被重组了,此时需要把数据库中, 发生重组的区块的交易都清理掉, 然后从共同祖先区块的下一个区块,重新扫描。
方法 2 现在是业界常规做法, 也是 cex-wallet/scan 代码实现的逻辑。
scan 程序启动时,读取上一次扫描区块的高度(如果数据没有数据,则配置一个起始的扫块高度)与链上最新的高度:
finalized
的区块(获取经过了我们的要求的确认数),可以批量扫描解析入库直到我们扫描跟踪到当前的最新高度,并保持跟踪...
首先我们需要区块表和交易表,并且每个链应该创建各自的区块表和交易表,我们当前的实现时,暂时只支持 ETH, 因此只有一个 blocks 和 transactions,表的字段如下:
如果有后续有多个链,应该是使用更具体的名称,例如:eth_blocks、 eth_transactions、solana_blocks、solana_transactions 。
字段 | 类型 | 说明 |
---|---|---|
hash | TEXT | 主键,区块哈希 |
parent_hash | TEXT | 父区块哈希 |
number | TEXT | 区块号,大整数存储 |
timestamp | INTEGER | 区块时间戳 |
status | TEXT | 区块确认状态:confirmed(safe、finalized)被重组:orphaned |
created_at | DATETIME | 创建时间 |
updated_at | DATETIME | 更新时间 |
区块表 (blocks) 主要作用是记录扫描的进度。
字段 | 类型 | 说明 |
---|---|---|
id | INTEGER | 主键,自增 |
block_hash | TEXT | 交易哈希 |
block_no | INTEGER | 区块哈希 |
tx_hash | TEXT | 交易哈希,唯一 |
from_addr | TEXT | 发起地址 |
to_addr | TEXT | 接收地址 |
token_addr | TEXT | Token 合约地址 |
amount | TEXT | 交易金额(存储为字符串避免精度丢失) |
type | TEXT | 交易类型 充值提现归集调度:deposit/withdraw/collect/rebalance |
status | TEXT | 交易状态:confirmed/safe/finalized/failed |
confirmation_count | INTEGER | 确认数(网络终结性模式下可选) |
created_at | DATETIME | 创建时间 |
updated_at | DATETIME | 更新时间 |
交易记录表 (transactions) 当前作用是记录我们识别到的充值交易。
status 和 confirmation_count 用来记录交易被确认的信息,对于 POS 网络,有网络终结性的状态,status 根据网络共识状态逐步从 confirmed
→ safe
->finalized
。 对于 POW 网络,或者一些本地测试网络,我们可以为网络配置一个确认数 confirmationBlocks
,然后对比区块高度差与确认数,区块高度差 >= confirmationBlocks
, 可以认为是 finalized
。
tokens 表只需要一个全局的表即可, 在一个表中记录所有的支持的 Token, 包括 原生代币, 表结构如下:
字段 | 类型 | 说明 |
---|---|---|
id | INTEGER | 主键,自增 |
chain_type | TEXT | 链类型:eth/btc/sol/polygon/bsc 等 |
chain_id | INTEGER | 链ID:1(以太坊主网)/5(Goerli)/137(Polygon)/56(BSC) 等 |
token_address | TEXT | 代币合约地址(原生代币为NULL或全零地址), Solana 上为 Mint 地址 |
token_symbol | TEXT | 代币符号:ETH/USDC/USDT/BTC/SOL 等 |
token_name | TEXT | 代币全名:Ethereum/USD Coin/Bitcoin 等 |
decimals | INTEGER | 代币精度(小数位数),默认18 |
is_native | BOOLEAN | 是否为链原生代币(ETH/BTC/SOL等) |
collect_amount | TEXT | 归集金额阈值,大整数存储 |
status | INTEGER | 代币状态:0-禁用,1-启用 |
created_at | DATETIME | 创建时间 |
updated_at | DATETIME | 更新时间 |
处理 Token 时,要注意的是,在区块链上可能有多个合约对应同一个币, 例如 原生的 ETH , 和 WETH ,本质上是一个币。通过跨链桥跨的 Token 和官方发行的是一样的。同时为了防止 Token 重复,可以创建一个多链代币唯一索引: UNIQUE(chain_type, chain_id, token_address, token_symbol)
。
上面几个表比较简单,基本上是链上的字段是对应的。
但是,我们该如何汇总余额的呢? 用户在交易所中的资金余额,可能来自多个链的多笔充值。
字段 | 类型 | 说明 |
---|---|---|
id | INTEGER | 主键 |
user_id | INTEGER | 用户ID |
address | TEXT | 钱包地址 |
chain_type | TEXT | 链类型:eth/btc/sol/polygon/bsc 等 |
token_id | INTEGER | 代币ID,关联 tokens 表 |
token_symbol | TEXT | 代币符号,冗余字段便于查询 |
address_type | INTEGER | 地址类型:0-用户地址,1-热钱包地址(归集地址),2-多签地址 |
balance | TEXT | 可用余额,大整数存储 |
created_at | DATETIME | 创建时间 |
updated_at | DATETIME | 更新时间 |
添加一个余额表 (balances), 是很直观的处理方法,每当发现一笔 finalized
的充值交易时,用户对应的余额记录,增加相应的金额,获取用户的余额,只需要查询这个表。
但是这个余额表有一个隐患:用户余额的增加,无法对应上充值交易(缺少对账能力),如果出现一笔充值交易,余额表余额增加了 2 次怎么办? 另外,如果 finalized
的交易也出现了回滚, 余额应该如何处理呢? 使用余额表将缺少幂等性保证,因此使用余额表 (balances) 不是很安全。
备注:数据库幂等性指的是一个操作(如增删改)执行一次和执行多次所产生的最终影响是一样的,即多次执行不会改变系统的状态或业务结果。在分布式系统中,为防止因网络异常导致重复执行操作而引起数据不一致或业务混乱(如支付重复、订单重复下单),确保操作的幂等性尤为重要。
更推荐的方法是添加资金流水表 , 当前每一条记录对应着一条充值记录, 后续交易所内部用户转账或者现货交易,都可以对应相应的记录,用户的余额 = 流水的聚合, 表结构如下:
字段 | 类型 | 说明 |
---|---|---|
id | INTEGER | 主键,自增 |
user_id | INTEGER | 用户ID |
address | TEXT | 钱包地址 |
token_id | INTEGER | 代币ID,关联tokens表 |
token_symbol | TEXT | 代币符号,冗余字段便于查询 |
amount | TEXT | 金额,正数入账负数出账,以最小单位存储 |
credit_type | TEXT | 流水类型:deposit/withdraw/collect/rebalance/trade_buy/trade_sell/freeze/unfreeze等 |
business_type | TEXT | 业务类型:blockchain/spot_trade/internal_transfer/admin_adjust等 |
reference_id | TEXT | 关联业务ID(如txHash_eventIndex) |
reference_type | TEXT | 关联业务类型(如blockchain_tx) |
status | TEXT | 状态:pending/confirmed/finalized/failed |
block_number | INTEGER | 区块号(链上交易才有) |
tx_hash | TEXT | 交易哈希(链上交易才有) |
event_index | INTEGER | 事件索引(区块链事件的logIndex) |
metadata | TEXT | JSON格式的扩展信息 |
created_at | DATETIME | 创建时间 |
updated_at | DATETIME | 更新时间 |
我们可以在数据上建一个唯一性约束: UNIQUE(reference_id, reference_type, event_index)
, 这样就不用担心交易的重放问题,并且,每条记录都可以朔源。
然后在 credits
和 tokens
上,建立一个用户余额聚合视图表v_user_token_totals
(有删减,完整的 sql 在这里 ):
CREATE VIEW IF NOT EXISTS v_user_token_totals AS
SELECT
c.user_id,
c.token_id,
c.token_symbol,
t.decimals,
SUM(CASE
WHEN c.status = 'finalized'
THEN CAST(c.amount AS REAL)
ELSE 0
END) as total_balance,
PRINTF('%.6f', SUM(CASE
WHEN c.status = 'finalized'
THEN CAST(c.amount AS REAL)
ELSE 0
END) / POWER(10, t.decimals)) as total_balance_formatted, # 注意不同的 decimal 要统一为一致的单位
COUNT(DISTINCT c.address) as address_count,
MAX(c.updated_at) as last_updated
FROM credits c
JOIN tokens t ON c.token_id = t.id
GROUP BY c.user_id, c.token_id, c.token_symbol, t.decimals
HAVING total_balance > 0
现在获取用户,只需要查询 用户余额聚合视图表v_user_token_totals
就可以了。
这篇文章我们梳理了交易所钱包系统中充值处理环节,可能遇到的各种问题。从 如何识别 ETH 和 ERC20 充值、到 应对区块链分叉,再到 数据库的表结构与用户余额的聚合视图,完整地走了一遍充值业务的核心流程,通过本文的设计,交易所既能保证用户体验(实时入账),又确保资产安全和一致性。
希望对大家实现交易所类托管系统有所帮助, 代码已经完全开源, 请参考这里 。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!