PAYATHON 2026

托管加密钱包如何实现独立充值地址、资金归集、共享资金池和收益部署?

支付老李

结论:用内部账本管理用户权益,用交易状态机管理链上执行

这套架构可以采用,但需要分别建模用户余额、链上资产位置和资金可用性。充值确认后增加用户权益;归集改变资产所在地址;投入收益协议改变资产形态。归集和投入收益协议都不能再次增加用户充值余额。

系统可以拆成链上索引器、内部账本、交易执行器、资金调度器和对账任务。这些组件通过持久化事件和任务协作,可以短时间处于不同的处理阶段,再通过重试和对账使状态收敛。

以下涉及 nonce、gas 和 ERC-20 的说明以 EVM 链为前提。支持其他链时,需要按对应链的交易与最终性规则调整。

1. 先明确内部账本记录什么

建议使用不可直接覆盖的复式记账分录,余额表汇总分录得出。至少区分:

  • 资产账户:充值地址资产、treasury 资产、收益协议中的头寸。
  • 用户负债账户:平台应付给各用户的余额。
  • 业务状态:待确认充值、提现冻结、提现处理中等。
  • 平台账户:自有资金、手续费和运营支出。

例如,用户充值 100 USDC,达到入账条件后,可以记录“借记充值地址资产 100 USDC,贷记用户负债 100 USDC”。归集时,将资产从充值地址账户转到 treasury 账户,用户负债保持不变。

投入收益协议后,应记录相应的协议头寸及其计量方式。收到的份额凭证、凭证可兑换的底层资产和用户负债是不同的概念,不能把凭证余额直接当成可立即支付的 USDC。

每种资产都应使用明确的链和资产标识。不同链上的同名 USDC,以及不同合约的同名代币,不能仅凭 symbol 合并。

2. 归集需要持久化状态机

为每次归集创建稳定的业务操作 ID,保存源地址、目标地址、链、资产、金额和执行状态。例如:

PLANNED → SIGNED → SUBMITTED → CONFIRMED → FINALIZED

失败、替换和人工处理应有独立状态或关联记录。业务操作与交易尝试是一对多关系:同一次归集可能涉及多次广播或手续费替换,但这些尝试不能变成多笔独立归集。

并发与 nonce

对同一链、同一发送地址,使用统一的交易调度器分配 nonce。通过数据库锁、唯一约束和持久化预留记录控制并发,不要只依赖 Node.js 进程内的互斥锁。

pending nonce 可以作为参考,还需要结合本地已预留、已签名和已提交的交易判断。多个 worker 各自读取 RPC 后直接发送,容易发生 nonce 冲突。

还需要防止两个任务归集同一笔可用资金。计算归集金额时,应扣除其他任务已预留的金额,并在广播前检查余额和 gas 条件。

超时与重试

广播超时只说明客户端没有拿到明确结果,不能据此认定交易未提交。

较稳妥的流程是:

  1. 持久化归集意图并预留 nonce。
  2. 签名后,安全保存签名交易及其本地计算的交易哈希。
  3. 广播交易,持续查询执行结果。
  4. 遇到超时,先检查原交易,必要时重新广播同一份签名交易。
  5. 需要提高手续费时,将同一 nonce 的替换交易关联到原归集操作。

不要因为一次超时就分配新 nonce 再发送一笔相同转账,否则原交易和新交易可能都成功。

数据库事务无法与区块链提交组成一个原子事务。可靠性需要依靠持久化状态、幂等重试和结果核对,仅有一个“发送成功”标志并不够。

ERC-20 与 gas

普通 EOA 发送 ERC-20 归集交易,通常需要持有该链的原生 gas 资产。补充原生资产应设置金额上限、频率限制并保留独立记录,否则大量小额充值可能触发不合理的补 gas 支出。

交易回执成功后,还要核对目标代币合约产生的实际转账事件、收款地址和金额。交易执行成功并不等于业务转账已经完成。代币是否存在转账扣费等特殊行为,应在资产接入时明确支持范围。

3. 链上与内部账本通过确认策略和对账保持一致

充值识别与入账

索引器应保存处理游标,并支持重启后重扫一段历史区块。ERC-20 充值需要识别已允许的代币合约发出的转账事件,不能把所有发往充值地址的交易都当作充值。

充值事件可以使用以下组合标识去重:

chainId + tokenContract + transactionHash + logIndex

同时保存 blockNumber、blockHash、收款地址、金额和处理状态。数据库必须建立唯一约束;仅在应用层先查询再插入,无法防止并发重复入账。

“检测到充值”和“允许使用余额”应分开处理。具体确认数或 finalized 条件取决于目标链、金额和风险要求,没有适用于所有链的固定值。

链重组处理

索引器应检查区块是否仍属于规范链。如果已记录的事件因链重组失效,需要更新事件状态;如果已经入账,则应通过冲正分录处理,关联原分录并保留审计轨迹。

即使采用较保守的入账条件,也应保留异常纠正路径。等待最终性可以降低重组风险,但不能代替错误修复机制。

对账分成两层

第一层核对链上持仓与资产账:逐链、逐地址、逐资产检查余额,并单独解释未完成归集、提现、费用和协议头寸。

第二层核对资产与负债:按明确的计量口径汇总托管资产、策略资产、用户负债和平台权益。出现差额时,应能定位到交易、分录或估值变化。

比较时必须使用一致的时间边界,例如将资产账和链上查询都对齐到某个区块。用实时链上余额对比几分钟前的账本,容易产生假差异。

对账差异应进入异常队列,按严重程度暂停相关充值入账、归集或提现流程。不要自动将用户余额改成某个链上地址的余额。

4. 流动性应同时在账本、地址和调度规则中隔离

建议至少划分三类资金:

  • 提现储备:可直接用于支付,且尚未被其他提现占用的资产。
  • 策略资金:已经授权投入外部协议、需要执行赎回才能回到 treasury 的资产。
  • 运营资金:支付 gas 等成本的平台资金。

不同地址或账户可以增强隔离,还需要配合账本和权限规则。提现服务只消耗可支付储备;策略服务只能动用已批准的策略额度。

用户可提现额度与 treasury 可支付能力需要分别检查:前者取决于用户已入账余额及冻结金额,后者取决于系统当前实际可动用的资产。受理提现时,应在数据库事务中冻结用户额度并建立资金预留,避免多个请求同时占用同一份余额或储备。

收益协议中的头寸不能无条件计为即时流动性。赎回可能受到协议可用资金、交易执行、暂停状态或其他条件影响。如果要求某部分资金始终可以立即支付,就需要让相应资产保持可直接支配,并将这些金额排除在策略预算之外。

策略调度器还应具备最低储备限制、单协议额度、停止新增部署和紧急赎回开关。已经发起但尚未完成的赎回,不应提前计入可支付储备。

5. Node.js 示例:充值事件与分录原子落库

以下伪代码展示事务边界。db、ledger 和 outbox 都是需要自行实现的接口,不是 ethers.js API。前提是事件已经完成合约、收款地址、规范链和入账条件校验。

async function creditEligibleDeposit(event) {
  const eventKey = [
    event.chainId,
    event.tokenContract.toLowerCase(),
    event.transactionHash,
    event.logIndex
  ].join(':');

  // amountRaw 是代币最小单位的整数字符串。
  const amount = BigInt(event.amountRaw);
  if (amount <= 0n) return;

  await db.transaction(async (tx) => {
    // 数据库对 eventKey 建立唯一约束。
    // 首次插入返回 true,重复事件返回 false。
    const inserted = await tx.insertDepositIfAbsent({
      eventKey,
      blockHash: event.blockHash,
      userId: event.userId,
      assetId: event.assetId,
      amountRaw: amount.toString()
    });

    if (!inserted) return;

    await ledger.post(tx, {
      reference: eventKey,
      debitAccount: event.depositAssetAccount,
      creditAccount: event.userLiabilityAccount,
      assetId: event.assetId,
      amountRaw: amount.toString()
    });

    await outbox.enqueue(tx, {
      type: 'DEPOSIT_CREDITED',
      eventKey
    });
  });
}

充值记录、分录和 outbox 必须处于同一个数据库事务中,分录引用也应具备唯一约束。消息可能重复投递,因此 outbox 消费者仍需要幂等处理。

示例只覆盖首次入账。链重组后的冲正,以及事件重新进入规范链后的处理,需要额外的状态迁移,不能始终用“已存在就返回”代替。

Node.js 中的链上整数应使用 bigint 或其他精确整数表示,避免用 Number 保存代币原始数量。数据库和消息中的表示形式也要统一,展示金额时再按经过配置校验的 decimals 格式化。

6. 共享 treasury 最容易遗漏的边界

共享资金池降低了地址层面的资金分散程度,同时使系统依赖账本正确性、签名权限和流动性调度。设计时应检查:

  • 归集与用户权益:归集成功不会产生新的用户收入,归集失败也不应自动撤销已经确认的充值。
  • 策略收益与亏损:收益分配、费用和亏损承担必须有明确的记账规则,不能直接把链上头寸变化平均写入用户余额。
  • 签名与业务服务:ethers.js 可以参与交易构建和链上交互,但私钥保管、签名授权和额度控制需要单独设计,可采用隔离签名服务、HSM 或托管服务商。
  • 策略执行权限:约束目标合约、资产、额度和授权范围,避免策略 worker 获得任意转账或无限扩大的资金支配能力。
  • 外部通知与事实记录:webhook 可用于触发处理,仍需要处理重复、漏报和乱序,并具备主动补扫能力。

开发阶段应重点验证广播超时、进程在签名后崩溃、消息重复、nonce 冲突、链重组和赎回失败等场景。相比单次转账成功,这些故障能更好地检验系统是否可以恢复到可解释的状态,以及架构是否完整。

备注:内容仅供参考。