交易所钱包系统开发 #3 - 处理用户充值

  • Tiny熊
  • 发布于 14小时前
  • 阅读 180

梳理了交易所钱包系统中充值处理环节,可能遇到的各种问题。从 如何识别 ETH 和 ERC20 充值、到 应对区块链分叉,再到 数据库的表结构与用户余额的聚合视图,完整地走了一遍充值业务的核心流程。

在前两篇文章中,我们分别介绍了交易所钱包系统的整体架构设计,以及签名机与用户账户生成的方案。本篇我们将继续深入,处理另一个核心环节 —— 处理用户的充值

处理用户充值可以拆分为几个问题:

  • 如何识别充值交易及解析充值交易,要关注到原生代币(Coin , 如 ETH ) 与 合约代币(ERC20 Token) 的不同、以及在大量的用户下,该如何高效解析交易?
  • 区块链上的交易是有可能发生分叉重组的,当发生重组时,如何应对分叉?
  • 交易所的资产通常对应着多条链不同的合约,如何设计数据库及查询为用户提供统一的余额查询接口

处理用户充值,对应着上一篇 独立出来的 scan 模块。具体示例可参考 cex-wallet/scan

识别解析充值交易

在很多的链上原生代币(如 ETH、SOL) 和用户创建的代币(例如以太坊上的ERC20 和 Solana 上的SPL Token)交易时不一样的,这篇文章仅考虑以太坊系的区块链(所有的EVM 兼容链的逻辑是一样的)。

ETH 充值

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转账');
          }
       }
    }
  }

这里有注意两个小问题:

  1. 如果通过利用合约向用户地址充值 ETH , to 将是一个合约地址,就无法识别出 ETH 转账,此时,需要调用 RPC 的debug_traceTransaction 查找交易里面的 call 调用来识别 ETH 转账,匹配 trace 中 call 的 to 地址是不是用户地址。不过很多 RPC 节点,尤其是免费的节点,是不支持 debug_traceTransaction 方法的,因此,在业务量不大时,可以提示用户不要通过合约转账,在规模化业务中,建议自建支持 debug_traceTransaction 的节点。
  2. 如果用户是一个区块验证者,它直接将的区块奖励设置为我们为期生成的地址上,也无法识别,因为区块奖励不是一笔正常交易。

ERC20 充值

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) 。

遍历区块、分叉重组处理

了解如何识别解析充值交易, 我们就可以写一个程序,不断获取区块,把解析的区块写入到数据中:

image-20250919103254009

但是,由于区块链是一个分布式的网络, 由众多的节点独立运行,很可能出现,在某一些时间段,网络会出现分叉,但其中的一条延长出块后,另外这被回滚或被重组,如下图:

image-20250919094746372

如果我们解析的交易出现在被回滚的区块上呢? 系统可能误以为充值成功,但是链上真实的充值交易可能被回滚掉了。

方法1:仅解析最终确定性的区块

最简单的方式是处理方式,scan 程序只处理达到了最终确定性(finalized)的区块交易,这些交易理论上不会出现回滚。

在 POW 链上,通常我们会看一个区块后,累计了多少算力,算力越低的链,就需要等待约多的后续区块,通常认为比特币追加 6 个区块后几乎不可能重组(约经过 1 个小时)。而低算力的链,就需要追加几十甚至几百个区块。

在 POS 链上,例如以太坊、Solana 等,通常会有最终确定性标记finalized, 表示2/3 以上的验证签名验证了该区块,这个区块不会被重组。在以太坊是经过 2 个 epoch (每个 epoch 为 32 个区块, 大约 6 分多钟)达到最终确定性。

这个处理方法足够安全,最大的问题体验较差,用户充值需要等待较长的时间,才能看到充值成功。

方法2:链回滚时,数据库同步回滚

区块链区块分叉并不是一个大概率事件,而且多少情况下,用户的交易是被重组,而不是被撤销, 因此更好的处理方式是实时处理用户的交易,在区块链上发生重组时,本地数据库同步回滚掉原来的交易,在重组后的区块上重新扫块入库。

区块分叉检测

区块链上每一个区块都包含了上一个区块的 Hash , Scan 模块在扫描区块时,就记录下每一个扫描区块的高度与Hash, 当我们从链上获取到一个最新的区块,出现:

  1. 区块高度与数据库相同,但是 区块 Hash 不同
  2. 区块高度+1 , 而 父区块 Hash 不同

则说明链已经出现了分叉,当出现分叉时, 我们需要向前回溯到本地和区块链有共同 hash 的祖先区块,因为重组很可能有多个区块都被重组了,此时需要把数据库中, 发生重组的区块的交易都清理掉, 然后从共同祖先区块的下一个区块,重新扫描。

方法 2 现在是业界常规做法, 也是 cex-wallet/scan 代码实现的逻辑。

完整的扫块逻辑

scan 程序启动时,读取上一次扫描区块的高度(如果数据没有数据,则配置一个起始的扫块高度)与链上最新的高度:

  1. 对于已经finalized的区块(获取经过了我们的要求的确认数),可以批量扫描解析入库
  2. 对于最近的区块,需要先做分叉检测,若分叉则回滚到共同祖先处重新扫描。

直到我们扫描跟踪到当前的最新高度,并保持跟踪...

image-20250919114259472

数据库设计

首先我们需要区块表和交易表,并且每个链应该创建各自的区块表和交易表,我们当前的实现时,暂时只支持 ETH, 因此只有一个 blocks 和 transactions,表的字段如下:

如果有后续有多个链,应该是使用更具体的名称,例如:eth_blocks、 eth_transactions、solana_blocks、solana_transactions 。

区块表 (blocks)

字段 类型 说明
hash TEXT 主键,区块哈希
parent_hash TEXT 父区块哈希
number TEXT 区块号,大整数存储
timestamp INTEGER 区块时间戳
status TEXT 区块确认状态:confirmed(safe、finalized)被重组:orphaned
created_at DATETIME 创建时间
updated_at DATETIME 更新时间

区块表 (blocks) 主要作用是记录扫描的进度。

交易记录表 (transactions)

字段 类型 说明
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 根据网络共识状态逐步从 confirmedsafe ->finalized。 对于 POW 网络,或者一些本地测试网络,我们可以为网络配置一个确认数 confirmationBlocks ,然后对比区块高度差与确认数,区块高度差 >= confirmationBlocks, 可以认为是 finalized

Token 表 (tokens)

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)

上面几个表比较简单,基本上是链上的字段是对应的。

但是,我们该如何汇总余额的呢? 用户在交易所中的资金余额,可能来自多个链的多笔充值。

用户余额处理

方法 1: 添加余额表 (balances)

字段 类型 说明
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) 不是很安全。

备注:数据库幂等性指的是一个操作(如增删改)执行一次和执行多次所产生的最终影响是一样的,即多次执行不会改变系统的状态或业务结果。在分布式系统中,为防止因网络异常导致重复执行操作而引起数据不一致或业务混乱(如支付重复、订单重复下单),确保操作的幂等性尤为重要。

方法 2: 添加资金流水表 (credits)

更推荐的方法是添加资金流水表 , 当前每一条记录对应着一条充值记录, 后续交易所内部用户转账或者现货交易,都可以对应相应的记录,用户的余额 = 流水的聚合, 表结构如下:

字段 类型 说明
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) , 这样就不用担心交易的重放问题,并且,每条记录都可以朔源。

然后在 creditstokens 上,建立一个用户余额聚合视图表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 充值、到 应对区块链分叉,再到 数据库的表结构与用户余额的聚合视图,完整地走了一遍充值业务的核心流程,通过本文的设计,交易所既能保证用户体验(实时入账),又确保资产安全和一致性。

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

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

0 条评论

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