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:
hailin 2025-12-07 12:32:10 -08:00
parent f1390a85c8
commit c1670d2439
16 changed files with 446 additions and 62 deletions

View File

@ -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端口 3011Redis 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": []

View File

@ -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}`);

View File

@ -7,6 +7,10 @@ export interface WalletAddressCreatedPayload {
chainType: string;
address: string;
}[];
// 恢复助记词相关
mnemonic?: string; // 12词助记词 (明文)
encryptedMnemonic?: string; // 加密的助记词
mnemonicHash?: string; // 助记词哈希
[key: string]: unknown;
}

View File

@ -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';

View File

@ -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');
}
}

View File

@ -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,

View File

@ -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");

View File

@ -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")

View File

@ -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: '已标记为已备份' };
}
}

View File

@ -153,6 +153,10 @@ export class GetWalletStatusQuery {
constructor(public readonly userSerialNum: number) {}
}
export class MarkMnemonicBackedUpCommand {
constructor(public readonly userId: string) {}
}
// ============ Results ============
// 钱包状态

View File

@ -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,
},
});
}
}

View File

@ -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 {
// 解析失败,忽略
}
}
}
}

View File

@ -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,

View File

@ -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>;

View File

@ -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() - 本地标记完成');
}
///

View File

@ -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('选择的单词不正确,请重试'),