feat(mnemonic): add recovery mnemonic generation and backup confirmation
Backend (blockchain-service): - Add RecoveryMnemonicAdapter to generate 12-word BIP39 mnemonic - Generate mnemonic when wallet addresses are derived (linked to public key) - Include mnemonic in WalletAddressCreated event Backend (identity-service): - Add RecoveryMnemonic table with revocation/replacement support - Save encrypted mnemonic to database on WalletAddressCreated event - Add PUT /user/mnemonic/backup API to mark mnemonic as backed up - Clear plaintext mnemonic from Redis after backup confirmation Frontend (mobile-app): - Update markMnemonicBackedUp() to call backend API - Fix verify_mnemonic_page validation logic: - Checkbox checked → pass directly - Not checked → must select correct word 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f1390a85c8
commit
c1670d2439
|
|
@ -1,48 +1,8 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dir:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(ls -la \"c:\\Users\\dong\\Desktop\\rwadurian\\backend\\services\"\" 2>/dev/null || dir \"c:UsersdongDesktoprwadurianbackendservices\"\")",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npx nest build)",
|
||||
"Bash(npm install)",
|
||||
"Bash(npx prisma migrate dev:*)",
|
||||
"Bash(npx jest:*)",
|
||||
"Bash(flutter test:*)",
|
||||
"Bash(flutter analyze:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(flutter pub get:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nrefactor(infra): 统一微服务基础设施为共享模式\n\n- 将 presence-service 添加到主 docker-compose.yml(端口 3011,Redis DB 10)\n- 更新 init-databases.sh 添加 rwa_admin 和 rwa_presence 数据库\n- 重构 admin-service/deploy.sh 使用共享基础设施\n- 重构 presence-service/deploy.sh 使用共享基础设施\n- 添加 authorization-service 开发指南文档\n\n解决多个微服务独立启动重复基础设施(PostgreSQL/Redis/Kafka)的问题\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git push)",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(admin-service): 增强移动端版本上传功能\n\n- 添加 APK/IPA 文件解析器自动提取版本信息\n- 支持从安装包自动读取 versionName 和 versionCode\n- 添加 adbkit-apkreader 依赖解析 APK 文件\n- 添加 plist 依赖解析 IPA 文件\n- 优化上传接口支持自动填充版本信息\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(wsl -e bash -c:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git pull:*)",
|
||||
"Bash(git stash:*)",
|
||||
"Bash(cd:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(git revert:*)",
|
||||
"Bash(del \"c:\\Users\\dong\\Desktop\\rwadurian\\backend\\services\\identity-service\\src\\infrastructure\\persistence\\entities\\user-device.entity.ts\")",
|
||||
"Bash(cmd /c \"cd /d c:\\Users\\dong\\Desktop\\rwadurian\\backend\\services\\identity-service && npm run build\")",
|
||||
"Bash(cmd /c \"cd /d c:\\Users\\dong\\Desktop\\rwadurian\\backend\\services\\identity-service && npx nest build\")",
|
||||
"Bash(cmd /c \"cd /d c:\\Users\\dong\\Desktop\\rwadurian && git add -A && git status\")",
|
||||
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" status)",
|
||||
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" add -A)",
|
||||
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" commit -m \"$(cat <<''EOF''\nrefactor(identity): remove province/city/address fields\n\n- Remove provinceCode, cityCode, address from UserAccount aggregate\n- Remove ProvinceCode, CityCode value objects\n- Remove UserLocationUpdatedEvent domain event\n- Update Prisma schema to drop province/city/address columns\n- Update repository, mapper, handlers, services and DTOs\n- Clean up tests and factory files\n\nProvince/city should belong to adoption-service as transaction data,\nnot identity-service user data.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" push)",
|
||||
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" commit -m \"$(cat <<''EOF''\nfix(identity): update migration to TEXT avatar and remove province/city/address\n\n- Change avatar_url column from VARCHAR(500) to TEXT\n- Remove province_code, city_code, address columns from user_accounts\n- Remove idx_province_city index\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" commit -m \"$(cat <<''EOF''\nfix(identity): remove address from updateProfile and fix deviceInfo type\n\n- Remove dto.address parameter from updateProfile controller\n- Remove address property from updateProfile service\n- Fix deviceInfo JSON serialization for Prisma InputJsonValue type\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" checkout -- backend/services/identity-service/prisma/migrations/20241204000000_init/migration.sql)",
|
||||
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" commit -m \"$(cat <<''EOF''\nfix(identity): add device_info columns to user_devices migration\n\n- Add device_info JSONB column for storing complete device info\n- Add platform, device_model, os_version, app_version columns\n- Add screen_width, screen_height, locale, timezone columns\n- Add idx_platform index\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" commit -m \"$(cat <<''EOF''\nfix(mobile-app): fix API response parsing for auto-create and wallet\n\n- Extract ''data'' field from API response before parsing\n- Fix createAccount() to parse responseData[''data'']\n- Fix getWalletInfo() to parse responseData[''data'']\n- Resolves: type ''Null'' is not a subtype of type ''int'' in type cast\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")"
|
||||
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" commit -m \"$(cat <<''EOF''\nfix(mobile-app): skip backup mnemonic page for MPC wallet mode\n\n- Fix mnemonic parsing: empty string \"\" now correctly becomes empty list\n- MPC mode (no mnemonic) skips backup page and navigates directly to home\n- Apply fix to both initial load and polling logic\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(npx prisma:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
AddressDerivationAdapter,
|
||||
DerivedAddress,
|
||||
} from '@/infrastructure/blockchain/address-derivation.adapter';
|
||||
import { RecoveryMnemonicAdapter } from '@/infrastructure/blockchain/recovery-mnemonic.adapter';
|
||||
import { AddressCacheService } from '@/infrastructure/redis/address-cache.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import {
|
||||
|
|
@ -42,6 +43,7 @@ export class AddressDerivationService {
|
|||
|
||||
constructor(
|
||||
private readonly addressDerivation: AddressDerivationAdapter,
|
||||
private readonly recoveryMnemonic: RecoveryMnemonicAdapter,
|
||||
private readonly addressCache: AddressCacheService,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
@Inject(MONITORED_ADDRESS_REPOSITORY)
|
||||
|
|
@ -68,7 +70,15 @@ export class AddressDerivationService {
|
|||
}
|
||||
}
|
||||
|
||||
// 3. 发布钱包地址创建事件 (包含所有链的地址)
|
||||
// 3. 生成恢复助记词 (与钱包公钥关联)
|
||||
this.logger.log(`[MNEMONIC] Generating recovery mnemonic for user ${userId}`);
|
||||
const mnemonicResult = this.recoveryMnemonic.generateMnemonic({
|
||||
userId: userId.toString(),
|
||||
publicKey,
|
||||
});
|
||||
this.logger.log(`[MNEMONIC] Recovery mnemonic generated, hash: ${mnemonicResult.mnemonicHash.slice(0, 16)}...`);
|
||||
|
||||
// 4. 发布钱包地址创建事件 (包含所有链的地址和助记词)
|
||||
const event = new WalletAddressCreatedEvent({
|
||||
userId: userId.toString(),
|
||||
publicKey,
|
||||
|
|
@ -76,6 +86,10 @@ export class AddressDerivationService {
|
|||
chainType: a.chainType,
|
||||
address: a.address,
|
||||
})),
|
||||
// 恢复助记词
|
||||
mnemonic: mnemonicResult.mnemonic,
|
||||
encryptedMnemonic: mnemonicResult.encryptedMnemonic,
|
||||
mnemonicHash: mnemonicResult.mnemonicHash,
|
||||
});
|
||||
|
||||
this.logger.log(`[PUBLISH] Publishing WalletAddressCreated event for user ${userId}`);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ export interface WalletAddressCreatedPayload {
|
|||
chainType: string;
|
||||
address: string;
|
||||
}[];
|
||||
// 恢复助记词相关
|
||||
mnemonic?: string; // 12词助记词 (明文)
|
||||
encryptedMnemonic?: string; // 加密的助记词
|
||||
mnemonicHash?: string; // 助记词哈希
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './evm-provider.adapter';
|
||||
export * from './address-derivation.adapter';
|
||||
export * from './mnemonic-derivation.adapter';
|
||||
export * from './recovery-mnemonic.adapter';
|
||||
export * from './block-scanner.service';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* Recovery Mnemonic Adapter
|
||||
*
|
||||
* 生成与钱包公钥关联的恢复助记词
|
||||
* 支持挂失和更换功能
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { validateMnemonic, entropyToMnemonic } from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||
import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface GenerateMnemonicParams {
|
||||
userId: string;
|
||||
publicKey: string; // 钱包公钥 (hex)
|
||||
}
|
||||
|
||||
export interface GenerateMnemonicResult {
|
||||
mnemonic: string; // 12词助记词 (明文,仅首次返回)
|
||||
encryptedMnemonic: string; // 加密的助记词
|
||||
mnemonicHash: string; // 助记词哈希 (用于验证)
|
||||
publicKey: string; // 关联的公钥
|
||||
}
|
||||
|
||||
export interface VerifyMnemonicResult {
|
||||
valid: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RecoveryMnemonicAdapter {
|
||||
private readonly logger = new Logger(RecoveryMnemonicAdapter.name);
|
||||
private readonly encryptionKey: Buffer;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
// 从环境变量获取加密密钥
|
||||
const key = this.configService.get<string>('MNEMONIC_ENCRYPTION_KEY');
|
||||
if (key) {
|
||||
this.encryptionKey = createHash('sha256').update(key).digest();
|
||||
} else {
|
||||
this.logger.warn('MNEMONIC_ENCRYPTION_KEY not set, using development key');
|
||||
this.encryptionKey = createHash('sha256').update('dev-mnemonic-key-do-not-use-in-production').digest();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成与钱包公钥关联的恢复助记词
|
||||
*
|
||||
* 生成逻辑:
|
||||
* 1. 用公钥 + 随机数生成熵
|
||||
* 2. 从熵生成 12 词 BIP39 助记词
|
||||
* 3. 加密存储
|
||||
*/
|
||||
generateMnemonic(params: GenerateMnemonicParams): GenerateMnemonicResult {
|
||||
const { userId, publicKey } = params;
|
||||
this.logger.log(`Generating recovery mnemonic for user=${userId}, publicKey=${publicKey.slice(0, 16)}...`);
|
||||
|
||||
// 生成随机熵 (128 bits = 16 bytes for 12 words)
|
||||
const randomEntropy = randomBytes(16);
|
||||
const publicKeyBytes = Buffer.from(publicKey.replace('0x', ''), 'hex');
|
||||
|
||||
// 混合熵: SHA256(randomEntropy + publicKey + timestamp)
|
||||
const timestampBuffer = Buffer.alloc(8);
|
||||
timestampBuffer.writeBigInt64BE(BigInt(Date.now()));
|
||||
|
||||
const mixedEntropyFull = createHash('sha256')
|
||||
.update(randomEntropy)
|
||||
.update(publicKeyBytes)
|
||||
.update(timestampBuffer)
|
||||
.digest();
|
||||
|
||||
// 取前 16 bytes (128 bits) 作为 BIP39 熵
|
||||
const entropy = mixedEntropyFull.slice(0, 16);
|
||||
|
||||
// 生成 12 词助记词
|
||||
const mnemonic = entropyToMnemonic(entropy, wordlist);
|
||||
|
||||
if (!validateMnemonic(mnemonic, wordlist)) {
|
||||
throw new Error('Generated mnemonic validation failed');
|
||||
}
|
||||
|
||||
// 加密助记词
|
||||
const encryptedMnemonic = this.encryptMnemonic(mnemonic);
|
||||
|
||||
// 计算助记词哈希 (用于验证,不可逆)
|
||||
const mnemonicHash = this.hashMnemonic(mnemonic);
|
||||
|
||||
this.logger.log(`Recovery mnemonic generated: hash=${mnemonicHash.slice(0, 16)}...`);
|
||||
|
||||
return {
|
||||
mnemonic,
|
||||
encryptedMnemonic,
|
||||
mnemonicHash,
|
||||
publicKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证助记词是否正确
|
||||
*/
|
||||
verifyMnemonic(mnemonic: string, expectedHash: string): VerifyMnemonicResult {
|
||||
if (!validateMnemonic(mnemonic, wordlist)) {
|
||||
return { valid: false, message: 'Invalid mnemonic format' };
|
||||
}
|
||||
|
||||
const hash = this.hashMnemonic(mnemonic);
|
||||
if (hash !== expectedHash) {
|
||||
return { valid: false, message: 'Mnemonic does not match' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密助记词
|
||||
*/
|
||||
decryptMnemonic(encryptedMnemonic: string): string {
|
||||
try {
|
||||
const data = Buffer.from(encryptedMnemonic, 'base64');
|
||||
const iv = data.slice(0, 16);
|
||||
const encrypted = data.slice(16);
|
||||
|
||||
const decipher = createDecipheriv('aes-256-cbc', this.encryptionKey, iv);
|
||||
let decrypted = decipher.update(encrypted);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
|
||||
return decrypted.toString('utf8');
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to decrypt mnemonic: ${error.message}`);
|
||||
throw new Error('Failed to decrypt mnemonic');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密助记词
|
||||
*/
|
||||
private encryptMnemonic(mnemonic: string): string {
|
||||
const iv = randomBytes(16);
|
||||
const cipher = createCipheriv('aes-256-cbc', this.encryptionKey, iv);
|
||||
|
||||
let encrypted = cipher.update(mnemonic, 'utf8');
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
|
||||
return Buffer.concat([iv, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算助记词哈希 (不可逆)
|
||||
*/
|
||||
private hashMnemonic(mnemonic: string): string {
|
||||
const hash1 = createHash('sha256').update(mnemonic).digest();
|
||||
const hash2 = createHash('sha256').update(hash1).digest();
|
||||
return hash2.toString('hex');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { Global, Module } from '@nestjs/common';
|
|||
import { PrismaService } from './persistence/prisma/prisma.service';
|
||||
import { RedisService, AddressCacheService } from './redis';
|
||||
import { EventPublisherService, MpcEventConsumerService } from './kafka';
|
||||
import { EvmProviderAdapter, AddressDerivationAdapter, MnemonicDerivationAdapter, BlockScannerService } from './blockchain';
|
||||
import { EvmProviderAdapter, AddressDerivationAdapter, MnemonicDerivationAdapter, RecoveryMnemonicAdapter, BlockScannerService } from './blockchain';
|
||||
import { DomainModule } from '@/domain/domain.module';
|
||||
import {
|
||||
DEPOSIT_TRANSACTION_REPOSITORY,
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
EvmProviderAdapter,
|
||||
AddressDerivationAdapter,
|
||||
MnemonicDerivationAdapter,
|
||||
RecoveryMnemonicAdapter,
|
||||
BlockScannerService,
|
||||
|
||||
// 缓存服务
|
||||
|
|
@ -62,6 +63,7 @@ import {
|
|||
EvmProviderAdapter,
|
||||
AddressDerivationAdapter,
|
||||
MnemonicDerivationAdapter,
|
||||
RecoveryMnemonicAdapter,
|
||||
BlockScannerService,
|
||||
AddressCacheService,
|
||||
DEPOSIT_TRANSACTION_REPOSITORY,
|
||||
|
|
|
|||
|
|
@ -296,6 +296,35 @@ CREATE INDEX "idx_session_status" ON "mpc_sessions"("status");
|
|||
-- CreateIndex
|
||||
CREATE INDEX "idx_session_created" ON "mpc_sessions"("created_at");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "recovery_mnemonics" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"user_id" BIGINT NOT NULL,
|
||||
"public_key" VARCHAR(130) NOT NULL,
|
||||
"encrypted_mnemonic" TEXT NOT NULL,
|
||||
"mnemonic_hash" VARCHAR(64) NOT NULL,
|
||||
"status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
"is_backed_up" BOOLEAN NOT NULL DEFAULT false,
|
||||
"revoked_at" TIMESTAMP(3),
|
||||
"revoked_reason" VARCHAR(200),
|
||||
"replaced_by_id" BIGINT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "recovery_mnemonics_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "uk_user_active_mnemonic" ON "recovery_mnemonics"("user_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_recovery_user" ON "recovery_mnemonics"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_recovery_public_key" ON "recovery_mnemonics"("public_key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_recovery_status" ON "recovery_mnemonics"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "referral_links_short_code_key" ON "referral_links"("short_code");
|
||||
|
||||
|
|
|
|||
|
|
@ -234,6 +234,34 @@ model MpcSession {
|
|||
@@map("mpc_sessions")
|
||||
}
|
||||
|
||||
// 账户恢复助记词 - 与钱包公钥关联,支持挂失和更换
|
||||
model RecoveryMnemonic {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @map("user_id")
|
||||
publicKey String @map("public_key") @db.VarChar(130) // 关联的钱包公钥
|
||||
|
||||
// 助记词存储 (加密)
|
||||
encryptedMnemonic String @map("encrypted_mnemonic") @db.Text // AES加密的助记词
|
||||
mnemonicHash String @map("mnemonic_hash") @db.VarChar(64) // SHA256哈希(用于验证)
|
||||
|
||||
// 状态管理
|
||||
status String @default("ACTIVE") @db.VarChar(20) // ACTIVE, REVOKED, REPLACED
|
||||
isBackedUp Boolean @default(false) @map("is_backed_up") // 用户是否已备份
|
||||
|
||||
// 挂失/更换相关
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
revokedReason String? @map("revoked_reason") @db.VarChar(200)
|
||||
replacedById BigInt? @map("replaced_by_id") // 被哪个新助记词替代
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([userId, status], name: "uk_user_active_mnemonic") // 一个用户只有一个ACTIVE助记词
|
||||
@@index([userId], name: "idx_recovery_user")
|
||||
@@index([publicKey], name: "idx_recovery_public_key")
|
||||
@@index([status], name: "idx_recovery_status")
|
||||
@@map("recovery_mnemonics")
|
||||
}
|
||||
|
||||
// 推荐链接 - 用于追踪不同渠道的邀请
|
||||
model ReferralLink {
|
||||
linkId BigInt @id @default(autoincrement()) @map("link_id")
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
|
||||
UpdateProfileCommand, SubmitKYCCommand, RemoveDeviceCommand, SendSmsCodeCommand,
|
||||
GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery, GetWalletStatusQuery,
|
||||
MarkMnemonicBackedUpCommand,
|
||||
} from '@/application/commands';
|
||||
import {
|
||||
AutoCreateAccountDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto,
|
||||
|
|
@ -177,4 +178,15 @@ export class UserAccountController {
|
|||
new GetWalletStatusQuery(user.accountSequence),
|
||||
);
|
||||
}
|
||||
|
||||
@Put('mnemonic/backup')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '标记助记词已备份' })
|
||||
@ApiResponse({ status: 200, description: '标记成功' })
|
||||
async markMnemonicBackedUp(@CurrentUser() user: CurrentUserData) {
|
||||
await this.userService.markMnemonicBackedUp(
|
||||
new MarkMnemonicBackedUpCommand(user.userId),
|
||||
);
|
||||
return { message: '已标记为已备份' };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,6 +153,10 @@ export class GetWalletStatusQuery {
|
|||
constructor(public readonly userSerialNum: number) {}
|
||||
}
|
||||
|
||||
export class MarkMnemonicBackedUpCommand {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
|
||||
// ============ Results ============
|
||||
|
||||
// 钱包状态
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/reposit
|
|||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||
import { ChainType, UserId } from '@/domain/value-objects';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
import {
|
||||
BlockchainEventConsumerService,
|
||||
WalletAddressCreatedPayload,
|
||||
|
|
@ -30,6 +31,7 @@ interface WalletCompletedStatusData {
|
|||
userId: string;
|
||||
publicKey?: string;
|
||||
walletAddresses?: { chainType: string; address: string }[];
|
||||
mnemonic?: string; // 恢复助记词 (明文,仅首次)
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +43,7 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
|||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly userRepository: UserAccountRepository,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly blockchainEventConsumer: BlockchainEventConsumerService,
|
||||
) {}
|
||||
|
||||
|
|
@ -59,11 +62,12 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
|||
* - BSC: 0x... (EVM)
|
||||
*/
|
||||
private async handleWalletAddressCreated(payload: WalletAddressCreatedPayload): Promise<void> {
|
||||
const { userId, publicKey, addresses } = payload;
|
||||
const { userId, publicKey, addresses, mnemonic, encryptedMnemonic, mnemonicHash } = payload;
|
||||
|
||||
this.logger.log(`[HANDLE] Processing WalletAddressCreated: userId=${userId}`);
|
||||
this.logger.log(`[HANDLE] Public key: ${publicKey?.substring(0, 30)}...`);
|
||||
this.logger.log(`[HANDLE] Addresses: ${JSON.stringify(addresses)}`);
|
||||
this.logger.log(`[HANDLE] Has mnemonic: ${!!mnemonic}`);
|
||||
|
||||
if (!userId) {
|
||||
this.logger.error('[ERROR] WalletAddressCreated event missing userId, skipping');
|
||||
|
|
@ -83,14 +87,15 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
|||
return;
|
||||
}
|
||||
|
||||
// 2. Create wallet addresses for each chain
|
||||
// 2. Create wallet addresses for each chain (with publicKey)
|
||||
const wallets: WalletAddress[] = addresses.map((addr) => {
|
||||
const chainType = this.parseChainType(addr.chainType);
|
||||
this.logger.log(`[WALLET] Creating wallet: ${addr.chainType} -> ${addr.address}`);
|
||||
this.logger.log(`[WALLET] Creating wallet: ${addr.chainType} -> ${addr.address} (publicKey: ${publicKey?.slice(0, 16)}...)`);
|
||||
return WalletAddress.create({
|
||||
userId: account.userId,
|
||||
chainType,
|
||||
address: addr.address,
|
||||
publicKey, // 传入公钥,用于关联助记词
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -98,12 +103,19 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
|||
await this.userRepository.saveWallets(account.userId, wallets);
|
||||
this.logger.log(`[WALLET] Saved ${wallets.length} wallet addresses for user: ${userId}`);
|
||||
|
||||
// 4. Update Redis status to completed
|
||||
// 4. Save recovery mnemonic if provided
|
||||
if (mnemonic && encryptedMnemonic && mnemonicHash && publicKey) {
|
||||
await this.saveRecoveryMnemonic(BigInt(userId), publicKey, encryptedMnemonic, mnemonicHash);
|
||||
this.logger.log(`[MNEMONIC] Saved recovery mnemonic for user: ${userId}`);
|
||||
}
|
||||
|
||||
// 5. Update Redis status to completed (include mnemonic for first-time retrieval)
|
||||
const statusData: WalletCompletedStatusData = {
|
||||
status: 'completed',
|
||||
userId,
|
||||
publicKey,
|
||||
walletAddresses: addresses,
|
||||
mnemonic, // 首次返回明文助记词
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
|
@ -141,4 +153,39 @@ export class BlockchainWalletHandler implements OnModuleInit {
|
|||
return ChainType.BSC;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save recovery mnemonic to database
|
||||
*/
|
||||
private async saveRecoveryMnemonic(
|
||||
userId: bigint,
|
||||
publicKey: string,
|
||||
encryptedMnemonic: string,
|
||||
mnemonicHash: string,
|
||||
): Promise<void> {
|
||||
// Check if mnemonic already exists for this user
|
||||
const existing = await this.prisma.recoveryMnemonic.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
this.logger.log(`[MNEMONIC] Active mnemonic already exists for user: ${userId}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new recovery mnemonic record
|
||||
await this.prisma.recoveryMnemonic.create({
|
||||
data: {
|
||||
userId,
|
||||
publicKey,
|
||||
encryptedMnemonic,
|
||||
mnemonicHash,
|
||||
status: 'ACTIVE',
|
||||
isBackedUp: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '@/domain/value-objects';
|
||||
import { TokenService } from './token.service';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
|
||||
|
|
@ -27,7 +28,7 @@ import {
|
|||
UpdateProfileCommand, SubmitKYCCommand, ReviewKYCCommand, RemoveDeviceCommand,
|
||||
SendSmsCodeCommand, GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery,
|
||||
ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand,
|
||||
GetWalletStatusQuery, WalletStatusResult,
|
||||
GetWalletStatusQuery, WalletStatusResult, MarkMnemonicBackedUpCommand,
|
||||
AutoCreateAccountResult, RecoverAccountResult, AutoLoginResult, RegisterResult,
|
||||
LoginResult, UserProfileDTO, DeviceDTO, UserBriefDTO,
|
||||
ReferralCodeValidationResult, ReferralLinkResult, ReferralStatsResult, MeResult,
|
||||
|
|
@ -48,6 +49,7 @@ export class UserApplicationService {
|
|||
private readonly mpcWalletService: MpcWalletService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly smsService: SmsService,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
// 注入事件处理器以确保它们被 NestJS 实例化并执行 onModuleInit
|
||||
|
|
@ -644,6 +646,7 @@ export class UserApplicationService {
|
|||
* 获取钱包状态 (GET /user/{userSerialNum}/wallet)
|
||||
*
|
||||
* 钱包通过 Kafka 事件异步生成,此接口用于轮询查询状态
|
||||
* 首次查询时会返回助记词(未备份状态),之后不再返回
|
||||
*/
|
||||
async getWalletStatus(query: GetWalletStatusQuery): Promise<WalletStatusResult> {
|
||||
const accountSequence = AccountSequence.create(query.userSerialNum);
|
||||
|
|
@ -662,8 +665,9 @@ export class UserApplicationService {
|
|||
const bscWallet = wallets.find(w => w.chainType === ChainType.BSC);
|
||||
|
||||
if (kavaWallet && dstWallet && bscWallet) {
|
||||
// 钱包已就绪
|
||||
// 注意: MPC 模式下没有助记词,返回空字符串
|
||||
// 钱包已就绪,获取助记词
|
||||
const mnemonic = await this.getRecoveryMnemonic(BigInt(account.userId.value));
|
||||
|
||||
return {
|
||||
status: 'ready',
|
||||
walletAddresses: {
|
||||
|
|
@ -671,7 +675,7 @@ export class UserApplicationService {
|
|||
dst: dstWallet.address,
|
||||
bsc: bscWallet.address,
|
||||
},
|
||||
mnemonic: '', // MPC模式无助记词
|
||||
mnemonic: mnemonic || '',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -680,4 +684,99 @@ export class UserApplicationService {
|
|||
status: 'generating',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的恢复助记词
|
||||
*
|
||||
* 优先从 Redis 获取(首次生成时临时存储的明文)
|
||||
* 如果 Redis 没有,从数据库获取(仅当用户未备份时返回)
|
||||
*/
|
||||
private async getRecoveryMnemonic(userId: bigint): Promise<string | null> {
|
||||
// 1. 先从 Redis 获取首次生成的助记词
|
||||
const redisKey = `keygen:status:${userId}`;
|
||||
const statusData = await this.redisService.get(redisKey);
|
||||
|
||||
if (statusData) {
|
||||
try {
|
||||
const parsed = JSON.parse(statusData);
|
||||
if (parsed.mnemonic) {
|
||||
this.logger.log(`[MNEMONIC] Found mnemonic in Redis for user: ${userId}`);
|
||||
return parsed.mnemonic;
|
||||
}
|
||||
} catch {
|
||||
// 解析失败,继续从数据库获取
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 从数据库获取(仅当用户未备份时返回)
|
||||
const recoveryMnemonic = await this.prisma.recoveryMnemonic.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
status: 'ACTIVE',
|
||||
isBackedUp: false, // 只有未备份时才返回
|
||||
},
|
||||
});
|
||||
|
||||
if (!recoveryMnemonic) {
|
||||
this.logger.log(`[MNEMONIC] No active unbackuped mnemonic for user: ${userId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 返回空字符串,因为加密的助记词需要解密才能返回
|
||||
// 实际应用中应该解密后返回,但这里为了安全,只在首次(Redis 中有时)返回
|
||||
this.logger.log(`[MNEMONIC] Found encrypted mnemonic in DB for user: ${userId}, but not returning decrypted value`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============ 助记词备份相关 ============
|
||||
|
||||
/**
|
||||
* 标记助记词已备份 (PUT /user/mnemonic/backup)
|
||||
*
|
||||
* 用户确认已备份助记词后调用此接口:
|
||||
* 1. 更新数据库 isBackedUp = true
|
||||
* 2. 清除 Redis 中的明文助记词
|
||||
*/
|
||||
async markMnemonicBackedUp(command: MarkMnemonicBackedUpCommand): Promise<void> {
|
||||
const userId = BigInt(command.userId);
|
||||
this.logger.log(`[BACKUP] Marking mnemonic as backed up for user: ${userId}`);
|
||||
|
||||
// 1. 更新数据库
|
||||
const result = await this.prisma.recoveryMnemonic.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
status: 'ACTIVE',
|
||||
isBackedUp: false,
|
||||
},
|
||||
data: {
|
||||
isBackedUp: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
this.logger.warn(`[BACKUP] No active unbackuped mnemonic found for user: ${userId}`);
|
||||
// 不抛出错误,可能已经备份过了
|
||||
} else {
|
||||
this.logger.log(`[BACKUP] Mnemonic marked as backed up for user: ${userId}`);
|
||||
}
|
||||
|
||||
// 2. 清除 Redis 中的明文助记词(更新状态,移除 mnemonic 字段)
|
||||
const redisKey = `keygen:status:${userId}`;
|
||||
const statusData = await this.redisService.get(redisKey);
|
||||
|
||||
if (statusData) {
|
||||
try {
|
||||
const parsed = JSON.parse(statusData);
|
||||
if (parsed.mnemonic) {
|
||||
// 移除明文助记词,保留其他状态信息
|
||||
delete parsed.mnemonic;
|
||||
parsed.isBackedUp = true;
|
||||
await this.redisService.set(redisKey, JSON.stringify(parsed), 60 * 60 * 24); // 24小时
|
||||
this.logger.log(`[BACKUP] Cleared mnemonic from Redis for user: ${userId}`);
|
||||
}
|
||||
} catch {
|
||||
// 解析失败,忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,9 +188,14 @@ export class WalletAddress {
|
|||
// ==================== 兼容旧版本的方法 (保留但标记为废弃) ====================
|
||||
|
||||
/**
|
||||
* @deprecated 请使用 createMpc 方法
|
||||
* 创建钱包地址(简化版,用于从 blockchain-service 接收地址)
|
||||
*/
|
||||
static create(params: { userId: UserId; chainType: ChainType; address: string }): WalletAddress {
|
||||
static create(params: {
|
||||
userId: UserId;
|
||||
chainType: ChainType;
|
||||
address: string;
|
||||
publicKey?: string; // 公钥
|
||||
}): WalletAddress {
|
||||
if (!this.validateAddress(params.chainType, params.address)) {
|
||||
throw new DomainError(`${params.chainType}地址格式错误`);
|
||||
}
|
||||
|
|
@ -199,7 +204,7 @@ export class WalletAddress {
|
|||
params.userId,
|
||||
params.chainType,
|
||||
params.address,
|
||||
'',
|
||||
params.publicKey || '',
|
||||
'',
|
||||
'', // empty signature
|
||||
AddressStatus.ACTIVE,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ export interface WalletAddressCreatedPayload {
|
|||
chainType: string;
|
||||
address: string;
|
||||
}[];
|
||||
// 恢复助记词相关
|
||||
mnemonic?: string; // 12词助记词 (明文)
|
||||
encryptedMnemonic?: string; // 加密的助记词
|
||||
mnemonicHash?: string; // 助记词哈希
|
||||
}
|
||||
|
||||
export type BlockchainEventHandler<T> = (payload: T) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -562,13 +562,28 @@ class AccountService {
|
|||
}
|
||||
|
||||
/// 标记助记词已备份
|
||||
///
|
||||
/// 1. 调用后端 API 标记已备份
|
||||
/// 2. 保存本地状态
|
||||
Future<void> markMnemonicBackedUp() async {
|
||||
debugPrint('$_tag markMnemonicBackedUp() - 标记助记词已备份');
|
||||
|
||||
try {
|
||||
// 1. 调用后端 API
|
||||
debugPrint('$_tag markMnemonicBackedUp() - 调用 PUT /user/mnemonic/backup');
|
||||
await _apiClient.put('/user/mnemonic/backup');
|
||||
debugPrint('$_tag markMnemonicBackedUp() - 后端标记成功');
|
||||
} catch (e) {
|
||||
// 后端调用失败时仅记录日志,不影响本地标记
|
||||
debugPrint('$_tag markMnemonicBackedUp() - 后端调用失败: $e');
|
||||
}
|
||||
|
||||
// 2. 保存本地状态
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.isMnemonicBackedUp,
|
||||
value: 'true',
|
||||
);
|
||||
debugPrint('$_tag markMnemonicBackedUp() - 完成');
|
||||
debugPrint('$_tag markMnemonicBackedUp() - 本地标记完成');
|
||||
}
|
||||
|
||||
/// 检查助记词是否已备份
|
||||
|
|
|
|||
|
|
@ -105,14 +105,18 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
|
|||
|
||||
/// 检查是否可以提交
|
||||
bool get _canSubmit {
|
||||
// 方式1:勾选确认
|
||||
// 方式2:选择正确的单词
|
||||
return _isChecked || _selectedWord == _correctWord;
|
||||
// 方式1:勾选确认 → 直接通过
|
||||
// 方式2:不勾选 → 必须选择正确的单词
|
||||
if (_isChecked) {
|
||||
return true;
|
||||
}
|
||||
return _selectedWord == _correctWord;
|
||||
}
|
||||
|
||||
/// 确认并创建账号
|
||||
Future<void> _confirmAndCreate() async {
|
||||
if (!_canSubmit) {
|
||||
// 未勾选时,检查是否选择了单词
|
||||
if (!_isChecked && _selectedWord == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('请勾选确认或选择正确的助记词单词'),
|
||||
|
|
@ -122,8 +126,8 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
|
|||
return;
|
||||
}
|
||||
|
||||
// 如果选择了单词但选错了
|
||||
if (_selectedWord != null && _selectedWord != _correctWord) {
|
||||
// 未勾选时,检查选择的单词是否正确
|
||||
if (!_isChecked && _selectedWord != _correctWord) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('选择的单词不正确,请重试'),
|
||||
|
|
|
|||
Loading…
Reference in New Issue