From c1670d243927f3831819893df7b356321f8635f1 Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 7 Dec 2025 12:32:10 -0800 Subject: [PATCH] feat(mnemonic): add recovery mnemonic generation and backup confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/settings.local.json | 44 +---- .../services/address-derivation.service.ts | 16 +- .../events/wallet-address-created.event.ts | 4 + .../src/infrastructure/blockchain/index.ts | 1 + .../blockchain/recovery-mnemonic.adapter.ts | 156 ++++++++++++++++++ .../infrastructure/infrastructure.module.ts | 4 +- .../20241204000000_init/migration.sql | 29 ++++ .../identity-service/prisma/schema.prisma | 28 ++++ .../controllers/user-account.controller.ts | 12 ++ .../src/application/commands/index.ts | 4 + .../blockchain-wallet.handler.ts | 55 +++++- .../services/user-application.service.ts | 107 +++++++++++- .../domain/entities/wallet-address.entity.ts | 11 +- .../blockchain-event-consumer.service.ts | 4 + .../lib/core/services/account_service.dart | 17 +- .../pages/verify_mnemonic_page.dart | 16 +- 16 files changed, 446 insertions(+), 62 deletions(-) create mode 100644 backend/services/blockchain-service/src/infrastructure/blockchain/recovery-mnemonic.adapter.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9618458c..88d1a3ee 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 \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 \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 \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 \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 \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 \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 \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 \nEOF\n)\")", + "Bash(npx prisma:*)" ], "deny": [], "ask": [] diff --git a/backend/services/blockchain-service/src/application/services/address-derivation.service.ts b/backend/services/blockchain-service/src/application/services/address-derivation.service.ts index 4d59e9db..2543b5e1 100644 --- a/backend/services/blockchain-service/src/application/services/address-derivation.service.ts +++ b/backend/services/blockchain-service/src/application/services/address-derivation.service.ts @@ -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}`); diff --git a/backend/services/blockchain-service/src/domain/events/wallet-address-created.event.ts b/backend/services/blockchain-service/src/domain/events/wallet-address-created.event.ts index 0ad50a20..b67be787 100644 --- a/backend/services/blockchain-service/src/domain/events/wallet-address-created.event.ts +++ b/backend/services/blockchain-service/src/domain/events/wallet-address-created.event.ts @@ -7,6 +7,10 @@ export interface WalletAddressCreatedPayload { chainType: string; address: string; }[]; + // 恢复助记词相关 + mnemonic?: string; // 12词助记词 (明文) + encryptedMnemonic?: string; // 加密的助记词 + mnemonicHash?: string; // 助记词哈希 [key: string]: unknown; } diff --git a/backend/services/blockchain-service/src/infrastructure/blockchain/index.ts b/backend/services/blockchain-service/src/infrastructure/blockchain/index.ts index 97de5db9..66fc41a9 100644 --- a/backend/services/blockchain-service/src/infrastructure/blockchain/index.ts +++ b/backend/services/blockchain-service/src/infrastructure/blockchain/index.ts @@ -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'; diff --git a/backend/services/blockchain-service/src/infrastructure/blockchain/recovery-mnemonic.adapter.ts b/backend/services/blockchain-service/src/infrastructure/blockchain/recovery-mnemonic.adapter.ts new file mode 100644 index 00000000..1c6fd159 --- /dev/null +++ b/backend/services/blockchain-service/src/infrastructure/blockchain/recovery-mnemonic.adapter.ts @@ -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('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'); + } +} diff --git a/backend/services/blockchain-service/src/infrastructure/infrastructure.module.ts b/backend/services/blockchain-service/src/infrastructure/infrastructure.module.ts index adc8cb30..ce4a17ee 100644 --- a/backend/services/blockchain-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/blockchain-service/src/infrastructure/infrastructure.module.ts @@ -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, diff --git a/backend/services/identity-service/prisma/migrations/20241204000000_init/migration.sql b/backend/services/identity-service/prisma/migrations/20241204000000_init/migration.sql index 75cd9909..446b81f4 100644 --- a/backend/services/identity-service/prisma/migrations/20241204000000_init/migration.sql +++ b/backend/services/identity-service/prisma/migrations/20241204000000_init/migration.sql @@ -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"); diff --git a/backend/services/identity-service/prisma/schema.prisma b/backend/services/identity-service/prisma/schema.prisma index 1b384c9e..84c26503 100644 --- a/backend/services/identity-service/prisma/schema.prisma +++ b/backend/services/identity-service/prisma/schema.prisma @@ -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") diff --git a/backend/services/identity-service/src/api/controllers/user-account.controller.ts b/backend/services/identity-service/src/api/controllers/user-account.controller.ts index ea56b55b..106d3365 100644 --- a/backend/services/identity-service/src/api/controllers/user-account.controller.ts +++ b/backend/services/identity-service/src/api/controllers/user-account.controller.ts @@ -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: '已标记为已备份' }; + } } diff --git a/backend/services/identity-service/src/application/commands/index.ts b/backend/services/identity-service/src/application/commands/index.ts index 7ffeedb8..7a26e44a 100644 --- a/backend/services/identity-service/src/application/commands/index.ts +++ b/backend/services/identity-service/src/application/commands/index.ts @@ -153,6 +153,10 @@ export class GetWalletStatusQuery { constructor(public readonly userSerialNum: number) {} } +export class MarkMnemonicBackedUpCommand { + constructor(public readonly userId: string) {} +} + // ============ Results ============ // 钱包状态 diff --git a/backend/services/identity-service/src/application/event-handlers/blockchain-wallet.handler.ts b/backend/services/identity-service/src/application/event-handlers/blockchain-wallet.handler.ts index 7a78dc07..51019c3e 100644 --- a/backend/services/identity-service/src/application/event-handlers/blockchain-wallet.handler.ts +++ b/backend/services/identity-service/src/application/event-handlers/blockchain-wallet.handler.ts @@ -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 { - 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 { + // 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, + }, + }); + } } diff --git a/backend/services/identity-service/src/application/services/user-application.service.ts b/backend/services/identity-service/src/application/services/user-application.service.ts index b66b1ba3..c956fb55 100644 --- a/backend/services/identity-service/src/application/services/user-application.service.ts +++ b/backend/services/identity-service/src/application/services/user-application.service.ts @@ -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 { 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 { + // 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 { + 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 { + // 解析失败,忽略 + } + } + } } diff --git a/backend/services/identity-service/src/domain/entities/wallet-address.entity.ts b/backend/services/identity-service/src/domain/entities/wallet-address.entity.ts index 304ea361..64d163c2 100644 --- a/backend/services/identity-service/src/domain/entities/wallet-address.entity.ts +++ b/backend/services/identity-service/src/domain/entities/wallet-address.entity.ts @@ -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, diff --git a/backend/services/identity-service/src/infrastructure/kafka/blockchain-event-consumer.service.ts b/backend/services/identity-service/src/infrastructure/kafka/blockchain-event-consumer.service.ts index 47d153e6..8c2b7297 100644 --- a/backend/services/identity-service/src/infrastructure/kafka/blockchain-event-consumer.service.ts +++ b/backend/services/identity-service/src/infrastructure/kafka/blockchain-event-consumer.service.ts @@ -21,6 +21,10 @@ export interface WalletAddressCreatedPayload { chainType: string; address: string; }[]; + // 恢复助记词相关 + mnemonic?: string; // 12词助记词 (明文) + encryptedMnemonic?: string; // 加密的助记词 + mnemonicHash?: string; // 助记词哈希 } export type BlockchainEventHandler = (payload: T) => Promise; diff --git a/frontend/mobile-app/lib/core/services/account_service.dart b/frontend/mobile-app/lib/core/services/account_service.dart index fec38a7b..c2d876a4 100644 --- a/frontend/mobile-app/lib/core/services/account_service.dart +++ b/frontend/mobile-app/lib/core/services/account_service.dart @@ -562,13 +562,28 @@ class AccountService { } /// 标记助记词已备份 + /// + /// 1. 调用后端 API 标记已备份 + /// 2. 保存本地状态 Future 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() - 本地标记完成'); } /// 检查助记词是否已备份 diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/verify_mnemonic_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/verify_mnemonic_page.dart index 69c8f16f..b868f8ca 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/verify_mnemonic_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/verify_mnemonic_page.dart @@ -105,14 +105,18 @@ class _VerifyMnemonicPageState extends ConsumerState { /// 检查是否可以提交 bool get _canSubmit { - // 方式1:勾选确认 - // 方式2:选择正确的单词 - return _isChecked || _selectedWord == _correctWord; + // 方式1:勾选确认 → 直接通过 + // 方式2:不勾选 → 必须选择正确的单词 + if (_isChecked) { + return true; + } + return _selectedWord == _correctWord; } /// 确认并创建账号 Future _confirmAndCreate() async { - if (!_canSubmit) { + // 未勾选时,检查是否选择了单词 + if (!_isChecked && _selectedWord == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('请勾选确认或选择正确的助记词单词'), @@ -122,8 +126,8 @@ class _VerifyMnemonicPageState extends ConsumerState { return; } - // 如果选择了单词但选错了 - if (_selectedWord != null && _selectedWord != _correctWord) { + // 未勾选时,检查选择的单词是否正确 + if (!_isChecked && _selectedWord != _correctWord) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('选择的单词不正确,请重试'),