feat: move address derivation from identity-service to blockchain-service

- Add Cosmos address derivation (bech32) to blockchain-service
  - KAVA: kava1... format
  - DST: dst1... format
  - BSC: 0x... EVM format

- Create MpcEventConsumerService in blockchain-service to consume mpc.KeygenCompleted events

- Create BlockchainEventConsumerService in identity-service to consume blockchain.WalletAddressCreated events

- Simplify identity-service MpcKeygenCompletedHandler to only manage status updates

- Add CosmosAddress value object for Cosmos chain addresses

Event flow:
1. identity-service -> mpc.KeygenRequested
2. mpc-service -> mpc.KeygenCompleted (with publicKey)
3. blockchain-service consumes mpc.KeygenCompleted, derives addresses
4. blockchain-service -> blockchain.WalletAddressCreated (with all chain addresses)
5. identity-service consumes blockchain.WalletAddressCreated, saves to user account

🤖 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-06 21:08:21 -08:00
parent 50388c1115
commit 2e815cec6e
28 changed files with 1096 additions and 353 deletions

View File

@ -1,37 +1,65 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { AddressDerivationService } from '../services/address-derivation.service';
export interface MpcKeygenCompletedPayload {
userId: string;
deviceId: string;
publicKey: string;
keyType: string;
}
import { MpcEventConsumerService, KeygenCompletedPayload } from '@/infrastructure/kafka/mpc-event-consumer.service';
/**
* MPC
*
* mpc.KeygenCompleted
* blockchain.WalletAddressCreated identity-service
*/
@Injectable()
export class MpcKeygenCompletedHandler {
export class MpcKeygenCompletedHandler implements OnModuleInit {
private readonly logger = new Logger(MpcKeygenCompletedHandler.name);
constructor(private readonly addressDerivationService: AddressDerivationService) {}
constructor(
private readonly addressDerivationService: AddressDerivationService,
private readonly mpcEventConsumer: MpcEventConsumerService,
) {}
onModuleInit() {
// Register handler for keygen completed events
this.mpcEventConsumer.onKeygenCompleted(this.handleKeygenCompleted.bind(this));
this.logger.log(`[INIT] MpcKeygenCompletedHandler registered with MpcEventConsumer`);
}
/**
* MPC
* mpc-service KeygenCompleted publicKey userId
*/
async handle(payload: MpcKeygenCompletedPayload): Promise<void> {
this.logger.log(`Handling MPC keygen completed for user: ${payload.userId}`);
private async handleKeygenCompleted(payload: KeygenCompletedPayload): Promise<void> {
this.logger.log(`[HANDLE] Received KeygenCompleted event`);
this.logger.log(`[HANDLE] sessionId: ${payload.sessionId}`);
this.logger.log(`[HANDLE] publicKey: ${payload.publicKey?.substring(0, 30)}...`);
this.logger.log(`[HANDLE] extraPayload: ${JSON.stringify(payload.extraPayload)}`);
// Extract userId from extraPayload
const userId = payload.extraPayload?.userId;
if (!userId) {
this.logger.error(`[ERROR] Missing userId in extraPayload, cannot derive addresses`);
return;
}
const publicKey = payload.publicKey;
if (!publicKey) {
this.logger.error(`[ERROR] Missing publicKey in payload, cannot derive addresses`);
return;
}
try {
this.logger.log(`[DERIVE] Starting address derivation for user: ${userId}`);
const result = await this.addressDerivationService.deriveAndRegister(
BigInt(payload.userId),
payload.publicKey,
BigInt(userId),
publicKey,
);
this.logger.log(`Derived ${result.addresses.length} addresses for user ${payload.userId}`);
this.logger.log(`[DERIVE] Successfully derived ${result.addresses.length} addresses for user ${userId}`);
result.addresses.forEach((addr) => {
this.logger.log(`[DERIVE] - ${addr.chainType}: ${addr.address}`);
});
} catch (error) {
this.logger.error(`Failed to derive addresses for user ${payload.userId}:`, error);
this.logger.error(`[ERROR] Failed to derive addresses for user ${userId}:`, error);
throw error;
}
}

View File

@ -12,6 +12,7 @@ import {
import { MonitoredAddress } from '@/domain/aggregates/monitored-address';
import { WalletAddressCreatedEvent } from '@/domain/events';
import { ChainType, EvmAddress } from '@/domain/value-objects';
import { ChainTypeEnum } from '@/domain/enums';
export interface DeriveAddressResult {
userId: bigint;
@ -22,11 +23,23 @@ export interface DeriveAddressResult {
/**
*
* MPC
*
*
* - KAVA: Cosmos bech32 (kava1...)
* - DST: Cosmos bech32 (dst1...)
* - BSC: EVM (0x...)
*
*
* - EVM (BSC)
* - Cosmos (KAVA, DST)
*/
@Injectable()
export class AddressDerivationService {
private readonly logger = new Logger(AddressDerivationService.name);
// EVM 链类型列表,用于判断是否需要注册监控
private readonly evmChains = new Set([ChainTypeEnum.BSC]);
constructor(
private readonly addressDerivation: AddressDerivationAdapter,
private readonly addressCache: AddressCacheService,
@ -39,38 +52,23 @@ export class AddressDerivationService {
*
*/
async deriveAndRegister(userId: bigint, publicKey: string): Promise<DeriveAddressResult> {
this.logger.log(`Deriving addresses for user ${userId} from public key`);
this.logger.log(`[DERIVE] Starting address derivation for user ${userId}`);
this.logger.log(`[DERIVE] Public key: ${publicKey.substring(0, 30)}...`);
// 1. 派生所有链的地址
// 1. 派生所有链的地址 (包括 Cosmos 和 EVM)
const derivedAddresses = this.addressDerivation.deriveAllAddresses(publicKey);
this.logger.log(`[DERIVE] Derived ${derivedAddresses.length} addresses`);
// 2. 为每个链注册监控地址
// 2. 只为 EVM 链注册监控地址 (用于充值检测)
for (const derived of derivedAddresses) {
const chainType = ChainType.fromEnum(derived.chainType);
const address = EvmAddress.create(derived.address);
// 检查是否已存在
const exists = await this.monitoredAddressRepo.existsByChainAndAddress(chainType, address);
if (!exists) {
// 创建监控地址
const monitored = MonitoredAddress.create({
chainType,
address,
userId,
});
await this.monitoredAddressRepo.save(monitored);
// 添加到缓存
await this.addressCache.addAddress(chainType, address.lowercase);
this.logger.log(`Registered address: ${derived.chainType} - ${derived.address}`);
if (this.evmChains.has(derived.chainType)) {
await this.registerEvmAddressForMonitoring(userId, derived);
} else {
this.logger.debug(`Address already registered: ${derived.chainType} - ${derived.address}`);
this.logger.log(`[DERIVE] Skipping monitoring registration for Cosmos chain: ${derived.chainType} - ${derived.address}`);
}
}
// 3. 发布钱包地址创建事件
// 3. 发布钱包地址创建事件 (包含所有链的地址)
const event = new WalletAddressCreatedEvent({
userId: userId.toString(),
publicKey,
@ -80,7 +78,10 @@ export class AddressDerivationService {
})),
});
this.logger.log(`[PUBLISH] Publishing WalletAddressCreated event for user ${userId}`);
this.logger.log(`[PUBLISH] Addresses: ${JSON.stringify(derivedAddresses)}`);
await this.eventPublisher.publish(event);
this.logger.log(`[PUBLISH] WalletAddressCreated event published successfully`);
return {
userId,
@ -89,6 +90,34 @@ export class AddressDerivationService {
};
}
/**
* EVM
*/
private async registerEvmAddressForMonitoring(userId: bigint, derived: DerivedAddress): Promise<void> {
const chainType = ChainType.fromEnum(derived.chainType);
const address = EvmAddress.create(derived.address);
// 检查是否已存在
const exists = await this.monitoredAddressRepo.existsByChainAndAddress(chainType, address);
if (!exists) {
// 创建监控地址
const monitored = MonitoredAddress.create({
chainType,
address,
userId,
});
await this.monitoredAddressRepo.save(monitored);
// 添加到缓存
await this.addressCache.addAddress(chainType, address.lowercase);
this.logger.log(`[MONITOR] Registered EVM address for monitoring: ${derived.chainType} - ${derived.address}`);
} else {
this.logger.debug(`[MONITOR] Address already registered: ${derived.chainType} - ${derived.address}`);
}
}
/**
*
*/

View File

@ -3,5 +3,6 @@
*/
export enum ChainTypeEnum {
KAVA = 'KAVA',
DST = 'DST',
BSC = 'BSC',
}

View File

@ -0,0 +1,50 @@
import { bech32 } from 'bech32';
/**
* Cosmos (bech32 )
* kava1..., dst1...
*/
export class CosmosAddress {
private readonly _value: string;
private readonly _prefix: string;
private constructor(value: string, prefix: string) {
this._value = value;
this._prefix = prefix;
}
static create(value: string): CosmosAddress {
try {
const decoded = bech32.decode(value);
return new CosmosAddress(value, decoded.prefix);
} catch {
throw new Error(`Invalid Cosmos address: ${value}`);
}
}
static fromPrefixAndHash(prefix: string, hash20Bytes: Uint8Array): CosmosAddress {
const words = bech32.toWords(Buffer.from(hash20Bytes));
const address = bech32.encode(prefix, words);
return new CosmosAddress(address, prefix);
}
get value(): string {
return this._value;
}
get prefix(): string {
return this._prefix;
}
get lowercase(): string {
return this._value.toLowerCase();
}
equals(other: CosmosAddress): boolean {
return this._value.toLowerCase() === other._value.toLowerCase();
}
toString(): string {
return this._value;
}
}

View File

@ -1,5 +1,6 @@
export * from './chain-type.vo';
export * from './evm-address.vo';
export * from './cosmos-address.vo';
export * from './tx-hash.vo';
export * from './token-amount.vo';
export * from './block-number.vo';

View File

@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { keccak256, getBytes } from 'ethers';
import { keccak256, getBytes, sha256, ripemd160 } from 'ethers';
import { bech32 } from 'bech32';
import { EvmAddress } from '@/domain/value-objects';
import { ChainTypeEnum } from '@/domain/enums';
@ -99,28 +100,77 @@ export class AddressDerivationAdapter {
return result;
}
/**
* Cosmos (bech32 )
*
* @param compressedPublicKey (33 bytes, 0x02/0x03 )
* @param prefix bech32 ( 'kava', 'dst')
* @returns bech32
*/
deriveCosmosAddress(compressedPublicKey: string, prefix: string): string {
// 移除 0x 前缀
const pubKeyHex = compressedPublicKey.replace('0x', '');
// 验证压缩公钥格式
if (pubKeyHex.length !== 66) {
throw new Error(`Invalid compressed public key length: ${pubKeyHex.length}, expected 66`);
}
// SHA256 哈希
const pubKeyBytes = getBytes('0x' + pubKeyHex);
const sha256Hash = sha256(pubKeyBytes);
// RIPEMD160 哈希 (得到 20 bytes)
const ripemd160Hash = ripemd160(sha256Hash);
// 转换为 5-bit words 用于 bech32 编码
const hashBytes = getBytes(ripemd160Hash);
const words = bech32.toWords(Buffer.from(hashBytes));
// bech32 编码
const address = bech32.encode(prefix, words);
this.logger.debug(`Derived Cosmos address with prefix '${prefix}': ${address}`);
return address;
}
/**
*
*/
deriveAllAddresses(compressedPublicKey: string): DerivedAddress[] {
const addresses: DerivedAddress[] = [];
this.logger.log(`[DERIVE] Starting address derivation for public key: ${compressedPublicKey.slice(0, 20)}...`);
// EVM 链共用同一个地址
const evmAddress = this.deriveEvmAddress(compressedPublicKey);
this.logger.log(`[DERIVE] EVM address derived: ${evmAddress}`);
// KAVA (EVM)
// KAVA (Cosmos bech32 格式 - kava1...)
const kavaAddress = this.deriveCosmosAddress(compressedPublicKey, 'kava');
addresses.push({
chainType: ChainTypeEnum.KAVA,
address: evmAddress,
address: kavaAddress,
});
this.logger.log(`[DERIVE] KAVA address (Cosmos): ${kavaAddress}`);
// BSC (EVM)
// DST (Cosmos bech32 格式 - dst1...)
const dstAddress = this.deriveCosmosAddress(compressedPublicKey, 'dst');
addresses.push({
chainType: ChainTypeEnum.DST,
address: dstAddress,
});
this.logger.log(`[DERIVE] DST address (Cosmos): ${dstAddress}`);
// BSC (EVM 格式 - 0x...)
addresses.push({
chainType: ChainTypeEnum.BSC,
address: evmAddress,
});
this.logger.log(`[DERIVE] BSC address (EVM): ${evmAddress}`);
this.logger.log(`Derived addresses from public key: ${addresses.length} chains`);
this.logger.log(`[DERIVE] Successfully derived ${addresses.length} addresses from public key`);
return addresses;
}

View File

@ -1,7 +1,7 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './persistence/prisma/prisma.service';
import { RedisService, AddressCacheService } from './redis';
import { EventPublisherService } from './kafka';
import { EventPublisherService, MpcEventConsumerService } from './kafka';
import { EvmProviderAdapter, AddressDerivationAdapter, BlockScannerService } from './blockchain';
import { DomainModule } from '@/domain/domain.module';
import {
@ -25,6 +25,7 @@ import {
PrismaService,
RedisService,
EventPublisherService,
MpcEventConsumerService,
// 区块链适配器
EvmProviderAdapter,
@ -56,6 +57,7 @@ import {
PrismaService,
RedisService,
EventPublisherService,
MpcEventConsumerService,
EvmProviderAdapter,
AddressDerivationAdapter,
BlockScannerService,

View File

@ -1,2 +1,3 @@
export * from './event-publisher.service';
export * from './event-consumer.controller';
export * from './mpc-event-consumer.service';

View File

@ -0,0 +1,186 @@
/**
* MPC Event Consumer Service for Blockchain Service
*
* Consumes MPC keygen completion events from mpc-service via Kafka.
* Derives wallet addresses from public keys and publishes WalletAddressCreated events.
*/
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
// MPC Event Topics (events from mpc-service)
export const MPC_TOPICS = {
KEYGEN_COMPLETED: 'mpc.KeygenCompleted',
SESSION_FAILED: 'mpc.SessionFailed',
} as const;
export interface KeygenCompletedPayload {
sessionId: string;
partyId: string;
publicKey: string;
shareId: string;
threshold: string;
extraPayload?: {
userId: string;
username: string;
delegateShare?: {
partyId: string;
partyIndex: number;
encryptedShare: string;
};
serverParties?: string[];
};
}
export interface SessionFailedPayload {
sessionId: string;
partyId: string;
sessionType: string;
errorMessage: string;
errorCode?: string;
extraPayload?: {
userId: string;
username: string;
};
}
export type MpcEventHandler<T> = (payload: T) => Promise<void>;
@Injectable()
export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MpcEventConsumerService.name);
private kafka: Kafka;
private consumer: Consumer;
private isConnected = false;
private keygenCompletedHandler?: MpcEventHandler<KeygenCompletedPayload>;
private sessionFailedHandler?: MpcEventHandler<SessionFailedPayload>;
constructor(private readonly configService: ConfigService) {}
async onModuleInit() {
const brokers = this.configService.get<string>('KAFKA_BROKERS')?.split(',') || ['localhost:9092'];
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID') || 'blockchain-service';
const groupId = 'blockchain-service-mpc-events';
this.logger.log(`[INIT] MPC Event Consumer for blockchain-service initializing...`);
this.logger.log(`[INIT] ClientId: ${clientId}`);
this.logger.log(`[INIT] GroupId: ${groupId}`);
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
this.logger.log(`[INIT] Topics to subscribe: ${Object.values(MPC_TOPICS).join(', ')}`);
this.kafka = new Kafka({
clientId,
brokers,
logLevel: logLevel.WARN,
retry: {
initialRetryTime: 100,
retries: 8,
},
});
this.consumer = this.kafka.consumer({
groupId,
sessionTimeout: 30000,
heartbeatInterval: 3000,
});
try {
this.logger.log(`[CONNECT] Connecting MPC Event consumer...`);
await this.consumer.connect();
this.isConnected = true;
this.logger.log(`[CONNECT] MPC Event Kafka consumer connected successfully`);
// Subscribe to MPC topics
await this.consumer.subscribe({ topics: Object.values(MPC_TOPICS), fromBeginning: false });
this.logger.log(`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`);
// Start consuming
await this.startConsuming();
} catch (error) {
this.logger.error(`[ERROR] Failed to connect MPC Event Kafka consumer`, error);
}
}
async onModuleDestroy() {
if (this.isConnected) {
await this.consumer.disconnect();
this.logger.log('MPC Event Kafka consumer disconnected');
}
}
/**
* Register handler for keygen completed events
*/
onKeygenCompleted(handler: MpcEventHandler<KeygenCompletedPayload>): void {
this.keygenCompletedHandler = handler;
this.logger.log(`[REGISTER] KeygenCompleted handler registered`);
}
/**
* Register handler for session failed events
*/
onSessionFailed(handler: MpcEventHandler<SessionFailedPayload>): void {
this.sessionFailedHandler = handler;
this.logger.log(`[REGISTER] SessionFailed handler registered`);
}
private async startConsuming(): Promise<void> {
await this.consumer.run({
eachMessage: async ({ topic, partition, message }: EachMessagePayload) => {
const offset = message.offset;
this.logger.log(`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`);
try {
const value = message.value?.toString();
if (!value) {
this.logger.warn(`[RECEIVE] Empty message received on ${topic}`);
return;
}
this.logger.log(`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`);
const parsed = JSON.parse(value);
const payload = parsed.payload || parsed;
this.logger.log(`[RECEIVE] Parsed event: eventType=${parsed.eventType || 'unknown'}`);
this.logger.log(`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`);
switch (topic) {
case MPC_TOPICS.KEYGEN_COMPLETED:
this.logger.log(`[HANDLE] Processing KeygenCompleted event for blockchain-service`);
this.logger.log(`[HANDLE] publicKey: ${(payload as KeygenCompletedPayload).publicKey?.substring(0, 20)}...`);
this.logger.log(`[HANDLE] extraPayload.userId: ${(payload as KeygenCompletedPayload).extraPayload?.userId}`);
if (this.keygenCompletedHandler) {
await this.keygenCompletedHandler(payload as KeygenCompletedPayload);
this.logger.log(`[HANDLE] KeygenCompleted handler completed successfully`);
} else {
this.logger.warn(`[HANDLE] No handler registered for KeygenCompleted`);
}
break;
case MPC_TOPICS.SESSION_FAILED:
this.logger.log(`[HANDLE] Processing SessionFailed event`);
this.logger.log(`[HANDLE] sessionType: ${(payload as SessionFailedPayload).sessionType}`);
this.logger.log(`[HANDLE] errorMessage: ${(payload as SessionFailedPayload).errorMessage}`);
if (this.sessionFailedHandler) {
await this.sessionFailedHandler(payload as SessionFailedPayload);
this.logger.log(`[HANDLE] SessionFailed handler completed`);
} else {
this.logger.warn(`[HANDLE] No handler registered for SessionFailed`);
}
break;
default:
this.logger.warn(`[RECEIVE] Unknown MPC topic: ${topic}`);
}
} catch (error) {
this.logger.error(`[ERROR] Error processing MPC event from ${topic}`, error);
}
},
});
this.logger.log(`[START] Started consuming MPC events for address derivation`);
}
}

View File

@ -6,7 +6,7 @@ import {
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
UpdateProfileCommand, SubmitKYCCommand, RemoveDeviceCommand, SendSmsCodeCommand,
GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery,
GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery, GetWalletStatusQuery,
} from '@/application/commands';
import {
AutoCreateAccountDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto,
@ -14,6 +14,7 @@ import {
BindWalletDto, SubmitKYCDto, RemoveDeviceDto,
AutoCreateAccountResponseDto, RecoverAccountResponseDto, LoginResponseDto,
UserProfileResponseDto, DeviceResponseDto,
WalletStatusReadyResponseDto, WalletStatusGeneratingResponseDto,
} from '@/api/dto';
@ApiTags('User')
@ -30,7 +31,6 @@ export class UserAccountController {
return this.userService.autoCreateAccount(
new AutoCreateAccountCommand(
dto.deviceId, dto.deviceName, dto.inviterReferralCode,
dto.provinceCode, dto.cityCode,
),
);
}
@ -166,4 +166,15 @@ export class UserAccountController {
async getByReferralCode(@Param('code') code: string) {
return this.userService.getUserByReferralCode(new GetUserByReferralCodeQuery(code));
}
@Get('wallet')
@ApiBearerAuth()
@ApiOperation({ summary: '获取我的钱包状态和地址' })
@ApiResponse({ status: 200, description: '钱包已就绪', type: WalletStatusReadyResponseDto })
@ApiResponse({ status: 202, description: '钱包生成中', type: WalletStatusGeneratingResponseDto })
async getWalletStatus(@CurrentUser() user: CurrentUserData) {
return this.userService.getWalletStatus(
new GetWalletStatusQuery(user.accountSequence),
);
}
}

View File

@ -122,31 +122,22 @@ export class RemoveDeviceDto {
// Response DTOs
export class AutoCreateAccountResponseDto {
@ApiProperty()
userId: string;
@ApiProperty({ example: 100001, description: '用户序列号 (唯一标识)' })
userSerialNum: number;
@ApiProperty({ description: '账户序列号 (唯一标识,用于推荐和分享)' })
accountSequence: number;
@ApiProperty({ description: '推荐码' })
@ApiProperty({ example: 'ABC123', description: '推荐码' })
referralCode: string;
@ApiPropertyOptional({ description: '助记词 (MPC模式下为空)' })
mnemonic?: string;
@ApiProperty({ example: '榴莲勇士_38472', description: '随机用户名' })
username: string;
@ApiPropertyOptional({ description: 'MPC客户端分片数据 (需安全存储,用于签名)' })
clientShareData?: string;
@ApiProperty({ example: '<svg>...</svg>', description: '随机SVG头像' })
avatarSvg: string;
@ApiPropertyOptional({ description: 'MPC公钥' })
publicKey?: string;
@ApiProperty({ description: '三链钱包地址 (BSC/KAVA/DST)' })
walletAddresses: { kava: string; dst: string; bsc: string };
@ApiProperty()
@ApiProperty({ description: '访问令牌' })
accessToken: string;
@ApiProperty()
@ApiProperty({ description: '刷新令牌' })
refreshToken: string;
}
@ -173,6 +164,36 @@ export class RecoverAccountResponseDto {
refreshToken: string;
}
// 钱包地址响应
export class WalletAddressesDto {
@ApiProperty({ example: '0x1234...', description: 'KAVA链地址' })
kava: string;
@ApiProperty({ example: 'dst1...', description: 'DST链地址' })
dst: string;
@ApiProperty({ example: '0x5678...', description: 'BSC链地址' })
bsc: string;
}
// 钱包状态响应 (就绪)
export class WalletStatusReadyResponseDto {
@ApiProperty({ example: 'ready', description: '钱包状态' })
status: 'ready';
@ApiProperty({ type: WalletAddressesDto, description: '三链钱包地址' })
walletAddresses: WalletAddressesDto;
@ApiProperty({ example: 'word1 word2 ... word12', description: '助记词 (12词)' })
mnemonic: string;
}
// 钱包状态响应 (生成中)
export class WalletStatusGeneratingResponseDto {
@ApiProperty({ example: 'generating', description: '钱包状态' })
status: 'generating';
}
export class LoginResponseDto {
@ApiProperty()
userId: string;

View File

@ -1,30 +1,39 @@
import { IsString, IsOptional, IsNotEmpty, Matches } from 'class-validator';
import { IsString, IsOptional, IsNotEmpty, Matches, ValidateNested } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class DeviceNameDto {
@ApiPropertyOptional({ example: 'iPhone 15 Pro', description: '设备型号' })
@IsOptional()
@IsString()
model?: string;
@ApiPropertyOptional({ example: 'ios', description: '平台: ios, android, web' })
@IsOptional()
@IsString()
platform?: string;
@ApiPropertyOptional({ example: 'iOS 17.2', description: '系统版本' })
@IsOptional()
@IsString()
osVersion?: string;
}
export class AutoCreateAccountDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000', description: '设备唯一标识' })
@IsString()
@IsNotEmpty()
deviceId: string;
@ApiPropertyOptional({ example: 'iPhone 15 Pro' })
@ApiPropertyOptional({ type: DeviceNameDto, description: '设备信息' })
@IsOptional()
@IsString()
deviceName?: string;
@ValidateNested()
@Type(() => DeviceNameDto)
deviceName?: DeviceNameDto;
@ApiPropertyOptional({ example: 'ABC123' })
@ApiPropertyOptional({ example: 'ABC123', description: '邀请人推荐码' })
@IsOptional()
@IsString()
@Matches(/^[A-Z0-9]{6}$/, { message: '推荐码格式错误' })
inviterReferralCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
provinceCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
cityCode?: string;
}

View File

@ -9,6 +9,7 @@ import { BindPhoneHandler } from './commands/bind-phone/bind-phone.handler';
import { GetMyProfileHandler } from './queries/get-my-profile/get-my-profile.handler';
import { GetMyDevicesHandler } from './queries/get-my-devices/get-my-devices.handler';
import { MpcKeygenCompletedHandler } from './event-handlers/mpc-keygen-completed.handler';
import { BlockchainWalletHandler } from './event-handlers/blockchain-wallet.handler';
import { DomainModule } from '@/domain/domain.module';
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
@ -26,6 +27,8 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
GetMyDevicesHandler,
// MPC Event Handlers
MpcKeygenCompletedHandler,
// Blockchain Event Handlers
BlockchainWalletHandler,
],
exports: [
UserApplicationService,

View File

@ -1,9 +1,9 @@
import { DeviceNameInput } from '../index';
export class AutoCreateAccountCommand {
constructor(
public readonly deviceId: string,
public readonly deviceName?: string,
public readonly deviceName?: DeviceNameInput,
public readonly inviterReferralCode?: string,
public readonly provinceCode?: string,
public readonly cityCode?: string,
) {}
}

View File

@ -2,13 +2,13 @@ import { Injectable, Inject, Logger } from '@nestjs/common';
import { AutoCreateAccountCommand } from './auto-create-account.command';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService } from '@/domain/services';
import { ReferralCode, AccountSequence, ProvinceCode, CityCode, ChainType } from '@/domain/value-objects';
import { AccountSequenceGeneratorService, UserValidatorService } from '@/domain/services';
import { ReferralCode, AccountSequence, ProvinceCode, CityCode, HardwareInfo } from '@/domain/value-objects';
import { TokenService } from '@/application/services/token.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
import { AutoCreateAccountResult } from '../index';
import { MpcShareStorageService } from '@/infrastructure/external/backup/mpc-share-storage.service';
import { generateRandomIdentity } from '@/shared/utils';
@Injectable()
export class AutoCreateAccountHandler {
@ -19,16 +19,18 @@ export class AutoCreateAccountHandler {
private readonly userRepository: UserAccountRepository,
private readonly sequenceGenerator: AccountSequenceGeneratorService,
private readonly validatorService: UserValidatorService,
private readonly walletGenerator: WalletGeneratorService,
private readonly tokenService: TokenService,
private readonly eventPublisher: EventPublisherService,
private readonly mpcShareStorage: MpcShareStorageService,
) {}
async execute(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> {
this.logger.log(`Creating account for device: ${command.deviceId}`);
// 1. 验证设备ID
const deviceCheck = await this.validatorService.checkDeviceNotRegistered(command.deviceId);
if (!deviceCheck.isValid) throw new ApplicationError(deviceCheck.errorMessage!);
// 2. 验证邀请码
let inviterSequence: AccountSequence | null = null;
if (command.inviterReferralCode) {
const referralCode = ReferralCode.create(command.inviterReferralCode);
@ -38,66 +40,62 @@ export class AutoCreateAccountHandler {
inviterSequence = inviter!.accountSequence;
}
// 3. 生成用户序列号
const accountSequence = await this.sequenceGenerator.generateNextUserSequence();
// 4. 生成随机用户名和头像
const identity = generateRandomIdentity();
// 5. 构建设备名称和硬件信息
let deviceNameStr = '未命名设备';
let hardwareInfo: HardwareInfo | undefined;
if (command.deviceName) {
const parts: string[] = [];
if (command.deviceName.model) parts.push(command.deviceName.model);
if (command.deviceName.platform) parts.push(command.deviceName.platform);
if (command.deviceName.osVersion) parts.push(command.deviceName.osVersion);
if (parts.length > 0) deviceNameStr = parts.join(' ');
hardwareInfo = {
platform: command.deviceName.platform,
deviceModel: command.deviceName.model,
osVersion: command.deviceName.osVersion,
};
}
// 6. 创建账户
const account = UserAccount.createAutomatic({
accountSequence,
initialDeviceId: command.deviceId,
deviceName: command.deviceName,
deviceName: deviceNameStr,
hardwareInfo,
inviterSequence,
province: ProvinceCode.create(command.provinceCode || 'DEFAULT'),
city: CityCode.create(command.cityCode || 'DEFAULT'),
province: ProvinceCode.create('DEFAULT'),
city: CityCode.create('DEFAULT'),
nickname: identity.username,
avatarSvg: identity.avatarSvg,
});
// 使用 MPC 2-of-3 生成三链钱包
this.logger.log(`Generating MPC wallet for user=${account.userId.toString()}`);
const mpcResult = await this.walletGenerator.generateMpcWalletSystem({
userId: account.userId.toString(),
username: accountSequence.value.toString(), // 使用账户序列号作为用户名
deviceId: command.deviceId,
});
// 将 MPC 钱包信息转换为领域实体
const wallets = this.walletGenerator.convertToWalletEntities(
account.userId,
mpcResult.wallets,
);
// 保存 delegate share 到备份服务 (用于恢复)
this.logger.log(`Storing delegate share for user=${account.userId.toString()}`);
await this.mpcShareStorage.storeBackupShare({
userId: account.userId.toString(),
shareData: mpcResult.delegateShare,
publicKey: mpcResult.publicKey,
});
account.bindMultipleWalletAddresses(wallets);
// 7. 保存账户
await this.userRepository.save(account);
await this.userRepository.saveWallets(account.userId, Array.from(wallets.values()));
// 8. 生成 Token
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId.toString(),
accountSequence: account.accountSequence.value,
deviceId: command.deviceId,
});
// 9. 发布领域事件
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
this.logger.log(`Account created successfully: userId=${account.userId.toString()}, seq=${account.accountSequence.value}`);
this.logger.log(`Account created: sequence=${accountSequence.value}, username=${identity.username}`);
return {
userId: account.userId.toString(),
accountSequence: account.accountSequence.value,
userSerialNum: account.accountSequence.value,
referralCode: account.referralCode.value,
mnemonic: '', // MPC 模式下不再使用助记词
delegateShare: mpcResult.delegateShare, // delegate share (客户端需安全存储)
publicKey: mpcResult.publicKey,
walletAddresses: {
kava: wallets.get(ChainType.KAVA)!.address,
dst: wallets.get(ChainType.DST)!.address,
bsc: wallets.get(ChainType.BSC)!.address,
},
username: account.nickname,
avatarSvg: account.avatarUrl || identity.avatarSvg,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};

View File

@ -1,11 +1,16 @@
// ============ Types ============
export interface DeviceNameInput {
model?: string; // iPhone 15 Pro, Pixel 8
platform?: string; // ios, android, web
osVersion?: string; // iOS 17.2, Android 14
}
// ============ Commands ============
export class AutoCreateAccountCommand {
constructor(
public readonly deviceId: string,
public readonly deviceName?: string,
public readonly deviceName?: DeviceNameInput,
public readonly inviterReferralCode?: string,
public readonly provinceCode?: string,
public readonly cityCode?: string,
) {}
}
@ -145,15 +150,30 @@ export class GenerateReferralLinkCommand {
) {}
}
export class GetWalletStatusQuery {
constructor(public readonly userSerialNum: number) {}
}
// ============ Results ============
// 钱包状态
export type WalletStatus = 'generating' | 'ready' | 'failed';
export interface WalletStatusResult {
status: WalletStatus;
walletAddresses?: {
kava: string;
dst: string;
bsc: string;
};
mnemonic?: string; // 助记词 (ready 状态时返回)
errorMessage?: string; // 失败原因 (failed 状态时返回)
}
export interface AutoCreateAccountResult {
userId: string;
accountSequence: number;
referralCode: string;
mnemonic: string; // 兼容字段MPC模式下为空
delegateShare?: string; // MPC delegate share (客户端需安全存储)
publicKey?: string; // MPC 公钥
walletAddresses: { kava: string; dst: string; bsc: string };
userSerialNum: number; // 用户序列号
referralCode: string; // 推荐码
username: string; // 随机用户名
avatarSvg: string; // 随机SVG头像
accessToken: string;
refreshToken: string;
}

View File

@ -0,0 +1,144 @@
/**
* Blockchain Wallet Event Handler
*
* Handles wallet address events from blockchain-service:
* - WalletAddressCreated: Saves derived wallet addresses to user account
*
* This handler receives properly derived addresses from blockchain-service:
* - KAVA: Cosmos bech32 format (kava1...)
* - DST: Cosmos bech32 format (dst1...)
* - BSC: EVM format (0x...)
*/
import { Injectable, Inject, Logger, OnModuleInit } from '@nestjs/common';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { ChainType, UserId } from '@/domain/value-objects';
import { RedisService } from '@/infrastructure/redis/redis.service';
import {
BlockchainEventConsumerService,
WalletAddressCreatedPayload,
} from '@/infrastructure/kafka/blockchain-event-consumer.service';
// Redis key prefix for keygen status
const KEYGEN_STATUS_PREFIX = 'keygen:status:';
const KEYGEN_STATUS_TTL = 60 * 60 * 24; // 24 hours
// Status data for wallet completion (extended from MpcKeygenCompletedHandler)
interface WalletCompletedStatusData {
status: 'completed';
userId: string;
publicKey?: string;
walletAddresses?: { chainType: string; address: string }[];
updatedAt: string;
}
@Injectable()
export class BlockchainWalletHandler implements OnModuleInit {
private readonly logger = new Logger(BlockchainWalletHandler.name);
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: UserAccountRepository,
private readonly redisService: RedisService,
private readonly blockchainEventConsumer: BlockchainEventConsumerService,
) {}
async onModuleInit() {
// Register event handler
this.blockchainEventConsumer.onWalletAddressCreated(this.handleWalletAddressCreated.bind(this));
this.logger.log('[INIT] Registered BlockchainWalletHandler for WalletAddressCreated events');
}
/**
* Handle WalletAddressCreated event from blockchain-service
*
* This event contains properly derived addresses:
* - KAVA: kava1... (Cosmos bech32)
* - DST: dst1... (Cosmos bech32)
* - BSC: 0x... (EVM)
*/
private async handleWalletAddressCreated(payload: WalletAddressCreatedPayload): Promise<void> {
const { userId, publicKey, addresses } = 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)}`);
if (!userId) {
this.logger.error('[ERROR] WalletAddressCreated event missing userId, skipping');
return;
}
if (!addresses || addresses.length === 0) {
this.logger.error('[ERROR] WalletAddressCreated event missing addresses, skipping');
return;
}
try {
// 1. Find user account
const account = await this.userRepository.findById(UserId.create(userId));
if (!account) {
this.logger.error(`[ERROR] User not found: ${userId}`);
return;
}
// 2. Create wallet addresses for each chain
const wallets: WalletAddress[] = addresses.map((addr) => {
const chainType = this.parseChainType(addr.chainType);
this.logger.log(`[WALLET] Creating wallet: ${addr.chainType} -> ${addr.address}`);
return WalletAddress.create({
userId: account.userId,
chainType,
address: addr.address,
});
});
// 3. Save wallet addresses to user account
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
const statusData: WalletCompletedStatusData = {
status: 'completed',
userId,
publicKey,
walletAddresses: addresses,
updatedAt: new Date().toISOString(),
};
await this.redisService.set(
`${KEYGEN_STATUS_PREFIX}${userId}`,
JSON.stringify(statusData),
KEYGEN_STATUS_TTL,
);
this.logger.log(`[STATUS] Keygen status updated to 'completed' for user: ${userId}`);
// Log all addresses
addresses.forEach((addr) => {
this.logger.log(`[COMPLETE] ${addr.chainType}: ${addr.address}`);
});
} catch (error) {
this.logger.error(`[ERROR] Failed to process WalletAddressCreated: ${error}`, error);
}
}
/**
* Parse chain type string to ChainType value object
*/
private parseChainType(chainType: string): ChainType {
const normalizedType = chainType.toUpperCase();
switch (normalizedType) {
case 'KAVA':
return ChainType.KAVA;
case 'DST':
return ChainType.DST;
case 'BSC':
return ChainType.BSC;
default:
this.logger.warn(`[WARN] Unknown chain type: ${chainType}, defaulting to BSC`);
return ChainType.BSC;
}
}
}

View File

@ -1 +1,2 @@
export * from './mpc-keygen-completed.handler';
export * from './blockchain-wallet.handler';

View File

@ -2,16 +2,17 @@
* MPC Keygen Event Handler
*
* Handles keygen events from mpc-service:
* - KeygenStarted: Updates status in Redis
* - KeygenCompleted: Derives wallet addresses and saves to user account
* - SessionFailed: Logs error and updates status
* - KeygenStarted: Updates status in Redis to "generating"
* - KeygenCompleted: Updates status to indicate waiting for blockchain-service
* - SessionFailed: Logs error and updates status to "failed"
*
* NOTE: Address derivation is now handled by blockchain-service.
* This handler only manages status updates. The actual wallet addresses
* are saved by BlockchainWalletHandler when it receives WalletAddressCreated
* events from blockchain-service.
*/
import { Injectable, Inject, Logger, OnModuleInit } from '@nestjs/common';
import { keccak256 } from 'ethers';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { ChainType, UserId } from '@/domain/value-objects';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { RedisService } from '@/infrastructure/redis/redis.service';
import {
MpcEventConsumerService,
@ -24,14 +25,13 @@ import {
const KEYGEN_STATUS_PREFIX = 'keygen:status:';
const KEYGEN_STATUS_TTL = 60 * 60 * 24; // 24 hours
export type KeygenStatus = 'pending' | 'generating' | 'completed' | 'failed';
export type KeygenStatus = 'pending' | 'generating' | 'deriving' | 'completed' | 'failed';
export interface KeygenStatusData {
status: KeygenStatus;
userId: string;
mpcSessionId?: string;
publicKey?: string;
walletAddress?: string;
errorMessage?: string;
updatedAt: string;
}
@ -41,28 +41,26 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
private readonly logger = new Logger(MpcKeygenCompletedHandler.name);
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: UserAccountRepository,
private readonly redisService: RedisService,
private readonly mpcEventConsumer: MpcEventConsumerService,
) {}
async onModuleInit() {
// 注册事件处理器
// Register event handlers
this.mpcEventConsumer.onKeygenStarted(this.handleKeygenStarted.bind(this));
this.mpcEventConsumer.onKeygenCompleted(this.handleKeygenCompleted.bind(this));
this.mpcEventConsumer.onSessionFailed(this.handleSessionFailed.bind(this));
this.logger.log('Registered MPC event handlers');
this.logger.log('[INIT] Registered MPC event handlers (status updates only)');
}
/**
* keygen
* Handle keygen started event
*
* Redis "generating"
* Update Redis status to "generating"
*/
private async handleKeygenStarted(payload: KeygenStartedPayload): Promise<void> {
const { userId, mpcSessionId } = payload;
this.logger.log(`Keygen started: userId=${userId}, mpcSessionId=${mpcSessionId}`);
this.logger.log(`[STATUS] Keygen started: userId=${userId}, mpcSessionId=${mpcSessionId}`);
try {
const statusData: KeygenStatusData = {
@ -78,60 +76,38 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
KEYGEN_STATUS_TTL,
);
this.logger.log(`Keygen status updated to 'generating' for user: ${userId}`);
this.logger.log(`[STATUS] Keygen status updated to 'generating' for user: ${userId}`);
} catch (error) {
this.logger.error(`Failed to update keygen status: ${error}`, error);
this.logger.error(`[ERROR] Failed to update keygen status: ${error}`, error);
}
}
/**
* keygen
* Handle keygen completed event
*
* mpc-service :
* 1.
* 2.
* 3.
* 4. Redis completed
* From mpc-service, keygen is complete with public key.
* Update status to "deriving" - blockchain-service will now derive addresses
* and send WalletAddressCreated event which BlockchainWalletHandler will process.
*/
private async handleKeygenCompleted(payload: KeygenCompletedPayload): Promise<void> {
const { publicKey, extraPayload } = payload;
if (!extraPayload?.userId) {
this.logger.warn('KeygenCompleted event missing userId, skipping');
this.logger.warn('[WARN] KeygenCompleted event missing userId, skipping');
return;
}
const { userId, username } = extraPayload;
this.logger.log(`Processing keygen completed: userId=${userId}, username=${username}`);
this.logger.log(`[STATUS] Keygen completed: userId=${userId}, username=${username}`);
this.logger.log(`[STATUS] Public key: ${publicKey?.substring(0, 30)}...`);
this.logger.log(`[STATUS] Waiting for blockchain-service to derive addresses...`);
try {
// 1. 查找用户账户
const account = await this.userRepository.findById(UserId.create(userId));
if (!account) {
this.logger.error(`User not found: ${userId}`);
return;
}
// 2. 从公钥派生以太坊地址 (各链通用 EVM 地址)
const walletAddress = this.deriveAddressFromPublicKey(publicKey);
this.logger.log(`Derived wallet address: ${walletAddress}`);
// 3. 创建三条链的钱包地址
const wallets: WalletAddress[] = [
WalletAddress.create({ userId: account.userId, chainType: ChainType.KAVA, address: walletAddress }),
WalletAddress.create({ userId: account.userId, chainType: ChainType.DST, address: walletAddress }),
WalletAddress.create({ userId: account.userId, chainType: ChainType.BSC, address: walletAddress }),
];
// 4. 保存钱包地址到用户账户
await this.userRepository.saveWallets(account.userId, wallets);
// 5. 更新 Redis 状态为 completed
// Update status to "deriving" - waiting for blockchain-service
const statusData: KeygenStatusData = {
status: 'completed',
status: 'deriving',
userId,
publicKey,
walletAddress,
updatedAt: new Date().toISOString(),
};
@ -141,32 +117,33 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
KEYGEN_STATUS_TTL,
);
this.logger.log(`Wallet addresses saved for user: ${userId}, address: ${walletAddress}`);
this.logger.log(`[STATUS] Keygen status updated to 'deriving' for user: ${userId}`);
this.logger.log(`[STATUS] blockchain-service will derive addresses and send WalletAddressCreated event`);
} catch (error) {
this.logger.error(`Failed to process keygen completed: ${error}`, error);
this.logger.error(`[ERROR] Failed to update keygen status: ${error}`, error);
}
}
/**
* session
* Handle session failed event
*
* keygen :
* 1.
* 2. Redis failed
* When keygen fails:
* 1. Log error
* 2. Update Redis status to "failed"
*/
private async handleSessionFailed(payload: SessionFailedPayload): Promise<void> {
const { sessionType, errorMessage, extraPayload } = payload;
// 只处理 keygen 失败
// Only handle keygen failures
if (sessionType !== 'keygen' && sessionType !== 'KEYGEN') {
return;
}
const userId = extraPayload?.userId || 'unknown';
this.logger.error(`Keygen failed for user ${userId}: ${errorMessage}`);
this.logger.error(`[ERROR] Keygen failed for user ${userId}: ${errorMessage}`);
try {
// 更新 Redis 状态为 failed
// Update Redis status to failed
const statusData: KeygenStatusData = {
status: 'failed',
userId,
@ -180,86 +157,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
KEYGEN_STATUS_TTL,
);
this.logger.log(`Keygen status updated to 'failed' for user: ${userId}`);
this.logger.log(`[STATUS] Keygen status updated to 'failed' for user: ${userId}`);
} catch (error) {
this.logger.error(`Failed to update keygen failed status: ${error}`, error);
this.logger.error(`[ERROR] Failed to update keygen failed status: ${error}`, error);
}
}
/**
*
*
* @param compressedPubKey 33 (hex string)
* @returns (0x...)
*/
private deriveAddressFromPublicKey(compressedPubKey: string): string {
// 移除 0x 前缀(如果有)
const pubKeyHex = compressedPubKey.startsWith('0x')
? compressedPubKey.slice(2)
: compressedPubKey;
// 如果是压缩公钥 (33 bytes = 66 hex chars),需要解压
let uncompressedPubKey: string;
if (pubKeyHex.length === 66) {
// 压缩公钥,需要解压
uncompressedPubKey = this.decompressPublicKey(pubKeyHex);
} else if (pubKeyHex.length === 128 || pubKeyHex.length === 130) {
// 未压缩公钥 (带或不带 04 前缀)
uncompressedPubKey = pubKeyHex.length === 130 ? pubKeyHex.slice(2) : pubKeyHex;
} else {
throw new Error(`Invalid public key length: ${pubKeyHex.length}`);
}
// 对未压缩公钥进行 keccak256 哈希
const hash = keccak256('0x' + uncompressedPubKey);
// 取最后 20 字节作为地址
return '0x' + hash.slice(-40);
}
/**
* secp256k1
*/
private decompressPublicKey(compressedHex: string): string {
const prefix = parseInt(compressedHex.slice(0, 2), 16);
const xHex = compressedHex.slice(2);
const x = BigInt('0x' + xHex);
// secp256k1 curve parameters
const p = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F');
const a = BigInt(0);
const b = BigInt(7);
// Calculate y^2 = x^3 + ax + b (mod p)
const ySquared = (x ** 3n + a * x + b) % p;
// Calculate modular square root
const y = this.modPow(ySquared, (p + 1n) / 4n, p);
// Choose correct y based on prefix (02 = even, 03 = odd)
const isEven = y % 2n === 0n;
const needEven = prefix === 0x02;
const finalY = isEven === needEven ? y : p - y;
// Format as 64-char hex strings
const xStr = x.toString(16).padStart(64, '0');
const yStr = finalY.toString(16).padStart(64, '0');
return xStr + yStr;
}
/**
* Modular exponentiation
*/
private modPow(base: bigint, exp: bigint, mod: bigint): bigint {
let result = 1n;
base = base % mod;
while (exp > 0n) {
if (exp % 2n === 1n) {
result = (result * base) % mod;
}
exp = exp / 2n;
base = (base * base) % mod;
}
return result;
}
}

View File

@ -8,7 +8,7 @@ import {
} from '@/domain/services';
import {
UserId, PhoneNumber, ReferralCode, AccountSequence, ProvinceCode, CityCode,
ChainType, Mnemonic, KYCInfo,
ChainType, Mnemonic, KYCInfo, HardwareInfo,
} from '@/domain/value-objects';
import { TokenService } from './token.service';
import { RedisService } from '@/infrastructure/redis/redis.service';
@ -17,12 +17,14 @@ import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.se
import { MpcWalletService } from '@/infrastructure/external/mpc';
import { BackupClientService } from '@/infrastructure/external/backup';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
import { generateRandomIdentity } from '@/shared/utils';
import {
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
UpdateProfileCommand, SubmitKYCCommand, ReviewKYCCommand, RemoveDeviceCommand,
SendSmsCodeCommand, GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery,
ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand,
GetWalletStatusQuery, WalletStatusResult,
AutoCreateAccountResult, RecoverAccountResult, AutoLoginResult, RegisterResult,
LoginResult, UserProfileDTO, DeviceDTO, UserBriefDTO,
ReferralCodeValidationResult, ReferralLinkResult, ReferralStatsResult, MeResult,
@ -51,13 +53,16 @@ export class UserApplicationService {
/**
* (APP)
*
* 使 MPC 2-of-3 :
* - (BSC/KAVA/DST)
* - MPC
* -
* :
* -
* -
* -
* - token
*
* 注意: MPC钱包地址生成移到后台异步处理
*/
async autoCreateAccount(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> {
this.logger.log(`Creating account with MPC 2-of-3 for device: ${command.deviceId}`);
this.logger.log(`Creating account for device: ${command.deviceId}`);
// 1. 验证设备ID (检查设备是否已创建过账户)
const deviceValidation = await this.validatorService.checkDeviceNotRegistered(command.deviceId);
@ -76,83 +81,71 @@ export class UserApplicationService {
// 3. 生成用户序列号
const accountSequence = await this.sequenceGenerator.generateNextUserSequence();
// 4. 创建用户账户
// 4. 生成随机用户名和头像
const identity = generateRandomIdentity();
// 5. 构建设备名称字符串和硬件信息
let deviceNameStr = '未命名设备';
let hardwareInfo: HardwareInfo | undefined;
if (command.deviceName) {
const parts: string[] = [];
if (command.deviceName.model) parts.push(command.deviceName.model);
if (command.deviceName.platform) parts.push(command.deviceName.platform);
if (command.deviceName.osVersion) parts.push(command.deviceName.osVersion);
if (parts.length > 0) deviceNameStr = parts.join(' ');
hardwareInfo = {
platform: command.deviceName.platform,
deviceModel: command.deviceName.model,
osVersion: command.deviceName.osVersion,
};
}
// 6. 创建用户账户
const account = UserAccount.createAutomatic({
accountSequence,
initialDeviceId: command.deviceId,
deviceName: command.deviceName,
deviceName: deviceNameStr,
hardwareInfo,
inviterSequence,
province: ProvinceCode.create(command.provinceCode || 'DEFAULT'),
city: CityCode.create(command.cityCode || 'DEFAULT'),
province: ProvinceCode.create('DEFAULT'),
city: CityCode.create('DEFAULT'),
nickname: identity.username,
avatarSvg: identity.avatarSvg,
});
// 5. 使用 MPC 2-of-3 生成三链钱包地址
this.logger.log(`Generating MPC wallet for account sequence: ${accountSequence.value}`);
const mpcResult = await this.mpcWalletService.generateMpcWallet({
userId: account.userId.toString(),
username: accountSequence.value.toString(), // 使用账户序列号作为用户名
deviceId: command.deviceId,
});
// 6. 创建钱包地址实体 (包含 MPC 签名)
const wallets = new Map<ChainType, WalletAddress>();
for (const walletInfo of mpcResult.wallets) {
const chainType = walletInfo.chainType as ChainType;
const wallet = WalletAddress.createMpc({
userId: account.userId,
chainType,
address: walletInfo.address,
publicKey: walletInfo.publicKey,
addressDigest: walletInfo.addressDigest,
signature: walletInfo.signature,
});
wallets.set(chainType, wallet);
}
// 7. 绑定钱包地址到账户
account.bindMultipleWalletAddresses(wallets);
// 8. 保存账户和钱包
// 7. 保存账户
await this.userRepository.save(account);
await this.userRepository.saveWallets(account.userId, Array.from(wallets.values()));
// 9. 保存 delegate share 到 backup-service (用于恢复)
// 注意: delegate share 由 mpc-service 代理生成,用户设备也应安全存储一份
if (mpcResult.delegateShare) {
await this.backupClient.storeBackupShare({
userId: account.userId.toString(),
accountSequence: account.accountSequence.value,
publicKey: mpcResult.publicKey,
encryptedShareData: mpcResult.delegateShare,
});
this.logger.log(`Delegate share sent to backup-service for user: ${account.userId.toString()}`);
}
// 10. 生成 Token
// 8. 生成 Token
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId.toString(),
accountSequence: account.accountSequence.value,
deviceId: command.deviceId,
});
// 11. 发布领域事件
// 9. 发布领域事件 (包含 UserAccountAutoCreated)
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
this.logger.log(`Account created successfully: sequence=${accountSequence.value}, publicKey=${mpcResult.publicKey}`);
// 10. 发布 MPC Keygen 请求事件 (触发后台生成钱包)
const { MpcKeygenRequestedEvent } = await import('@/domain/events');
const sessionId = crypto.randomUUID();
await this.eventPublisher.publish(new MpcKeygenRequestedEvent({
sessionId,
userId: account.userId.toString(),
username: `user_${account.accountSequence.value}`, // 用于 mpc-system 标识
threshold: 2,
totalParties: 3,
requireDelegate: true,
}));
this.logger.log(`Account created: sequence=${accountSequence.value}, username=${identity.username}, MPC keygen requested`);
return {
userId: account.userId.toString(),
accountSequence: account.accountSequence.value,
userSerialNum: account.accountSequence.value,
referralCode: account.referralCode.value,
mnemonic: '', // MPC 模式不使用助记词
delegateShare: mpcResult.delegateShare, // delegate share (客户端需安全存储)
publicKey: mpcResult.publicKey,
walletAddresses: {
kava: wallets.get(ChainType.KAVA)!.address,
dst: wallets.get(ChainType.DST)!.address,
bsc: wallets.get(ChainType.BSC)!.address,
},
username: account.nickname,
avatarSvg: account.avatarUrl || identity.avatarSvg,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
@ -645,4 +638,47 @@ export class UserApplicationService {
}
return result;
}
// ============ 钱包状态查询 ============
/**
* (GET /user/{userSerialNum}/wallet)
*
* Kafka
*/
async getWalletStatus(query: GetWalletStatusQuery): Promise<WalletStatusResult> {
const accountSequence = AccountSequence.create(query.userSerialNum);
const account = await this.userRepository.findByAccountSequence(accountSequence);
if (!account) {
throw new ApplicationError('用户不存在');
}
// 获取所有钱包地址
const wallets = account.getAllWalletAddresses();
// 检查是否已有三条链的钱包地址
const kavaWallet = wallets.find(w => w.chainType === ChainType.KAVA);
const dstWallet = wallets.find(w => w.chainType === ChainType.DST);
const bscWallet = wallets.find(w => w.chainType === ChainType.BSC);
if (kavaWallet && dstWallet && bscWallet) {
// 钱包已就绪
// 注意: MPC 模式下没有助记词,返回空字符串
return {
status: 'ready',
walletAddresses: {
kava: kavaWallet.address,
dst: dstWallet.address,
bsc: bscWallet.address,
},
mnemonic: '', // MPC模式无助记词
};
}
// 钱包还在生成中
return {
status: 'generating',
};
}
}

View File

@ -1,7 +1,7 @@
import { DomainError } from '@/shared/exceptions/domain.exception';
import {
UserId, AccountSequence, PhoneNumber, ReferralCode, ProvinceCode, CityCode,
DeviceInfo, ChainType, KYCInfo, KYCStatus, AccountStatus,
DeviceInfo, HardwareInfo, ChainType, KYCInfo, KYCStatus, AccountStatus,
} from '@/domain/value-objects';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import {
@ -87,19 +87,26 @@ export class UserAccount {
accountSequence: AccountSequence;
initialDeviceId: string;
deviceName?: string;
hardwareInfo?: HardwareInfo;
inviterSequence: AccountSequence | null;
province: ProvinceCode;
city: CityCode;
nickname?: string;
avatarSvg?: string;
}): UserAccount {
const devices = new Map<string, DeviceInfo>();
devices.set(params.initialDeviceId, new DeviceInfo(
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
params.hardwareInfo,
));
// UserID将由数据库自动生成(autoincrement)这里使用临时值0
const nickname = params.nickname || `用户${params.accountSequence.value}`;
const avatarUrl = params.avatarSvg || null;
const account = new UserAccount(
UserId.create(0), params.accountSequence, devices, null,
`用户${params.accountSequence.value}`, null, params.inviterSequence,
nickname, avatarUrl, params.inviterSequence,
ReferralCode.generate(), params.province, params.city, null,
new Map(), null, KYCStatus.NOT_VERIFIED, AccountStatus.ACTIVE,
new Date(), null, new Date(),
@ -123,6 +130,7 @@ export class UserAccount {
phoneNumber: PhoneNumber;
initialDeviceId: string;
deviceName?: string;
hardwareInfo?: HardwareInfo;
inviterSequence: AccountSequence | null;
province: ProvinceCode;
city: CityCode;
@ -130,6 +138,7 @@ export class UserAccount {
const devices = new Map<string, DeviceInfo>();
devices.set(params.initialDeviceId, new DeviceInfo(
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
params.hardwareInfo,
));
// UserID将由数据库自动生成(autoincrement)这里使用临时值0
@ -192,15 +201,21 @@ export class UserAccount {
);
}
addDevice(deviceId: string, deviceName?: string): void {
addDevice(deviceId: string, deviceName?: string, hardwareInfo?: HardwareInfo): void {
this.ensureActive();
if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
throw new DomainError('最多允许5个设备同时登录');
}
if (this._devices.has(deviceId)) {
this._devices.get(deviceId)!.updateActivity();
const device = this._devices.get(deviceId)!;
device.updateActivity();
if (hardwareInfo) {
device.updateHardwareInfo(hardwareInfo);
}
} else {
this._devices.set(deviceId, new DeviceInfo(deviceId, deviceName || '未命名设备', new Date(), new Date()));
this._devices.set(deviceId, new DeviceInfo(
deviceId, deviceName || '未命名设备', new Date(), new Date(), hardwareInfo,
));
this.addDomainEvent(new DeviceAddedEvent({
userId: this.userId.toString(),
accountSequence: this.accountSequence.value,

View File

@ -144,26 +144,65 @@ export class Mnemonic {
}
}
// ============ HardwareInfo ============
export interface HardwareInfo {
platform?: string; // ios, android, web
deviceModel?: string; // iPhone 15 Pro, Pixel 8
osVersion?: string; // iOS 17.2, Android 14
appVersion?: string; // 1.0.0
screenWidth?: number;
screenHeight?: number;
locale?: string; // zh-CN, en-US
timezone?: string; // Asia/Shanghai
}
// ============ DeviceInfo ============
export class DeviceInfo {
private _lastActiveAt: Date;
private _hardwareInfo: HardwareInfo;
constructor(
public readonly deviceId: string,
public readonly deviceName: string,
public readonly addedAt: Date,
lastActiveAt: Date,
hardwareInfo?: HardwareInfo,
) {
this._lastActiveAt = lastActiveAt;
this._hardwareInfo = hardwareInfo || {};
}
get lastActiveAt(): Date {
return this._lastActiveAt;
}
get hardwareInfo(): HardwareInfo {
return this._hardwareInfo;
}
get platform(): string | undefined {
return this._hardwareInfo.platform;
}
get deviceModel(): string | undefined {
return this._hardwareInfo.deviceModel;
}
get osVersion(): string | undefined {
return this._hardwareInfo.osVersion;
}
get appVersion(): string | undefined {
return this._hardwareInfo.appVersion;
}
updateActivity(): void {
this._lastActiveAt = new Date();
}
updateHardwareInfo(info: HardwareInfo): void {
this._hardwareInfo = { ...this._hardwareInfo, ...info };
}
}
// ============ ChainType ============

View File

@ -0,0 +1,144 @@
/**
* Blockchain Event Consumer Service
*
* Consumes wallet address creation events from blockchain-service via Kafka.
* Updates user wallet addresses when blockchain-service derives addresses from MPC public keys.
*/
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
// Blockchain Event Topics (events from blockchain-service)
export const BLOCKCHAIN_TOPICS = {
WALLET_ADDRESS_CREATED: 'blockchain.wallets',
} as const;
export interface WalletAddressCreatedPayload {
userId: string;
publicKey: string;
addresses: {
chainType: string;
address: string;
}[];
}
export type BlockchainEventHandler<T> = (payload: T) => Promise<void>;
@Injectable()
export class BlockchainEventConsumerService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(BlockchainEventConsumerService.name);
private kafka: Kafka;
private consumer: Consumer;
private isConnected = false;
private walletAddressCreatedHandler?: BlockchainEventHandler<WalletAddressCreatedPayload>;
constructor(private readonly configService: ConfigService) {}
async onModuleInit() {
const brokers = this.configService.get<string>('KAFKA_BROKERS')?.split(',') || ['localhost:9092'];
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID') || 'identity-service';
const groupId = 'identity-service-blockchain-events';
this.logger.log(`[INIT] Blockchain Event Consumer initializing...`);
this.logger.log(`[INIT] ClientId: ${clientId}`);
this.logger.log(`[INIT] GroupId: ${groupId}`);
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
this.logger.log(`[INIT] Topics to subscribe: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`);
this.kafka = new Kafka({
clientId,
brokers,
logLevel: logLevel.WARN,
retry: {
initialRetryTime: 100,
retries: 8,
},
});
this.consumer = this.kafka.consumer({
groupId,
sessionTimeout: 30000,
heartbeatInterval: 3000,
});
try {
this.logger.log(`[CONNECT] Connecting Blockchain Event consumer...`);
await this.consumer.connect();
this.isConnected = true;
this.logger.log(`[CONNECT] Blockchain Event Kafka consumer connected successfully`);
// Subscribe to blockchain topics
await this.consumer.subscribe({ topics: Object.values(BLOCKCHAIN_TOPICS), fromBeginning: false });
this.logger.log(`[SUBSCRIBE] Subscribed to blockchain topics: ${Object.values(BLOCKCHAIN_TOPICS).join(', ')}`);
// Start consuming
await this.startConsuming();
} catch (error) {
this.logger.error(`[ERROR] Failed to connect Blockchain Event Kafka consumer`, error);
}
}
async onModuleDestroy() {
if (this.isConnected) {
await this.consumer.disconnect();
this.logger.log('Blockchain Event Kafka consumer disconnected');
}
}
/**
* Register handler for wallet address created events
*/
onWalletAddressCreated(handler: BlockchainEventHandler<WalletAddressCreatedPayload>): void {
this.walletAddressCreatedHandler = handler;
this.logger.log(`[REGISTER] WalletAddressCreated handler registered`);
}
private async startConsuming(): Promise<void> {
await this.consumer.run({
eachMessage: async ({ topic, partition, message }: EachMessagePayload) => {
const offset = message.offset;
this.logger.log(`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`);
try {
const value = message.value?.toString();
if (!value) {
this.logger.warn(`[RECEIVE] Empty message received on ${topic}`);
return;
}
this.logger.log(`[RECEIVE] Raw message value: ${value.substring(0, 500)}...`);
const parsed = JSON.parse(value);
const payload = parsed.payload || parsed;
const eventType = parsed.eventType || 'unknown';
this.logger.log(`[RECEIVE] Parsed event: eventType=${eventType}`);
this.logger.log(`[RECEIVE] Payload keys: ${Object.keys(payload).join(', ')}`);
// Handle WalletAddressCreated events
if (eventType === 'blockchain.wallet.address.created' || topic === BLOCKCHAIN_TOPICS.WALLET_ADDRESS_CREATED) {
this.logger.log(`[HANDLE] Processing WalletAddressCreated event`);
this.logger.log(`[HANDLE] userId: ${payload.userId}`);
this.logger.log(`[HANDLE] publicKey: ${payload.publicKey?.substring(0, 30)}...`);
this.logger.log(`[HANDLE] addresses count: ${payload.addresses?.length}`);
if (this.walletAddressCreatedHandler) {
await this.walletAddressCreatedHandler(payload as WalletAddressCreatedPayload);
this.logger.log(`[HANDLE] WalletAddressCreated handler completed successfully`);
} else {
this.logger.warn(`[HANDLE] No handler registered for WalletAddressCreated`);
}
} else {
this.logger.warn(`[RECEIVE] Unknown event type: ${eventType}`);
}
} catch (error) {
this.logger.error(`[ERROR] Error processing blockchain event from ${topic}`, error);
}
},
});
this.logger.log(`[START] Started consuming blockchain events`);
}
}

View File

@ -4,3 +4,4 @@ export * from './event-consumer.controller';
export * from './dead-letter.service';
export * from './event-retry.service';
export * from './mpc-event-consumer.service';
export * from './blockchain-event-consumer.service';

View File

@ -1,15 +1,18 @@
import { Module } from '@nestjs/common';
import { EventPublisherService } from './event-publisher.service';
import { MpcEventConsumerService } from './mpc-event-consumer.service';
import { BlockchainEventConsumerService } from './blockchain-event-consumer.service';
@Module({
providers: [
EventPublisherService,
MpcEventConsumerService,
BlockchainEventConsumerService,
],
exports: [
EventPublisherService,
MpcEventConsumerService,
BlockchainEventConsumerService,
],
})
export class KafkaModule {}

View File

@ -29,6 +29,16 @@ export interface UserDeviceEntity {
userId: bigint;
deviceId: string;
deviceName: string | null;
// Hardware Info
platform: string | null;
deviceModel: string | null;
osVersion: string | null;
appVersion: string | null;
screenWidth: number | null;
screenHeight: number | null;
locale: string | null;
timezone: string | null;
// Timestamps
addedAt: Date;
lastActiveAt: Date;
}

View File

@ -1,16 +1,32 @@
import { Injectable } from '@nestjs/common';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { DeviceInfo, KYCInfo, KYCStatus, AccountStatus, ChainType, AddressStatus } from '@/domain/value-objects';
import { DeviceInfo, HardwareInfo, KYCInfo, KYCStatus, AccountStatus, ChainType, AddressStatus } from '@/domain/value-objects';
import { UserAccountEntity } from '../entities/user-account.entity';
import { toMpcSignatureString } from '../entities/wallet-address.entity';
@Injectable()
export class UserAccountMapper {
toDomain(entity: UserAccountEntity): UserAccount {
const devices = (entity.devices || []).map(
(d) => new DeviceInfo(d.deviceId, d.deviceName || '未命名设备', d.addedAt, d.lastActiveAt),
);
const devices = (entity.devices || []).map((d) => {
const hardwareInfo: HardwareInfo = {
platform: d.platform || undefined,
deviceModel: d.deviceModel || undefined,
osVersion: d.osVersion || undefined,
appVersion: d.appVersion || undefined,
screenWidth: d.screenWidth || undefined,
screenHeight: d.screenHeight || undefined,
locale: d.locale || undefined,
timezone: d.timezone || undefined,
};
return new DeviceInfo(
d.deviceId,
d.deviceName || '未命名设备',
d.addedAt,
d.lastActiveAt,
hardwareInfo,
);
});
const wallets = (entity.walletAddresses || []).map((w) =>
WalletAddress.reconstruct({

View File

@ -7,7 +7,7 @@ import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggre
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import {
UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType,
AccountStatus, KYCStatus, DeviceInfo, KYCInfo, AddressStatus,
AccountStatus, KYCStatus, DeviceInfo, HardwareInfo, KYCInfo, AddressStatus,
} from '@/domain/value-objects';
import { toMpcSignatureString, fromMpcSignatureString } from '../entities/wallet-address.entity';
@ -79,6 +79,14 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
userId: savedUserId,
deviceId: d.deviceId,
deviceName: d.deviceName,
platform: d.hardwareInfo.platform || null,
deviceModel: d.hardwareInfo.deviceModel || null,
osVersion: d.hardwareInfo.osVersion || null,
appVersion: d.hardwareInfo.appVersion || null,
screenWidth: d.hardwareInfo.screenWidth || null,
screenHeight: d.hardwareInfo.screenHeight || null,
locale: d.hardwareInfo.locale || null,
timezone: d.hardwareInfo.timezone || null,
addedAt: d.addedAt,
lastActiveAt: d.lastActiveAt,
})),
@ -205,9 +213,25 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
}
private toDomain(data: any): UserAccount {
const devices = data.devices.map(
(d: any) => new DeviceInfo(d.deviceId, d.deviceName || '未命名设备', d.addedAt, d.lastActiveAt),
);
const devices = data.devices.map((d: any) => {
const hardwareInfo: HardwareInfo = {
platform: d.platform || undefined,
deviceModel: d.deviceModel || undefined,
osVersion: d.osVersion || undefined,
appVersion: d.appVersion || undefined,
screenWidth: d.screenWidth || undefined,
screenHeight: d.screenHeight || undefined,
locale: d.locale || undefined,
timezone: d.timezone || undefined,
};
return new DeviceInfo(
d.deviceId,
d.deviceName || '未命名设备',
d.addedAt,
d.lastActiveAt,
hardwareInfo,
);
});
const wallets = data.walletAddresses.map((w: any) =>
WalletAddress.reconstruct({