feat(blockchain): 将提现转账从私钥签名改为 MPC 签名
背景: - 原实现使用 HOT_WALLET_PRIVATE_KEY 进行热钱包签名 - 私钥直接存储存在安全风险 - 系统已有 MPC 基础设施,应该复用 改动内容: 1. 新增 MPC 签名客户端 - infrastructure/mpc/mpc-signing.client.ts: 调用 mpc-service 的签名 API - 支持创建签名会话、轮询等待、获取签名结果 2. 重构 ERC20 转账服务 - domain/services/erc20-transfer.service.ts: 从私钥签名改为 MPC 签名 - 移除 Wallet 依赖,改用 Transaction 手动构建交易 - 使用 MPC 签名后广播已签名交易 3. 新增初始化服务 - mpc-transfer-initializer.service.ts: 启动时注入 MPC 客户端 - 解决 Domain 层和 Infrastructure 层的循环依赖 4. 新增热钱包初始化脚本 - scripts/init-hot-wallet.sh: 便捷创建系统热钱包的 MPC 密钥 - 支持配置门限值、用户名等参数 5. 更新配置 - 移除 HOT_WALLET_PRIVATE_KEY 依赖 - 新增 MPC_SERVICE_URL, HOT_WALLET_USERNAME, HOT_WALLET_ADDRESS - 更新 docker-compose.yml 和 .env.example 部署前需要: 1. 运行 init-hot-wallet.sh 初始化热钱包 2. 配置 HOT_WALLET_USERNAME 和 HOT_WALLET_ADDRESS 3. 向热钱包充值 USDT 和原生币(gas) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
28c44d7219
commit
9cac91b5f0
|
|
@ -0,0 +1,187 @@
|
|||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# 热钱包 MPC 初始化脚本
|
||||
# =============================================================================
|
||||
#
|
||||
# 用途: 创建系统热钱包的 MPC 密钥,用于提现转账
|
||||
#
|
||||
# 前提条件:
|
||||
# 1. mpc-service 正在运行
|
||||
# 2. mpc-system 正在运行且所有 party 已启动
|
||||
#
|
||||
# 使用方法:
|
||||
# ./init-hot-wallet.sh [options]
|
||||
#
|
||||
# 选项:
|
||||
# -u, --username 热钱包用户名 (默认: system-hot-wallet)
|
||||
# -n, --threshold-n 总 party 数量 (默认: 3)
|
||||
# -t, --threshold-t 签名门限值 (默认: 2)
|
||||
# -h, --host mpc-service 地址 (默认: http://localhost:3013)
|
||||
# --help 显示帮助
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# 默认配置
|
||||
USERNAME="system-hot-wallet"
|
||||
THRESHOLD_N=3
|
||||
THRESHOLD_T=2
|
||||
MPC_HOST="http://localhost:3013"
|
||||
|
||||
# 解析参数
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-u|--username)
|
||||
USERNAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
-n|--threshold-n)
|
||||
THRESHOLD_N="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--threshold-t)
|
||||
THRESHOLD_T="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--host)
|
||||
MPC_HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help)
|
||||
head -30 "$0" | tail -25
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "未知参数: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "=============================================="
|
||||
echo "热钱包 MPC 初始化"
|
||||
echo "=============================================="
|
||||
echo "用户名: $USERNAME"
|
||||
echo "门限: $THRESHOLD_T-of-$THRESHOLD_N"
|
||||
echo "MPC 服务: $MPC_HOST"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
|
||||
# Step 1: 创建 Keygen 会话
|
||||
echo "[1/4] 创建 Keygen 会话..."
|
||||
KEYGEN_RESPONSE=$(curl -s -X POST "$MPC_HOST/mpc/keygen" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"username\": \"$USERNAME\",
|
||||
\"thresholdN\": $THRESHOLD_N,
|
||||
\"thresholdT\": $THRESHOLD_T,
|
||||
\"requireDelegate\": true
|
||||
}")
|
||||
|
||||
SESSION_ID=$(echo "$KEYGEN_RESPONSE" | jq -r '.sessionId')
|
||||
STATUS=$(echo "$KEYGEN_RESPONSE" | jq -r '.status')
|
||||
|
||||
if [ "$SESSION_ID" == "null" ] || [ -z "$SESSION_ID" ]; then
|
||||
echo "错误: 创建 Keygen 会话失败"
|
||||
echo "响应: $KEYGEN_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "会话 ID: $SESSION_ID"
|
||||
echo "状态: $STATUS"
|
||||
echo ""
|
||||
|
||||
# Step 2: 轮询等待 Keygen 完成
|
||||
echo "[2/4] 等待 Keygen 完成..."
|
||||
MAX_ATTEMPTS=180 # 最多等待 6 分钟
|
||||
ATTEMPT=0
|
||||
PUBLIC_KEY=""
|
||||
|
||||
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
|
||||
STATUS_RESPONSE=$(curl -s -X GET "$MPC_HOST/mpc/keygen/$SESSION_ID/status")
|
||||
STATUS=$(echo "$STATUS_RESPONSE" | jq -r '.status')
|
||||
PUBLIC_KEY=$(echo "$STATUS_RESPONSE" | jq -r '.publicKey // empty')
|
||||
|
||||
echo " 轮询 #$((ATTEMPT + 1)): 状态=$STATUS"
|
||||
|
||||
if [ "$STATUS" == "completed" ]; then
|
||||
echo ""
|
||||
echo "Keygen 完成!"
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$STATUS" == "failed" ] || [ "$STATUS" == "expired" ]; then
|
||||
echo ""
|
||||
echo "错误: Keygen 失败,状态=$STATUS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$STATUS" != "completed" ]; then
|
||||
echo "错误: Keygen 超时"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 3: 显示公钥
|
||||
echo ""
|
||||
echo "[3/4] 获取公钥..."
|
||||
if [ -z "$PUBLIC_KEY" ]; then
|
||||
# 再次获取状态以确保拿到公钥
|
||||
STATUS_RESPONSE=$(curl -s -X GET "$MPC_HOST/mpc/keygen/$SESSION_ID/status")
|
||||
PUBLIC_KEY=$(echo "$STATUS_RESPONSE" | jq -r '.publicKey')
|
||||
fi
|
||||
|
||||
if [ -z "$PUBLIC_KEY" ] || [ "$PUBLIC_KEY" == "null" ]; then
|
||||
echo "错误: 无法获取公钥"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "公钥: $PUBLIC_KEY"
|
||||
|
||||
# Step 4: 从公钥派生 EVM 地址
|
||||
echo ""
|
||||
echo "[4/4] 派生 EVM 地址..."
|
||||
|
||||
# 使用 node 计算地址(如果安装了 ethers)
|
||||
if command -v node &> /dev/null; then
|
||||
ADDRESS=$(node -e "
|
||||
const { computeAddress } = require('ethers');
|
||||
try {
|
||||
const address = computeAddress('0x$PUBLIC_KEY');
|
||||
console.log(address);
|
||||
} catch (e) {
|
||||
console.error('Error:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$ADDRESS" ] && [ "$ADDRESS" != "Error:"* ]; then
|
||||
echo "EVM 地址: $ADDRESS"
|
||||
else
|
||||
echo "提示: 无法自动计算地址,请手动计算"
|
||||
echo "公钥 (hex): $PUBLIC_KEY"
|
||||
fi
|
||||
else
|
||||
echo "提示: 未安装 Node.js,请手动从公钥计算 EVM 地址"
|
||||
echo "公钥 (hex): $PUBLIC_KEY"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=============================================="
|
||||
echo "初始化完成!"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
echo "请将以下配置添加到 blockchain-service 的环境变量:"
|
||||
echo ""
|
||||
echo " HOT_WALLET_USERNAME=$USERNAME"
|
||||
if [ -n "$ADDRESS" ] && [ "$ADDRESS" != "Error:"* ]; then
|
||||
echo " HOT_WALLET_ADDRESS=$ADDRESS"
|
||||
else
|
||||
echo " HOT_WALLET_ADDRESS=<从公钥计算的地址>"
|
||||
fi
|
||||
echo ""
|
||||
echo "=============================================="
|
||||
|
|
@ -117,3 +117,14 @@ MINIO_BUCKET_AVATARS=avatars
|
|||
|
||||
# Public URL for accessing files
|
||||
MINIO_PUBLIC_URL=https://minio.szaiai.com
|
||||
|
||||
# =============================================================================
|
||||
# MPC Hot Wallet (用于提现转账)
|
||||
# =============================================================================
|
||||
# 热钱包用户名(MPC 系统中的标识,需要预先通过 keygen 创建)
|
||||
# 使用 scripts/init-hot-wallet.sh 脚本初始化
|
||||
HOT_WALLET_USERNAME=system-hot-wallet
|
||||
|
||||
# 热钱包地址(从 MPC 公钥派生的 EVM 地址)
|
||||
# 在 MPC keygen 完成后,从公钥计算得出
|
||||
HOT_WALLET_ADDRESS=
|
||||
|
|
|
|||
|
|
@ -87,6 +87,22 @@ BLOCK_CONFIRMATIONS_REQUIRED=12
|
|||
# Maximum blocks to process in one batch
|
||||
BLOCK_SCAN_BATCH_SIZE=100
|
||||
|
||||
# =============================================================================
|
||||
# MPC Hot Wallet (用于提现转账)
|
||||
# =============================================================================
|
||||
# MPC 服务地址
|
||||
# Docker Compose: http://mpc-service:3013 / Direct: http://192.168.1.111:3013
|
||||
MPC_SERVICE_URL=http://192.168.1.111:3013
|
||||
|
||||
# 热钱包用户名(MPC 系统中的标识,需要预先通过 keygen 创建)
|
||||
# 示例: system-hot-wallet
|
||||
HOT_WALLET_USERNAME=system-hot-wallet
|
||||
|
||||
# 热钱包地址(从 MPC 公钥派生的 EVM 地址)
|
||||
# 在 MPC keygen 完成后,从公钥计算得出
|
||||
# 示例: 0x1234567890abcdef1234567890abcdef12345678
|
||||
HOT_WALLET_ADDRESS=
|
||||
|
||||
# =============================================================================
|
||||
# Logging
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ services:
|
|||
# Blockchain RPC
|
||||
KAVA_RPC_URL: https://evm.kava.io
|
||||
BSC_RPC_URL: https://bsc-dataseed.binance.org
|
||||
# MPC Hot Wallet (用于提现转账)
|
||||
MPC_SERVICE_URL: http://rwa-mpc-service:3013
|
||||
HOT_WALLET_USERNAME: ${HOT_WALLET_USERNAME:-}
|
||||
HOT_WALLET_ADDRESS: ${HOT_WALLET_ADDRESS:-}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3012/api/v1/health"]
|
||||
interval: 30s
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
MnemonicVerificationService,
|
||||
OutboxPublisherService,
|
||||
DepositRepairService,
|
||||
MpcTransferInitializerService,
|
||||
} from './services';
|
||||
import { MpcKeygenCompletedHandler, WithdrawalRequestedHandler } from './event-handlers';
|
||||
import { DepositAckConsumerService } from '@/infrastructure/kafka/deposit-ack-consumer.service';
|
||||
|
|
@ -22,6 +23,7 @@ import { DepositAckConsumerService } from '@/infrastructure/kafka/deposit-ack-co
|
|||
MnemonicVerificationService,
|
||||
OutboxPublisherService,
|
||||
DepositRepairService,
|
||||
MpcTransferInitializerService,
|
||||
|
||||
// 事件消费者(依赖 OutboxPublisherService,需要在这里注册)
|
||||
DepositAckConsumerService,
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@ export * from './balance-query.service';
|
|||
export * from './mnemonic-verification.service';
|
||||
export * from './outbox-publisher.service';
|
||||
export * from './deposit-repair.service';
|
||||
export * from './mpc-transfer-initializer.service';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* MPC Transfer Initializer Service
|
||||
*
|
||||
* 在应用启动时将 MPC 签名客户端注入到 ERC20 转账服务中
|
||||
* 用于解决循环依赖问题(Domain 层不能直接依赖 Infrastructure 层)
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Erc20TransferService } from '@/domain/services/erc20-transfer.service';
|
||||
import { MpcSigningClient } from '@/infrastructure/mpc';
|
||||
|
||||
@Injectable()
|
||||
export class MpcTransferInitializerService implements OnModuleInit {
|
||||
private readonly logger = new Logger(MpcTransferInitializerService.name);
|
||||
|
||||
constructor(
|
||||
private readonly erc20TransferService: Erc20TransferService,
|
||||
private readonly mpcSigningClient: MpcSigningClient,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.logger.log('[INIT] Injecting MPC Signing Client into ERC20 Transfer Service');
|
||||
this.erc20TransferService.setMpcSigningClient(this.mpcSigningClient);
|
||||
this.logger.log('[INIT] MPC Signing Client injection complete');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JsonRpcProvider, Wallet, Contract, parseUnits, formatUnits } from 'ethers';
|
||||
import {
|
||||
JsonRpcProvider,
|
||||
Contract,
|
||||
parseUnits,
|
||||
formatUnits,
|
||||
Transaction,
|
||||
Signature,
|
||||
} from 'ethers';
|
||||
import { ChainConfigService } from './chain-config.service';
|
||||
import { ChainType } from '@/domain/value-objects';
|
||||
import { ChainTypeEnum } from '@/domain/enums';
|
||||
|
|
@ -21,75 +28,97 @@ export interface TransferResult {
|
|||
blockNumber?: number;
|
||||
}
|
||||
|
||||
// MPC 签名客户端接口(避免循环依赖)
|
||||
export interface IMpcSigningClient {
|
||||
isConfigured(): boolean;
|
||||
getHotWalletAddress(): string;
|
||||
signMessage(messageHash: string): Promise<string>;
|
||||
}
|
||||
|
||||
export const MPC_SIGNING_CLIENT = Symbol('MPC_SIGNING_CLIENT');
|
||||
|
||||
/**
|
||||
* ERC20 转账服务
|
||||
*
|
||||
* 用于从系统热钱包发送 ERC20 代币到用户指定地址
|
||||
* 当前实现使用私钥签名,生产环境可替换为 MPC 签名
|
||||
* 使用 MPC 签名替代私钥签名,提高安全性
|
||||
*/
|
||||
@Injectable()
|
||||
export class Erc20TransferService {
|
||||
private readonly logger = new Logger(Erc20TransferService.name);
|
||||
private readonly hotWallets: Map<ChainTypeEnum, Wallet> = new Map();
|
||||
private readonly providers: Map<ChainTypeEnum, JsonRpcProvider> = new Map();
|
||||
private readonly hotWalletAddress: string;
|
||||
private mpcSigningClient: IMpcSigningClient | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly chainConfig: ChainConfigService,
|
||||
) {
|
||||
this.initializeHotWallets();
|
||||
this.hotWalletAddress = this.configService.get<string>('HOT_WALLET_ADDRESS', '');
|
||||
this.initializeProviders();
|
||||
}
|
||||
|
||||
private initializeHotWallets(): void {
|
||||
// 从环境变量获取热钱包私钥
|
||||
const hotWalletPrivateKey = this.configService.get<string>('HOT_WALLET_PRIVATE_KEY');
|
||||
/**
|
||||
* 设置 MPC 签名客户端(用于延迟注入,避免循环依赖)
|
||||
*/
|
||||
setMpcSigningClient(client: IMpcSigningClient): void {
|
||||
this.mpcSigningClient = client;
|
||||
this.logger.log(`[INIT] MPC Signing Client injected`);
|
||||
}
|
||||
|
||||
if (!hotWalletPrivateKey) {
|
||||
this.logger.warn('[INIT] HOT_WALLET_PRIVATE_KEY not configured, transfers will fail');
|
||||
return;
|
||||
}
|
||||
|
||||
// 为每条支持的链创建钱包
|
||||
private initializeProviders(): void {
|
||||
// 为每条支持的链创建 Provider
|
||||
for (const chainType of this.chainConfig.getSupportedChains()) {
|
||||
try {
|
||||
const config = this.chainConfig.getConfig(ChainType.fromEnum(chainType));
|
||||
const provider = new JsonRpcProvider(config.rpcUrl, config.chainId);
|
||||
const wallet = new Wallet(hotWalletPrivateKey, provider);
|
||||
this.hotWallets.set(chainType, wallet);
|
||||
this.logger.log(`[INIT] Hot wallet initialized for ${chainType}: ${wallet.address}`);
|
||||
this.providers.set(chainType, provider);
|
||||
this.logger.log(`[INIT] Provider initialized for ${chainType}: ${config.rpcUrl}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`[INIT] Failed to initialize wallet for ${chainType}`, error);
|
||||
this.logger.error(`[INIT] Failed to initialize provider for ${chainType}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查热钱包地址配置
|
||||
if (this.hotWalletAddress) {
|
||||
this.logger.log(`[INIT] Hot wallet address configured: ${this.hotWalletAddress}`);
|
||||
} else {
|
||||
this.logger.warn('[INIT] HOT_WALLET_ADDRESS not configured, transfers will fail');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热钱包地址
|
||||
*/
|
||||
getHotWalletAddress(chainType: ChainTypeEnum): string | null {
|
||||
const wallet = this.hotWallets.get(chainType);
|
||||
return wallet?.address ?? null;
|
||||
// MPC 钱包地址在所有 EVM 链上相同
|
||||
return this.hotWalletAddress || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热钱包 USDT 余额
|
||||
*/
|
||||
async getHotWalletBalance(chainType: ChainTypeEnum): Promise<string> {
|
||||
const wallet = this.hotWallets.get(chainType);
|
||||
if (!wallet) {
|
||||
throw new Error(`Hot wallet not configured for chain: ${chainType}`);
|
||||
const provider = this.providers.get(chainType);
|
||||
if (!provider) {
|
||||
throw new Error(`Provider not configured for chain: ${chainType}`);
|
||||
}
|
||||
|
||||
if (!this.hotWalletAddress) {
|
||||
throw new Error('Hot wallet address not configured');
|
||||
}
|
||||
|
||||
const config = this.chainConfig.getConfig(ChainType.fromEnum(chainType));
|
||||
const contract = new Contract(config.usdtContract, ERC20_TRANSFER_ABI, wallet.provider);
|
||||
const contract = new Contract(config.usdtContract, ERC20_TRANSFER_ABI, provider);
|
||||
|
||||
const balance = await contract.balanceOf(wallet.address);
|
||||
const balance = await contract.balanceOf(this.hotWalletAddress);
|
||||
const decimals = await contract.decimals();
|
||||
|
||||
return formatUnits(balance, decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 ERC20 转账
|
||||
* 执行 ERC20 转账(使用 MPC 签名)
|
||||
*
|
||||
* @param chainType 链类型 (KAVA, BSC)
|
||||
* @param toAddress 接收地址
|
||||
|
|
@ -101,21 +130,33 @@ export class Erc20TransferService {
|
|||
toAddress: string,
|
||||
amount: string,
|
||||
): Promise<TransferResult> {
|
||||
this.logger.log(`[TRANSFER] Starting USDT transfer`);
|
||||
this.logger.log(`[TRANSFER] Starting USDT transfer with MPC signing`);
|
||||
this.logger.log(`[TRANSFER] Chain: ${chainType}`);
|
||||
this.logger.log(`[TRANSFER] To: ${toAddress}`);
|
||||
this.logger.log(`[TRANSFER] Amount: ${amount} USDT`);
|
||||
|
||||
const wallet = this.hotWallets.get(chainType);
|
||||
if (!wallet) {
|
||||
const error = `Hot wallet not configured for chain: ${chainType}`;
|
||||
const provider = this.providers.get(chainType);
|
||||
if (!provider) {
|
||||
const error = `Provider not configured for chain: ${chainType}`;
|
||||
this.logger.error(`[TRANSFER] ${error}`);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
if (!this.mpcSigningClient || !this.mpcSigningClient.isConfigured()) {
|
||||
const error = 'MPC signing client not configured';
|
||||
this.logger.error(`[TRANSFER] ${error}`);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
if (!this.hotWalletAddress) {
|
||||
const error = 'Hot wallet address not configured';
|
||||
this.logger.error(`[TRANSFER] ${error}`);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
try {
|
||||
const config = this.chainConfig.getConfig(ChainType.fromEnum(chainType));
|
||||
const contract = new Contract(config.usdtContract, ERC20_TRANSFER_ABI, wallet);
|
||||
const contract = new Contract(config.usdtContract, ERC20_TRANSFER_ABI, provider);
|
||||
|
||||
// 获取代币精度
|
||||
const decimals = await contract.decimals();
|
||||
|
|
@ -126,7 +167,7 @@ export class Erc20TransferService {
|
|||
this.logger.log(`[TRANSFER] Amount in wei: ${amountInWei.toString()}`);
|
||||
|
||||
// 检查余额
|
||||
const balance = await contract.balanceOf(wallet.address);
|
||||
const balance = await contract.balanceOf(this.hotWalletAddress);
|
||||
this.logger.log(`[TRANSFER] Hot wallet balance: ${formatUnits(balance, decimals)} USDT`);
|
||||
|
||||
if (balance < amountInWei) {
|
||||
|
|
@ -135,30 +176,74 @@ export class Erc20TransferService {
|
|||
return { success: false, error };
|
||||
}
|
||||
|
||||
// 执行转账
|
||||
this.logger.log(`[TRANSFER] Sending transaction...`);
|
||||
const tx = await contract.transfer(toAddress, amountInWei);
|
||||
this.logger.log(`[TRANSFER] Transaction sent: ${tx.hash}`);
|
||||
// 构建交易
|
||||
this.logger.log(`[TRANSFER] Building transaction...`);
|
||||
const nonce = await provider.getTransactionCount(this.hotWalletAddress);
|
||||
const feeData = await provider.getFeeData();
|
||||
|
||||
// ERC20 transfer 的 calldata
|
||||
const transferData = contract.interface.encodeFunctionData('transfer', [toAddress, amountInWei]);
|
||||
|
||||
// 估算 gas
|
||||
const gasEstimate = await provider.estimateGas({
|
||||
from: this.hotWalletAddress,
|
||||
to: config.usdtContract,
|
||||
data: transferData,
|
||||
});
|
||||
|
||||
const tx = Transaction.from({
|
||||
type: 2, // EIP-1559
|
||||
chainId: config.chainId,
|
||||
nonce,
|
||||
to: config.usdtContract,
|
||||
data: transferData,
|
||||
gasLimit: gasEstimate * BigInt(120) / BigInt(100), // 增加 20% buffer
|
||||
maxFeePerGas: feeData.maxFeePerGas,
|
||||
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
|
||||
});
|
||||
|
||||
this.logger.log(`[TRANSFER] Transaction built: nonce=${nonce}, gasLimit=${tx.gasLimit}`);
|
||||
|
||||
// 获取交易哈希用于签名
|
||||
const unsignedTxHash = tx.unsignedHash;
|
||||
this.logger.log(`[TRANSFER] Unsigned tx hash: ${unsignedTxHash}`);
|
||||
|
||||
// 使用 MPC 签名
|
||||
this.logger.log(`[TRANSFER] Requesting MPC signature...`);
|
||||
const signatureHex = await this.mpcSigningClient.signMessage(unsignedTxHash);
|
||||
this.logger.log(`[TRANSFER] MPC signature obtained: ${signatureHex.slice(0, 20)}...`);
|
||||
|
||||
// 解析签名
|
||||
const signature = Signature.from(signatureHex);
|
||||
|
||||
// 创建已签名交易
|
||||
const signedTx = tx.clone();
|
||||
signedTx.signature = signature;
|
||||
|
||||
// 广播交易
|
||||
this.logger.log(`[TRANSFER] Broadcasting transaction...`);
|
||||
const txResponse = await provider.broadcastTransaction(signedTx.serialized);
|
||||
this.logger.log(`[TRANSFER] Transaction sent: ${txResponse.hash}`);
|
||||
|
||||
// 等待确认
|
||||
this.logger.log(`[TRANSFER] Waiting for confirmation...`);
|
||||
const receipt = await tx.wait();
|
||||
const receipt = await txResponse.wait();
|
||||
|
||||
if (receipt.status === 1) {
|
||||
if (receipt && receipt.status === 1) {
|
||||
this.logger.log(`[TRANSFER] Transaction confirmed!`);
|
||||
this.logger.log(`[TRANSFER] Block: ${receipt.blockNumber}`);
|
||||
this.logger.log(`[TRANSFER] Gas used: ${receipt.gasUsed.toString()}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
txHash: tx.hash,
|
||||
txHash: txResponse.hash,
|
||||
gasUsed: receipt.gasUsed.toString(),
|
||||
blockNumber: receipt.blockNumber,
|
||||
};
|
||||
} else {
|
||||
const error = 'Transaction failed (reverted)';
|
||||
this.logger.error(`[TRANSFER] ${error}`);
|
||||
return { success: false, txHash: tx.hash, error };
|
||||
return { success: false, txHash: txResponse.hash, error };
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`[TRANSFER] Transfer failed:`, error);
|
||||
|
|
@ -173,6 +258,8 @@ export class Erc20TransferService {
|
|||
* 检查热钱包是否已配置
|
||||
*/
|
||||
isConfigured(chainType: ChainTypeEnum): boolean {
|
||||
return this.hotWallets.has(chainType);
|
||||
return this.providers.has(chainType) &&
|
||||
!!this.hotWalletAddress &&
|
||||
!!this.mpcSigningClient?.isConfigured();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { PrismaService } from './persistence/prisma/prisma.service';
|
||||
import { RedisService, AddressCacheService } from './redis';
|
||||
import { EventPublisherService, MpcEventConsumerService, WithdrawalEventConsumerService } from './kafka';
|
||||
import { EvmProviderAdapter, AddressDerivationAdapter, MnemonicDerivationAdapter, RecoveryMnemonicAdapter, BlockScannerService } from './blockchain';
|
||||
import { MpcSigningClient } from './mpc';
|
||||
import { DomainModule } from '@/domain/domain.module';
|
||||
import {
|
||||
DEPOSIT_TRANSACTION_REPOSITORY,
|
||||
|
|
@ -21,7 +23,7 @@ import {
|
|||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [DomainModule],
|
||||
imports: [DomainModule, HttpModule],
|
||||
providers: [
|
||||
// 核心服务
|
||||
PrismaService,
|
||||
|
|
@ -29,6 +31,7 @@ import {
|
|||
EventPublisherService,
|
||||
MpcEventConsumerService,
|
||||
WithdrawalEventConsumerService,
|
||||
MpcSigningClient,
|
||||
|
||||
// 区块链适配器
|
||||
EvmProviderAdapter,
|
||||
|
|
@ -68,6 +71,7 @@ import {
|
|||
EventPublisherService,
|
||||
MpcEventConsumerService,
|
||||
WithdrawalEventConsumerService,
|
||||
MpcSigningClient,
|
||||
EvmProviderAdapter,
|
||||
AddressDerivationAdapter,
|
||||
MnemonicDerivationAdapter,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export * from './mpc-signing.client';
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* MPC Signing Client
|
||||
*
|
||||
* 调用 mpc-service 进行 MPC 签名
|
||||
* 用于热钱包的 ERC20 转账签名
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface CreateSigningInput {
|
||||
username: string;
|
||||
messageHash: string;
|
||||
}
|
||||
|
||||
export interface SigningResult {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MpcSigningClient {
|
||||
private readonly logger = new Logger(MpcSigningClient.name);
|
||||
private readonly mpcServiceUrl: string;
|
||||
private readonly hotWalletUsername: string;
|
||||
private readonly hotWalletAddress: string;
|
||||
private readonly pollingIntervalMs: number = 2000;
|
||||
private readonly maxPollingAttempts: number = 150; // 5 minutes max
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly httpService: HttpService,
|
||||
) {
|
||||
this.mpcServiceUrl = this.configService.get<string>('MPC_SERVICE_URL', 'http://localhost:3013');
|
||||
this.hotWalletUsername = this.configService.get<string>('HOT_WALLET_USERNAME', '');
|
||||
this.hotWalletAddress = this.configService.get<string>('HOT_WALLET_ADDRESS', '');
|
||||
|
||||
if (!this.hotWalletUsername) {
|
||||
this.logger.warn('[INIT] HOT_WALLET_USERNAME not configured');
|
||||
}
|
||||
if (!this.hotWalletAddress) {
|
||||
this.logger.warn('[INIT] HOT_WALLET_ADDRESS not configured');
|
||||
}
|
||||
|
||||
this.logger.log(`[INIT] MPC Service URL: ${this.mpcServiceUrl}`);
|
||||
this.logger.log(`[INIT] Hot Wallet Username: ${this.hotWalletUsername || '(not configured)'}`);
|
||||
this.logger.log(`[INIT] Hot Wallet Address: ${this.hotWalletAddress || '(not configured)'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查热钱包是否已配置
|
||||
*/
|
||||
isConfigured(): boolean {
|
||||
return !!this.hotWalletUsername && !!this.hotWalletAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热钱包地址
|
||||
*/
|
||||
getHotWalletAddress(): string {
|
||||
return this.hotWalletAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热钱包用户名
|
||||
*/
|
||||
getHotWalletUsername(): string {
|
||||
return this.hotWalletUsername;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 MPC 签名会话
|
||||
*/
|
||||
async createSigningSession(messageHash: string): Promise<{ sessionId: string; status: string }> {
|
||||
this.logger.log(`[SIGN] Creating signing session for messageHash: ${messageHash.slice(0, 16)}...`);
|
||||
|
||||
if (!this.hotWalletUsername) {
|
||||
throw new Error('Hot wallet username not configured');
|
||||
}
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post<{
|
||||
sessionId: string;
|
||||
status: string;
|
||||
}>(
|
||||
`${this.mpcServiceUrl}/mpc/sign`,
|
||||
{
|
||||
username: this.hotWalletUsername,
|
||||
messageHash,
|
||||
},
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 30000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(`[SIGN] Session created: ${response.data.sessionId}`);
|
||||
return {
|
||||
sessionId: response.data.sessionId,
|
||||
status: response.data.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签名会话状态
|
||||
*/
|
||||
async getSigningStatus(sessionId: string): Promise<SigningResult> {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<{
|
||||
sessionId: string;
|
||||
status: string;
|
||||
signature?: string;
|
||||
}>(
|
||||
`${this.mpcServiceUrl}/mpc/sign/${sessionId}/status`,
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 10000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: response.data.sessionId,
|
||||
status: response.data.status,
|
||||
signature: response.data.signature,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名消息(创建会话并等待完成)
|
||||
*
|
||||
* @param messageHash 要签名的消息哈希 (hex string with 0x prefix)
|
||||
* @returns 签名结果 (hex string)
|
||||
*/
|
||||
async signMessage(messageHash: string): Promise<string> {
|
||||
this.logger.log(`[SIGN] Starting MPC signing for: ${messageHash.slice(0, 16)}...`);
|
||||
|
||||
// Step 1: 创建签名会话
|
||||
const session = await this.createSigningSession(messageHash);
|
||||
this.logger.log(`[SIGN] Session ID: ${session.sessionId}`);
|
||||
|
||||
// Step 2: 轮询等待签名完成
|
||||
const result = await this.pollForCompletion(session.sessionId);
|
||||
|
||||
if (result.status === 'completed' && result.signature) {
|
||||
this.logger.log(`[SIGN] Signature obtained: ${result.signature.slice(0, 20)}...`);
|
||||
return result.signature;
|
||||
}
|
||||
|
||||
throw new Error(`MPC signing failed with status: ${result.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询等待签名完成
|
||||
*/
|
||||
private async pollForCompletion(sessionId: string): Promise<SigningResult> {
|
||||
for (let attempt = 0; attempt < this.maxPollingAttempts; attempt++) {
|
||||
const result = await this.getSigningStatus(sessionId);
|
||||
|
||||
this.logger.debug(`[POLL] Session ${sessionId}: status=${result.status}, attempt=${attempt + 1}`);
|
||||
|
||||
if (result.status === 'completed') {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.status === 'failed' || result.status === 'expired') {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 等待下一次轮询
|
||||
await this.sleep(this.pollingIntervalMs);
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
status: 'timeout',
|
||||
};
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
|
@ -614,6 +614,10 @@ services:
|
|||
# - BSC_RPC_URL=https://data-seed-prebsc-1-s1.binance.org:8545
|
||||
# - BSC_CHAIN_ID=97
|
||||
# - BSC_USDT_CONTRACT=0x337610d27c682E347C9cD60BD4b3b107C9d34dDd
|
||||
# MPC Hot Wallet (用于提现转账)
|
||||
- MPC_SERVICE_URL=http://rwa-mpc-service:3006
|
||||
- HOT_WALLET_USERNAME=${HOT_WALLET_USERNAME:-}
|
||||
- HOT_WALLET_ADDRESS=${HOT_WALLET_ADDRESS:-}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
Loading…
Reference in New Issue