在交易所钱包系统中,我们通过内网隔离 wallet
主模块与 signer
签名机,提升了整体安全性。签名机基于 BIP39 / BIP32 / BIP44 标准,以单一助记词派生无限地址,降低了备份和迁移成本。
结合交互式密码隐藏输入机制,加强安全性。
在上一篇中,我们对交易所钱包系统做了初步的架构设计。整体系统采用微服务架构,将整个应用拆分为多个独立模块,主要包括:
它们分别对应代码库中的不同文件夹。我们用模块名称替换了上一篇的架构图,每个独立运行的模块以虚线框标识:
本篇将更深入介绍交易所钱包的账户管理,重点围绕 wallet
主模块和 signer
签名机模块。
在系统设计中提到,为了安全,必须通过签名机(signer
模块)为用户生成账号。用户的私钥始终保存在签名机内部。
地址是由私钥推导公钥,再经过特定算法计算得到的。不同区块链使用的椭圆曲线算法不同,例如:
由于推导过程是确定性的,本质上一个地址就是由私钥决定的。一个安全的私钥应当满足 随机、不可预测、不可重复。
如果每次都生成完全独立的随机私钥,备份、迁移和容灾的成本会非常高。
因此,几乎所有钱包系统都会采用 分层确定性(Hierarchical Deterministic, HD)钱包方案,它涉及几个 BIP(Bitcoin Improvement Proposal):
m/44'/60'/0'/0/n
通过一个助记词和密码,可以推导出种子,并基于不同路径生成无限多个地址。只需安全离线保存助记词和密码,即可保障安全性和可恢复性。
确定了地址生成方案后,就需要设计数据库来保存账号信息。
在 wallet
主模块中,我们处理用户注册,并为用户分配由签名机生成的账号。wallet
与 signer
的关系如下图所示:
图上展示了多个签名机,尽管用户的资金会及时归集到热钱包或多签冷钱包,但在用户规模较大的情况下,往往依旧需要部署多台签名机,由不同人员负责,以分散风险,避免单点被攻破导致资金全部泄露。
为了便于管理,签名机会维护自己的数据库,记录已生成的账号及对应的推导路径,因此 主模块和签名机有各自的数据库。
在主模块中,当前至少需要两个表:用户表(users) 和 钱包表(wallets)。
用户表(users) 用于记录用户用户名、邮箱、手机号、密码哈希、KYC 状态等基本信息在这个系统中,我们的侧重点是资金的管理,因此用户表会简化处理,users
主要字段如下:
字段 | 类型 | 说明 |
---|---|---|
id | INTEGER | 主键,自增 |
username | TEXT | 用户名,唯一 |
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 标准,以单一助记词派生无限地址,降低了备份和迁移成本。
结合交互式密码隐藏输入机制,加强了对助记词种子的保护。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!