交易所钱包系统开发 #2 - 签名机设计与账户生成实现

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

在交易所钱包系统中,我们通过内网隔离 wallet 主模块与 signer 签名机,提升了整体安全性。签名机基于 BIP39 / BIP32 / BIP44 标准,以单一助记词派生无限地址,降低了备份和迁移成本。 结合交互式密码隐藏输入机制,加强安全性。

上一篇中,我们对交易所钱包系统做了初步的架构设计。整体系统采用微服务架构,将整个应用拆分为多个独立模块,主要包括:

  • wallet:业务主模块,提供钱包管理 API
  • signer:签名机,负责地址生成、密钥管理及交易签名
  • scan:充值扫描服务,持续监听链上交易并匹配用户充值
  • risk_control:风控模块,检查交易是否存在风险
  • fund_rebalance:资金归集与多签钱包调度模块

它们分别对应代码库中的不同文件夹。我们用模块名称替换了上一篇的架构图,每个独立运行的模块以虚线框标识:

image-20250911164446954

本篇将更深入介绍交易所钱包的账户管理,重点围绕 wallet 主模块和 signer 签名机模块。

账户管理设计

系统设计中提到,为了安全,必须通过签名机(signer 模块)为用户生成账号。用户的私钥始终保存在签名机内部。

私钥与地址 背景知识

地址是由私钥推导公钥,再经过特定算法计算得到的。不同区块链使用的椭圆曲线算法不同,例如:

  • 比特币和以太坊采用 secp256k1
  • Solana 采用 Ed25519

由于推导过程是确定性的,本质上一个地址就是由私钥决定的。一个安全的私钥应当满足 随机、不可预测、不可重复

如果每次都生成完全独立的随机私钥,备份、迁移和容灾的成本会非常高。

因此,几乎所有钱包系统都会采用 分层确定性(Hierarchical Deterministic, HD)钱包方案,它涉及几个 BIP(Bitcoin Improvement Proposal):

  • BIP39:使用助记词生成一个种子(seed)
  • BIP32:基于种子派生主私钥、公钥,并可沿路径无限派生子密钥
  • BIP44:定义了 5 层树状层级结构,标准化不同币种的钱包路径,例如以太坊钱包的标准派生路径是 m/44'/60'/0'/0/n

推导

通过一个助记词和密码,可以推导出种子,并基于不同路径生成无限多个地址。只需安全离线保存助记词和密码,即可保障安全性和可恢复性。

确定了地址生成方案后,就需要设计数据库来保存账号信息。

数据库设计

wallet 主模块中,我们处理用户注册,并为用户分配由签名机生成的账号。walletsigner 的关系如下图所示:

image-20250911180654022

图上展示了多个签名机,尽管用户的资金会及时归集到热钱包或多签冷钱包,但在用户规模较大的情况下,往往依旧需要部署多台签名机,由不同人员负责,以分散风险,避免单点被攻破导致资金全部泄露。

为了便于管理,签名机会维护自己的数据库,记录已生成的账号及对应的推导路径,因此 主模块和签名机有各自的数据库。

主模块数据库

在主模块中,当前至少需要两个表:用户表(users)钱包表(wallets)

用户表(users) 用于记录用户用户名、邮箱、手机号、密码哈希、KYC 状态等基本信息在这个系统中,我们的侧重点是资金的管理,因此用户表会简化处理,users 主要字段如下:

字段 类型 说明
id INTEGER 主键,自增
username TEXT 用户名,唯一
email TEXT 邮箱地址,唯一
phone TEXT 手机号码
password_hash TEXT 密码哈希
status INTEGER 用户状态:0-正常,1-禁用,2-待审核
kyc_status INTEGER KYC 状态:0-未认证,1-待审核,2-已认证,3-认证失败
created_at DATETIME 创建时间
updated_at DATETIME 更新时间
last_login_at DATETIME 最后登录时间

钱包表(wallets) 用于记录用户的钱包地址信息,包括由哪个签名机生成、派生路径、链类型(BTC、EVM、Solana 等), wallets 主要字段如下:

字段 类型 说明
id INTEGER 主键,自增
user_id INTEGER 用户 ID,外键关联 users 表
address TEXT 用户钱包地址,唯一
device TEXT 来源签名机设备
path TEXT 私钥派生路径
chain_type TEXT 地址类型:evm、btc、solana
created_at DATETIME 创建时间
updated_at DATETIME 更新时间

👉 注意:数据库中不会保存私钥、助记词或种子,只保存派生路径,从而降低泄露风险。

签名机数据库

签名机内部需要保存已生成的账号信息,表名为 generatedAddresses,结构如下:

字段 类型 说明
id INTEGER 主键,自增
address TEXT 用户钱包地址,唯一
path TEXT 私钥派生路径
index_value INTEGER 派生路径中的索引值
chain_type TEXT 地址类型:evm、btc、solana
created_at DATETIME 创建时间

关键实现

题外话:这个项目也是 AI Vibe Coding 的一次实践,我之前本没有很多的 TypeScript 编程经验,由于在 Web3 里,TypeScript 有最丰富的库,因此这次的工程实践,主要是我向 AI 描述想法,由 AI 负责生成、调试和完善,我自己只是做一些小修小改。代码中存在一些冗余代码还请理解。

我们的工程里 每一个模块都是一个独立的服务, Wallet 主模块提供前端需要的各种 api , Wallet 主模块与其他的服务之间通过 http 接口通信,在 signer 签名机上,需要配置防火墙,仅接受来自来自 Wallet 模块服务器的内网请求。

这里,Wallet 主模块提供获取用户钱包 API, 前端请求时,如果业务主模块的 wallets 表记录了用户的地址, 这直接从表中返回,如果没有, 则由主模块请求 signer 模块为用户生成地址,地址返回给主模块(主模块只能拿到公钥地址,并不知晓如何生成的),再记录到主模块数据库中。

如果交易所突然涌入大量的新用户,即时生成一大批地址,可能会遇到一些性能问题,很多交易所会采用混合模式(预先和实时两个模式)生成地址,常规的链会预先生成一批未分配地址池,这样新用户注册时,可以快速匹配一个地址。当地址池快用完时,再请求签名机再批量生成新一批,避免实时生成的压力。而一些用户较少的新链、冷门链,仅在前端用户请求时,实时生成。

也有一些交易所为了节省管理成本,会让多个用户共享一个充值地址,通过链上转账额外的备注( Memo/Tag )来区分不用的用户转账。

在我们的实现中,使用的服务器为 Node.js + Express ,数据库使用 SQLite3 以便减少外部的环境依赖。

Wallet 主模块是常规的服务端业务,处理用户请求,读写数据库,不做详细介绍,具体代码可参考这里.

关键的签名机在签名机中,签名机使用 BIP39 密码保护 和 BIP32 BIP44 分层确定性派生来生成不同的账号。

签名机密码保护

账号地址生成方案 我们介绍了,可以 BIP39 助记词推导出的种子(seed),派生出无数的地址,以便达到我们只需要配置一个助记词即可。

为了保护推导出的种子的安全性, 我们加入了一个额外的密码保护设计,这样即便黑客获取到了助记词也无法拿到种子。

在启动签名机程序时,会加载环境变量中配置的助记词,并通过交互式方式(参考代码),让运营人员隐藏式输入密码(输入时,控制台会显示为* 号),这样即便他们入侵到签名机服务器中,也无法通过控制台终端 shell 的历史记录获取到密码。

其实助记词也可以通过终端输入来提升一些安全性,但是这样我就得在每次启动签名机时,输入很多的单词,我嫌太麻烦了,很多时候方便与安全是不可兼得的。

不过,如果入侵使用内存分析工具,依旧有可能获取到密码, 如果需要更高的安全,可以使用 CloudHSM, 但是成本较高,在工程实践中,没有绝对好的方案,都是在权衡。

另外,需要注意一个问题,即便输入了错误的密码,也同样可以派生出地址,因此,我们会使用第一个地址作为密码的校验,每次启动时,总是匹配数据库中第一条由 m/44'/60'/0'/0/0 推导的地址,如果地址匹配,则说明密码输入正确,如果密码输入错误则退出运行。

签名机创建账号

Signer 模块会提供一个 POST API 接口供主模块调用:

POST /api/signer/create
Content-Type: application/json

{
  "chainType": "evm"
}

Signer 模块收到请求后,调用 createNewWallet 创建账号, 关键代码如下:

// 导入核心技术库
import { mnemonicToSeedSync } from '@scure/bip39';
import { HDKey } from '@scure/bip32';  
import { privateKeyToAccount } from 'viem/accounts';

async createNewWallet(chainType: 'evm' | 'btc' | 'solana'): Promise<CreateWalletResponse> {
  try {
    // 从环境变量获取助记词
    const mnemonic = this.getMnemonicFromEnv();
    // 根据链类型生成新的派生路径
    const derivationPath = await this.generateNextDerivationPath(chainType);

    switch (chainType) {
      case 'evm':
        const pathParts = derivationPath.split('/');
        const index = pathParts[pathParts.length - 1];
        const accountData = this.createEvmAccount(mnemonic, index);
        // ... 忽略更多代码 

        await this.saveAddress(accountData.address, derivationPath, index, chainType);
    }

}

private createEvmAccount(mnemonic: string, index: string): any {
    const fullPath = `m/44'/60'/0'/0/${index}`;

    // 使用输入的密码生成种子
    const seed = mnemonicToSeedSync(mnemonic, this.password);

    // 从种子创建 HD 密钥
    const hdKey = HDKey.fromMasterSeed(seed);

    // 派生到指定路径
    const derivedKey = hdKey.derive(fullPath);

    if (!derivedKey.privateKey) {
      throw new Error('无法派生私钥');
    }

    // 从私钥创建账户(转换为十六进制字符串)
    const privateKeyHex = `0x${Buffer.from(derivedKey.privateKey).toString('hex')}`;
    const account = privateKeyToAccount(privateKeyHex as `0x${string}`);

    // 返回账户信息
    return {
      address: account.address,
      // privateKey: derivedKey.privateKey,
      path: fullPath
    };
  }

完整的代码,参考这里

当前,我们仅支持了 EVM 地址,后续会继续添加其他链的支持,生成一个新的地址很简单, 只需要推导路径的最后一位,在最大的索引号 index 基础上加 1。

在创建地址后,签名机程序会将地址、路径、索引号、chainType、保存到数据库中:

  private async saveAddress(address: string, path: string, index: number, chainType: string): Promise<void> {
    try {
      // 保存地址到数据库
      await this.db.addGeneratedAddress(address, path, index, chainType);

      console.log(`地址已保存: ${address}, 索引: ${index}, 链类型: ${chainType}`);
    } catch (error) {
      console.error('保存地址失败:', error);
      throw error;
    }
  }

  // 获取下一个派生路径
  private async generateNextDerivationPath(chainType: 'evm' | 'btc' | 'solana'): Promise<string> {
    const basePath = this.defaultDerivationPaths[chainType];

    if (chainType === 'evm') {
      const pathParts = basePath.split('/');

      // 获取当前链类型的最大索引
      const maxIndex = await this.db.getMaxIndexForChain(chainType);
      const nextIndex = maxIndex + 1;

      pathParts[pathParts.length - 1] = nextIndex.toString();
      return pathParts.join('/');
    }

    // 对于其他链类型,暂时返回基础路径
    return basePath;
  }

小结

在交易所钱包系统中,我们通过内网隔离 wallet 主模块与 signer 签名机,提升了整体安全性。 签名机基于 BIP39 / BIP32 / BIP44 标准,以单一助记词派生无限地址,降低了备份和迁移成本。 结合交互式密码隐藏输入机制,加强了对助记词种子的保护。

参考文章

  1. 分层确定性推导 BIP39 BIP32 BIP44
  2. viem.sh 文档
点赞 1
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

2 条评论

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