feat(mnemonic): propagate accountSequence through MPC keygen flow (DDD)

Changes across all three services to properly associate recovery mnemonics
with account sequence numbers instead of user IDs, following DDD principles:

identity-service:
- Add accountSequence to MpcKeygenRequestedEvent payload
- Pass accountSequence when publishing keygen request
- Remove direct access to recoveryMnemonic table (now in blockchain-service)
- Call blockchain-service for mnemonic backup marking
- BlockchainWalletHandler no longer saves mnemonic (stored in blockchain-service)

mpc-service:
- Add accountSequence to KeygenRequestedPayload interface
- Pass accountSequence through to blockchain-service when deriving addresses
- Include accountSequence in KeygenCompleted event extraPayload

blockchain-service:
- Add accountSequence to derive-address API and internal interfaces
- Add accountSequence to KeygenCompletedPayload extraPayload
- Add PUT /internal/mnemonic/backup API for marking mnemonic as backed up
- Store recovery mnemonic with accountSequence association

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-08 01:08:27 -08:00
parent e95dc4ca57
commit 1bfbaa06f1
16 changed files with 144 additions and 101 deletions

View File

@ -1,9 +1,9 @@
import { Controller, Post, Body, Get, Param } from '@nestjs/common';
import { Controller, Post, Body, Get, Param, Put } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AddressDerivationService } from '@/application/services/address-derivation.service';
import { MnemonicVerificationService } from '@/application/services/mnemonic-verification.service';
import { MnemonicDerivationAdapter } from '@/infrastructure/blockchain';
import { DeriveAddressDto, VerifyMnemonicDto, VerifyMnemonicHashDto } from '../dto/request';
import { DeriveAddressDto, VerifyMnemonicDto, VerifyMnemonicHashDto, MarkMnemonicBackupDto } from '../dto/request';
import { DeriveAddressResponseDto } from '../dto/response';
/**
@ -23,10 +23,11 @@ export class InternalController {
@ApiOperation({ summary: '从公钥派生地址' })
@ApiResponse({ status: 201, description: '派生成功', type: DeriveAddressResponseDto })
async deriveAddress(@Body() dto: DeriveAddressDto): Promise<DeriveAddressResponseDto> {
const result = await this.addressDerivationService.deriveAndRegister(
BigInt(dto.userId),
dto.publicKey,
);
const result = await this.addressDerivationService.deriveAndRegister({
userId: BigInt(dto.userId),
accountSequence: dto.accountSequence,
publicKey: dto.publicKey,
});
return {
userId: result.userId.toString(),
@ -90,4 +91,15 @@ export class InternalController {
message: result.message,
};
}
@Put('mnemonic/backup')
@ApiOperation({ summary: '标记助记词已备份' })
@ApiResponse({ status: 200, description: '标记成功' })
async markMnemonicBackedUp(@Body() dto: MarkMnemonicBackupDto) {
await this.mnemonicVerification.markAsBackedUp(dto.accountSequence);
return {
success: true,
message: 'Mnemonic marked as backed up',
};
}
}

View File

@ -1,4 +1,4 @@
import { IsString, IsNumberString } from 'class-validator';
import { IsString, IsNumberString, IsInt } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class DeriveAddressDto {
@ -6,6 +6,10 @@ export class DeriveAddressDto {
@IsNumberString()
userId: string;
@ApiProperty({ description: '账户序列号 (8位数字)', example: 10000001 })
@IsInt()
accountSequence: number;
@ApiProperty({
description: '压缩公钥 (33 bytes, 0x02/0x03 开头)',
example: '0x02abc123...',

View File

@ -2,3 +2,4 @@ export * from './query-balance.dto';
export * from './derive-address.dto';
export * from './verify-mnemonic.dto';
export * from './verify-mnemonic-hash.dto';
export * from './mark-mnemonic-backup.dto';

View File

@ -0,0 +1,8 @@
import { IsInt } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class MarkMnemonicBackupDto {
@ApiProperty({ description: '账户序列号 (8位数字)', example: 10000001 })
@IsInt()
accountSequence: number;
}

View File

@ -25,7 +25,7 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
/**
* MPC
* mpc-service KeygenCompleted publicKey userId
* mpc-service KeygenCompleted publicKeyuserId accountSequence
*/
private async handleKeygenCompleted(payload: KeygenCompletedPayload): Promise<void> {
this.logger.log(`[HANDLE] Received KeygenCompleted event`);
@ -33,13 +33,20 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
this.logger.log(`[HANDLE] publicKey: ${payload.publicKey?.substring(0, 30)}...`);
this.logger.log(`[HANDLE] extraPayload: ${JSON.stringify(payload.extraPayload)}`);
// Extract userId from extraPayload
// Extract userId and accountSequence from extraPayload
const userId = payload.extraPayload?.userId;
const accountSequence = payload.extraPayload?.accountSequence;
if (!userId) {
this.logger.error(`[ERROR] Missing userId in extraPayload, cannot derive addresses`);
return;
}
if (!accountSequence) {
this.logger.error(`[ERROR] Missing accountSequence in extraPayload, cannot derive addresses`);
return;
}
const publicKey = payload.publicKey;
if (!publicKey) {
this.logger.error(`[ERROR] Missing publicKey in payload, cannot derive addresses`);
@ -47,19 +54,20 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
}
try {
this.logger.log(`[DERIVE] Starting address derivation for user: ${userId}`);
this.logger.log(`[DERIVE] Starting address derivation for user: ${userId}, account: ${accountSequence}`);
const result = await this.addressDerivationService.deriveAndRegister(
BigInt(userId),
const result = await this.addressDerivationService.deriveAndRegister({
userId: BigInt(userId),
accountSequence: Number(accountSequence),
publicKey,
);
});
this.logger.log(`[DERIVE] Successfully derived ${result.addresses.length} addresses for user ${userId}`);
this.logger.log(`[DERIVE] Successfully derived ${result.addresses.length} addresses for account ${accountSequence}`);
result.addresses.forEach((addr) => {
this.logger.log(`[DERIVE] - ${addr.chainType}: ${addr.address}`);
});
} catch (error) {
this.logger.error(`[ERROR] Failed to derive addresses for user ${userId}:`, error);
this.logger.error(`[ERROR] Failed to derive addresses for account ${accountSequence}:`, error);
throw error;
}
}

View File

@ -6,6 +6,7 @@ import {
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 { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import {
MONITORED_ADDRESS_REPOSITORY,
IMonitoredAddressRepository,
@ -15,8 +16,15 @@ import { WalletAddressCreatedEvent } from '@/domain/events';
import { ChainType, EvmAddress } from '@/domain/value-objects';
import { ChainTypeEnum } from '@/domain/enums';
export interface DeriveAddressParams {
userId: bigint;
accountSequence: number;
publicKey: string;
}
export interface DeriveAddressResult {
userId: bigint;
accountSequence: number;
publicKey: string;
addresses: DerivedAddress[];
}
@ -46,6 +54,7 @@ export class AddressDerivationService {
private readonly recoveryMnemonic: RecoveryMnemonicAdapter,
private readonly addressCache: AddressCacheService,
private readonly eventPublisher: EventPublisherService,
private readonly prisma: PrismaService,
@Inject(MONITORED_ADDRESS_REPOSITORY)
private readonly monitoredAddressRepo: IMonitoredAddressRepository,
) {}
@ -53,8 +62,9 @@ export class AddressDerivationService {
/**
*
*/
async deriveAndRegister(userId: bigint, publicKey: string): Promise<DeriveAddressResult> {
this.logger.log(`[DERIVE] Starting address derivation for user ${userId}`);
async deriveAndRegister(params: DeriveAddressParams): Promise<DeriveAddressResult> {
const { userId, accountSequence, publicKey } = params;
this.logger.log(`[DERIVE] Starting address derivation for user ${userId}, account ${accountSequence}`);
this.logger.log(`[DERIVE] Public key: ${publicKey.substring(0, 30)}...`);
// 1. 派生所有链的地址 (包括 Cosmos 和 EVM)
@ -70,35 +80,50 @@ export class AddressDerivationService {
}
}
// 3. 生成恢复助记词 (与钱包公钥关联)
this.logger.log(`[MNEMONIC] Generating recovery mnemonic for user ${userId}`);
// 3. 生成恢复助记词 (与账户序列号关联)
this.logger.log(`[MNEMONIC] Generating recovery mnemonic for account ${accountSequence}`);
const mnemonicResult = this.recoveryMnemonic.generateMnemonic({
userId: userId.toString(),
publicKey,
});
this.logger.log(`[MNEMONIC] Recovery mnemonic generated, hash: ${mnemonicResult.mnemonicHash.slice(0, 16)}...`);
// 4. 发布钱包地址创建事件 (包含所有链的地址和助记词)
// 4. 存储恢复助记词到 blockchain-service 数据库 (使用 accountSequence 关联)
await this.prisma.recoveryMnemonic.create({
data: {
accountSequence,
publicKey,
encryptedMnemonic: mnemonicResult.encryptedMnemonic,
mnemonicHash: mnemonicResult.mnemonicHash,
status: 'ACTIVE',
isBackedUp: false,
},
});
this.logger.log(`[MNEMONIC] Recovery mnemonic saved for account ${accountSequence}`);
// 5. 发布钱包地址创建事件 (包含所有链的地址和助记词)
const event = new WalletAddressCreatedEvent({
userId: userId.toString(),
accountSequence,
publicKey,
addresses: derivedAddresses.map((a) => ({
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}`);
this.logger.log(`[PUBLISH] Publishing WalletAddressCreated event for account ${accountSequence}`);
this.logger.log(`[PUBLISH] Addresses: ${JSON.stringify(derivedAddresses)}`);
await this.eventPublisher.publish(event);
this.logger.log(`[PUBLISH] WalletAddressCreated event published successfully`);
return {
userId,
accountSequence,
publicKey,
addresses: derivedAddresses,
};

View File

@ -2,6 +2,7 @@ import { DomainEvent } from './domain-event.base';
export interface WalletAddressCreatedPayload {
userId: string;
accountSequence: number; // 8位账户序列号
publicKey: string;
addresses: {
chainType: string;

View File

@ -23,6 +23,7 @@ export interface KeygenCompletedPayload {
threshold: string;
extraPayload?: {
userId: string;
accountSequence: number; // 8位账户序列号用于关联恢复助记词
username: string;
delegateShare?: {
partyId: string;

View File

@ -103,10 +103,10 @@ 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. 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}`);
// 4. Recovery mnemonic is now stored in blockchain-service (DDD: domain separation)
// Note: blockchain-service stores mnemonic with accountSequence association
if (mnemonic) {
this.logger.log(`[MNEMONIC] Recovery mnemonic received for user: ${userId} (stored in blockchain-service)`);
}
// 5. Update Redis status to completed (include mnemonic for first-time retrieval)
@ -154,38 +154,4 @@ export class BlockchainWalletHandler implements OnModuleInit {
}
}
/**
* 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

@ -131,6 +131,7 @@ export class UserApplicationService {
await this.eventPublisher.publish(new MpcKeygenRequestedEvent({
sessionId,
userId: account.userId.toString(),
accountSequence: account.accountSequence.value, // 8位账户序列号用于关联恢复助记词
username: `user_${account.accountSequence.value}`, // 用于 mpc-system 标识
threshold: 2,
totalParties: 3,
@ -688,11 +689,11 @@ export class UserApplicationService {
/**
*
*
* Redis
* Redis
* Redis
* DDD: 助记词数据存储在 blockchain-serviceidentity-service 访
*/
private async getRecoveryMnemonic(userId: bigint): Promise<string | null> {
// 1. 先从 Redis 获取首次生成的助记词
// 从 Redis 获取首次生成的助记词
const redisKey = `keygen:status:${userId}`;
const statusData = await this.redisService.get(redisKey);
@ -704,27 +705,13 @@ export class UserApplicationService {
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`);
// Redis 中没有助记词,可能已经备份或过期
// DDD: 不再直接从 identity-service 数据库获取,助记词数据在 blockchain-service
this.logger.log(`[MNEMONIC] No mnemonic in Redis for user: ${userId} (may be backed up or expired)`);
return null;
}
@ -734,33 +721,30 @@ export class UserApplicationService {
* (PUT /user/mnemonic/backup)
*
*
* 1. isBackedUp = true
* 1. blockchain-service isBackedUp = true (DDD: domain separation)
* 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}`);
// 1. 获取用户的 accountSequence
const account = await this.userRepository.findById(UserId.create(command.userId));
if (!account) {
this.logger.warn(`[BACKUP] User not found: ${userId}`);
return;
}
// 2. 清除 Redis 中的明文助记词(更新状态,移除 mnemonic 字段)
// 2. 调用 blockchain-service 标记助记词已备份 (DDD: domain separation)
try {
await this.blockchainClient.markMnemonicBackedUp(account.accountSequence.value);
this.logger.log(`[BACKUP] Mnemonic marked as backed up in blockchain-service for account: ${account.accountSequence.value}`);
} catch (error) {
this.logger.error(`[BACKUP] Failed to mark mnemonic as backed up in blockchain-service`, error);
// 不阻塞,继续清除 Redis
}
// 3. 清除 Redis 中的明文助记词(更新状态,移除 mnemonic 字段)
const redisKey = `keygen:status:${userId}`;
const statusData = await this.redisService.get(redisKey);

View File

@ -166,6 +166,7 @@ export class UserAccountDeactivatedEvent extends DomainEvent {
* payload mpc-service KeygenRequestedPayload :
* - sessionId: 唯一会话ID
* - userId: 用户ID
* - accountSequence: 8位账户序列号 ()
* - username: 用户名 ( mpc-system )
* - threshold: 签名阈值 ( 2)
* - totalParties: 总参与方数 ( 3)
@ -176,6 +177,7 @@ export class MpcKeygenRequestedEvent extends DomainEvent {
public readonly payload: {
sessionId: string;
userId: string;
accountSequence: number;
username: string;
threshold: number;
totalParties: number;

View File

@ -140,4 +140,29 @@ export class BlockchainClientService {
throw error;
}
}
/**
*
*/
async markMnemonicBackedUp(accountSequence: number): Promise<void> {
this.logger.log(`Marking mnemonic as backed up for account ${accountSequence}`);
try {
await firstValueFrom(
this.httpService.put(
`${this.blockchainServiceUrl}/internal/mnemonic/backup`,
{ accountSequence },
{
headers: { 'Content-Type': 'application/json' },
timeout: 30000,
},
),
);
this.logger.log(`Mnemonic marked as backed up for account ${accountSequence}`);
} catch (error) {
this.logger.error('Failed to mark mnemonic as backed up', error);
throw error;
}
}
}

View File

@ -32,6 +32,7 @@ export interface KeygenCompletedPayload {
threshold: string;
extraPayload?: {
userId: string;
accountSequence: number; // 8位账户序列号
username: string;
delegateShare?: {
partyId: string;

View File

@ -46,7 +46,7 @@ export class KeygenRequestedHandler implements OnModuleInit {
this.logger.log(`[HANDLE] Payload: ${JSON.stringify(payload)}`);
const data = payload as unknown as KeygenRequestedPayload;
const { sessionId, userId, username, threshold, totalParties, requireDelegate } = data;
const { sessionId, userId, accountSequence, username, threshold, totalParties, requireDelegate } = data;
this.logger.log(`[HANDLE] Parsed request: sessionId=${sessionId}`);
this.logger.log(`[HANDLE] userId=${userId}, username=${username}`);
@ -81,6 +81,7 @@ export class KeygenRequestedHandler implements OnModuleInit {
try {
const deriveResult = await this.blockchainClient.deriveAddresses({
userId,
accountSequence, // 8位账户序列号用于关联恢复助记词
publicKey: result.publicKey,
});
derivedAddresses = deriveResult.addresses;
@ -129,6 +130,7 @@ export class KeygenRequestedHandler implements OnModuleInit {
// Add extra payload for identity-service
(completedEvent as any).extraPayload = {
userId,
accountSequence, // 8位账户序列号用于关联恢复助记词
username,
delegateShare: result.delegateShare,
derivedAddresses, // BSC, KAVA, DST addresses

View File

@ -11,6 +11,7 @@ import { firstValueFrom } from 'rxjs';
export interface DeriveAddressParams {
userId: string;
accountSequence: number; // 8位账户序列号用于关联恢复助记词
publicKey: string;
}
@ -54,6 +55,7 @@ export class BlockchainClientService {
`${this.blockchainServiceUrl}/api/v1/internal/derive-address`,
{
userId: params.userId,
accountSequence: params.accountSequence,
publicKey: params.publicKey,
},
{

View File

@ -18,6 +18,7 @@ export const MPC_CONSUME_TOPICS = {
export interface KeygenRequestedPayload {
sessionId: string;
userId: string;
accountSequence: number; // 8位账户序列号用于关联恢复助记词
username: string;
threshold: number;
totalParties: number;