This commit is contained in:
parent
f4a3a6dd1c
commit
083db83c96
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(dir:*)",
|
||||||
|
"Bash(tree:*)",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(ls -la \"c:\\Users\\dong\\Desktop\\rwadurian\\backend\\services\"\" 2>/dev/null || dir \"c:UsersdongDesktoprwadurianbackendservices\"\")"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,901 @@
|
||||||
|
# Backup Service 实施指导
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
### 1.1 服务定位
|
||||||
|
|
||||||
|
`backup-service` 是 RWA Durian 平台的 MPC 备份分片存储服务,负责安全存储用户的 Backup Share (Party 2)。
|
||||||
|
|
||||||
|
**核心职责:**
|
||||||
|
- 安全存储 MPC 2-of-3 的第三个分片 (Backup Share)
|
||||||
|
- 提供分片的存取 API
|
||||||
|
- 支持用户账户恢复时的分片验证和获取
|
||||||
|
- 审计日志记录
|
||||||
|
|
||||||
|
### 1.2 安全要求
|
||||||
|
|
||||||
|
**关键安全原则:必须部署在与 identity-service 不同的物理服务器上!**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ MPC 分片物理隔离架构 │
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 物理服务器 A 物理服务器 B 用户设备 │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────┐ │
|
||||||
|
│ │identity-service │ │ backup-service │ │Flutter App │ │
|
||||||
|
│ │Server Share (0) │ │Backup Share (2) │ │Client (1) │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ └────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 任意单点被攻破,攻击者只能获得 1 个分片,无法重建私钥 │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 Server Share 和 Backup Share 在同一台物理服务器,攻击者攻破该服务器后可获得 2 个分片,直接重建私钥,**MPC 安全性完全失效**!
|
||||||
|
|
||||||
|
### 1.3 技术栈
|
||||||
|
|
||||||
|
与 identity-service 保持一致:
|
||||||
|
- **框架**: NestJS 10.x
|
||||||
|
- **语言**: TypeScript 5.x
|
||||||
|
- **数据库**: PostgreSQL 15+ (独立数据库实例)
|
||||||
|
- **ORM**: Prisma 5.x
|
||||||
|
- **缓存**: Redis (可选)
|
||||||
|
- **消息队列**: Kafka (可选,用于接收 identity-service 的事件)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 目录结构
|
||||||
|
|
||||||
|
参考 identity-service 的 DDD + Hexagonal 架构:
|
||||||
|
|
||||||
|
```
|
||||||
|
backup-service/
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma # 数据库模型
|
||||||
|
│ └── migrations/ # 数据库迁移
|
||||||
|
├── src/
|
||||||
|
│ ├── api/ # 接口层 (Adapters - Driving)
|
||||||
|
│ │ ├── controllers/
|
||||||
|
│ │ │ └── backup-share.controller.ts
|
||||||
|
│ │ ├── dto/
|
||||||
|
│ │ │ ├── request/
|
||||||
|
│ │ │ │ ├── store-share.dto.ts
|
||||||
|
│ │ │ │ └── retrieve-share.dto.ts
|
||||||
|
│ │ │ └── response/
|
||||||
|
│ │ │ └── share-info.dto.ts
|
||||||
|
│ │ └── api.module.ts
|
||||||
|
│ │
|
||||||
|
│ ├── application/ # 应用层 (Use Cases)
|
||||||
|
│ │ ├── commands/
|
||||||
|
│ │ │ ├── store-backup-share/
|
||||||
|
│ │ │ │ ├── store-backup-share.command.ts
|
||||||
|
│ │ │ │ └── store-backup-share.handler.ts
|
||||||
|
│ │ │ └── revoke-share/
|
||||||
|
│ │ │ ├── revoke-share.command.ts
|
||||||
|
│ │ │ └── revoke-share.handler.ts
|
||||||
|
│ │ ├── queries/
|
||||||
|
│ │ │ └── get-backup-share/
|
||||||
|
│ │ │ ├── get-backup-share.query.ts
|
||||||
|
│ │ │ └── get-backup-share.handler.ts
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ └── backup-share-application.service.ts
|
||||||
|
│ │ └── application.module.ts
|
||||||
|
│ │
|
||||||
|
│ ├── domain/ # 领域层 (Core Business Logic)
|
||||||
|
│ │ ├── entities/
|
||||||
|
│ │ │ └── backup-share.entity.ts
|
||||||
|
│ │ ├── repositories/
|
||||||
|
│ │ │ └── backup-share.repository.interface.ts
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ └── share-encryption.domain-service.ts
|
||||||
|
│ │ ├── value-objects/
|
||||||
|
│ │ │ ├── share-id.vo.ts
|
||||||
|
│ │ │ └── encrypted-data.vo.ts
|
||||||
|
│ │ └── domain.module.ts
|
||||||
|
│ │
|
||||||
|
│ ├── infrastructure/ # 基础设施层 (Adapters - Driven)
|
||||||
|
│ │ ├── persistence/
|
||||||
|
│ │ │ ├── prisma/
|
||||||
|
│ │ │ │ └── prisma.service.ts
|
||||||
|
│ │ │ └── repositories/
|
||||||
|
│ │ │ └── backup-share.repository.impl.ts
|
||||||
|
│ │ ├── crypto/
|
||||||
|
│ │ │ └── aes-encryption.service.ts
|
||||||
|
│ │ └── infrastructure.module.ts
|
||||||
|
│ │
|
||||||
|
│ ├── shared/ # 共享模块
|
||||||
|
│ │ ├── guards/
|
||||||
|
│ │ │ └── service-auth.guard.ts # 服务间认证
|
||||||
|
│ │ ├── filters/
|
||||||
|
│ │ │ └── global-exception.filter.ts
|
||||||
|
│ │ └── interceptors/
|
||||||
|
│ │ └── audit-log.interceptor.ts
|
||||||
|
│ │
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ ├── app.module.ts
|
||||||
|
│ └── main.ts
|
||||||
|
│
|
||||||
|
├── test/
|
||||||
|
│ ├── unit/
|
||||||
|
│ └── e2e/
|
||||||
|
│ └── backup-share.e2e-spec.ts
|
||||||
|
│
|
||||||
|
├── .env.example
|
||||||
|
├── .env.development
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── nest-cli.json
|
||||||
|
└── Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 数据库设计
|
||||||
|
|
||||||
|
### 3.1 Prisma Schema
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
// prisma/schema.prisma
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备份分片存储
|
||||||
|
model BackupShare {
|
||||||
|
shareId BigInt @id @default(autoincrement()) @map("share_id")
|
||||||
|
|
||||||
|
// 用户标识 (来自 identity-service)
|
||||||
|
userId BigInt @unique @map("user_id")
|
||||||
|
accountSequence BigInt @unique @map("account_sequence")
|
||||||
|
|
||||||
|
// MPC 密钥信息
|
||||||
|
publicKey String @unique @map("public_key") @db.VarChar(130)
|
||||||
|
partyIndex Int @default(2) @map("party_index") // Backup = Party 2
|
||||||
|
threshold Int @default(2)
|
||||||
|
totalParties Int @default(3) @map("total_parties")
|
||||||
|
|
||||||
|
// 加密的分片数据 (AES-256-GCM 加密)
|
||||||
|
encryptedShareData String @map("encrypted_share_data") @db.Text
|
||||||
|
encryptionKeyId String @map("encryption_key_id") @db.VarChar(64) // 密钥轮换支持
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
status String @default("ACTIVE") @db.VarChar(20) // ACTIVE, REVOKED, ROTATED
|
||||||
|
|
||||||
|
// 访问控制
|
||||||
|
accessCount Int @default(0) @map("access_count") // 访问次数限制
|
||||||
|
lastAccessedAt DateTime? @map("last_accessed_at")
|
||||||
|
|
||||||
|
// 时间戳
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
revokedAt DateTime? @map("revoked_at")
|
||||||
|
|
||||||
|
// 索引
|
||||||
|
@@index([publicKey], name: "idx_backup_public_key")
|
||||||
|
@@index([status], name: "idx_backup_status")
|
||||||
|
@@index([createdAt], name: "idx_backup_created")
|
||||||
|
@@map("backup_shares")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 访问审计日志
|
||||||
|
model ShareAccessLog {
|
||||||
|
logId BigInt @id @default(autoincrement()) @map("log_id")
|
||||||
|
|
||||||
|
shareId BigInt @map("share_id")
|
||||||
|
userId BigInt @map("user_id")
|
||||||
|
|
||||||
|
action String @db.VarChar(20) // STORE, RETRIEVE, REVOKE, ROTATE
|
||||||
|
sourceService String @map("source_service") @db.VarChar(50) // identity-service, recovery-service
|
||||||
|
sourceIp String @map("source_ip") @db.VarChar(45)
|
||||||
|
|
||||||
|
success Boolean @default(true)
|
||||||
|
errorMessage String? @map("error_message") @db.Text
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([shareId], name: "idx_log_share")
|
||||||
|
@@index([userId], name: "idx_log_user")
|
||||||
|
@@index([action], name: "idx_log_action")
|
||||||
|
@@index([createdAt], name: "idx_log_created")
|
||||||
|
@@map("share_access_logs")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 设计
|
||||||
|
|
||||||
|
### 4.1 存储备份分片
|
||||||
|
|
||||||
|
**POST /backup-share/store**
|
||||||
|
|
||||||
|
由 identity-service 在创建账户时调用。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Request DTO
|
||||||
|
class StoreBackupShareDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
userId: string; // identity-service 的用户ID
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
accountSequence: number; // 账户序列号
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
publicKey: string; // MPC 公钥
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
encryptedShareData: string; // 已加密的分片数据
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
threshold?: number; // 默认 2
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
totalParties?: number; // 默认 3
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"shareId": "123",
|
||||||
|
"message": "Backup share stored successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 获取备份分片
|
||||||
|
|
||||||
|
**POST /backup-share/retrieve**
|
||||||
|
|
||||||
|
用于账户恢复场景,需要额外的身份验证。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Request DTO
|
||||||
|
class RetrieveBackupShareDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
publicKey: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
recoveryToken: string; // 恢复令牌 (由 identity-service 签发)
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
deviceId?: string; // 新设备ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"encryptedShareData": "...",
|
||||||
|
"partyIndex": 2,
|
||||||
|
"publicKey": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 撤销分片
|
||||||
|
|
||||||
|
**POST /backup-share/revoke**
|
||||||
|
|
||||||
|
用于密钥轮换或账户注销。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Request DTO
|
||||||
|
class RevokeShareDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
publicKey: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
reason: string; // ROTATION, ACCOUNT_CLOSED, SECURITY_BREACH
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 服务间认证
|
||||||
|
|
||||||
|
### 5.1 认证机制
|
||||||
|
|
||||||
|
backup-service 不对外公开,只接受来自内部服务的请求。使用 **服务间 JWT** 或 **mTLS** 进行认证。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/shared/guards/service-auth.guard.ts
|
||||||
|
|
||||||
|
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ServiceAuthGuard implements CanActivate {
|
||||||
|
constructor(private configService: ConfigService) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const serviceToken = request.headers['x-service-token'];
|
||||||
|
|
||||||
|
if (!serviceToken) {
|
||||||
|
throw new UnauthorizedException('Missing service token');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const secret = this.configService.get<string>('SERVICE_JWT_SECRET');
|
||||||
|
const payload = jwt.verify(serviceToken, secret) as { service: string };
|
||||||
|
|
||||||
|
// 只允许特定服务访问
|
||||||
|
const allowedServices = ['identity-service', 'recovery-service'];
|
||||||
|
if (!allowedServices.includes(payload.service)) {
|
||||||
|
throw new UnauthorizedException('Service not authorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
request.sourceService = payload.service;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
throw new UnauthorizedException('Invalid service token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 identity-service 调用示例
|
||||||
|
|
||||||
|
在 identity-service 中添加调用 backup-service 的客户端:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// identity-service/src/infrastructure/external/backup/backup-client.service.ts
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BackupClientService {
|
||||||
|
private readonly backupServiceUrl: string;
|
||||||
|
private readonly serviceToken: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.backupServiceUrl = this.configService.get('BACKUP_SERVICE_URL');
|
||||||
|
this.serviceToken = this.generateServiceToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeBackupShare(params: {
|
||||||
|
userId: string;
|
||||||
|
accountSequence: number;
|
||||||
|
publicKey: string;
|
||||||
|
encryptedShareData: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await firstValueFrom(
|
||||||
|
this.httpService.post(
|
||||||
|
`${this.backupServiceUrl}/backup-share/store`,
|
||||||
|
params,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Service-Token': this.serviceToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateServiceToken(): string {
|
||||||
|
const secret = this.configService.get<string>('SERVICE_JWT_SECRET');
|
||||||
|
return jwt.sign(
|
||||||
|
{ service: 'identity-service', iat: Math.floor(Date.now() / 1000) },
|
||||||
|
secret,
|
||||||
|
{ expiresIn: '1h' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 核心实现
|
||||||
|
|
||||||
|
### 6.1 领域实体
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/entities/backup-share.entity.ts
|
||||||
|
|
||||||
|
export class BackupShare {
|
||||||
|
private constructor(
|
||||||
|
private readonly _shareId: bigint | null,
|
||||||
|
private readonly _userId: bigint,
|
||||||
|
private readonly _accountSequence: bigint,
|
||||||
|
private readonly _publicKey: string,
|
||||||
|
private readonly _partyIndex: number,
|
||||||
|
private readonly _threshold: number,
|
||||||
|
private readonly _totalParties: number,
|
||||||
|
private _encryptedShareData: string,
|
||||||
|
private _encryptionKeyId: string,
|
||||||
|
private _status: BackupShareStatus,
|
||||||
|
private _accessCount: number,
|
||||||
|
private readonly _createdAt: Date,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static create(params: {
|
||||||
|
userId: bigint;
|
||||||
|
accountSequence: bigint;
|
||||||
|
publicKey: string;
|
||||||
|
encryptedShareData: string;
|
||||||
|
encryptionKeyId: string;
|
||||||
|
}): BackupShare {
|
||||||
|
return new BackupShare(
|
||||||
|
null,
|
||||||
|
params.userId,
|
||||||
|
params.accountSequence,
|
||||||
|
params.publicKey,
|
||||||
|
2, // Backup = Party 2
|
||||||
|
2, // threshold
|
||||||
|
3, // totalParties
|
||||||
|
params.encryptedShareData,
|
||||||
|
params.encryptionKeyId,
|
||||||
|
BackupShareStatus.ACTIVE,
|
||||||
|
0,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordAccess(): void {
|
||||||
|
if (this._status !== BackupShareStatus.ACTIVE) {
|
||||||
|
throw new DomainError('Cannot access revoked share');
|
||||||
|
}
|
||||||
|
this._accessCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
revoke(reason: string): void {
|
||||||
|
if (this._status === BackupShareStatus.REVOKED) {
|
||||||
|
throw new DomainError('Share already revoked');
|
||||||
|
}
|
||||||
|
this._status = BackupShareStatus.REVOKED;
|
||||||
|
}
|
||||||
|
|
||||||
|
rotate(newEncryptedData: string, newKeyId: string): void {
|
||||||
|
this._encryptedShareData = newEncryptedData;
|
||||||
|
this._encryptionKeyId = newKeyId;
|
||||||
|
this._status = BackupShareStatus.ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters...
|
||||||
|
get shareId(): bigint | null { return this._shareId; }
|
||||||
|
get userId(): bigint { return this._userId; }
|
||||||
|
get publicKey(): string { return this._publicKey; }
|
||||||
|
get encryptedShareData(): string { return this._encryptedShareData; }
|
||||||
|
get status(): BackupShareStatus { return this._status; }
|
||||||
|
get accessCount(): number { return this._accessCount; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum BackupShareStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
REVOKED = 'REVOKED',
|
||||||
|
ROTATED = 'ROTATED',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 应用服务
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/application/services/backup-share-application.service.ts
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BackupShareApplicationService {
|
||||||
|
private readonly logger = new Logger(BackupShareApplicationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(BACKUP_SHARE_REPOSITORY)
|
||||||
|
private readonly repository: BackupShareRepository,
|
||||||
|
private readonly encryptionService: AesEncryptionService,
|
||||||
|
private readonly auditService: AuditLogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async storeBackupShare(command: StoreBackupShareCommand): Promise<string> {
|
||||||
|
this.logger.log(`Storing backup share for user: ${command.userId}`);
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
const existing = await this.repository.findByUserId(BigInt(command.userId));
|
||||||
|
if (existing) {
|
||||||
|
throw new ApplicationError('Backup share already exists for this user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二次加密 (identity-service 已加密一次,这里再加密一次)
|
||||||
|
const { encrypted, keyId } = await this.encryptionService.encrypt(
|
||||||
|
command.encryptedShareData,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 创建实体
|
||||||
|
const share = BackupShare.create({
|
||||||
|
userId: BigInt(command.userId),
|
||||||
|
accountSequence: BigInt(command.accountSequence),
|
||||||
|
publicKey: command.publicKey,
|
||||||
|
encryptedShareData: encrypted,
|
||||||
|
encryptionKeyId: keyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存
|
||||||
|
const saved = await this.repository.save(share);
|
||||||
|
|
||||||
|
// 记录审计日志
|
||||||
|
await this.auditService.log({
|
||||||
|
shareId: saved.shareId!,
|
||||||
|
userId: BigInt(command.userId),
|
||||||
|
action: 'STORE',
|
||||||
|
sourceService: command.sourceService,
|
||||||
|
sourceIp: command.sourceIp,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Backup share stored: shareId=${saved.shareId}`);
|
||||||
|
return saved.shareId!.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieveBackupShare(query: GetBackupShareQuery): Promise<BackupShareDto> {
|
||||||
|
this.logger.log(`Retrieving backup share for user: ${query.userId}`);
|
||||||
|
|
||||||
|
const share = await this.repository.findByUserIdAndPublicKey(
|
||||||
|
BigInt(query.userId),
|
||||||
|
query.publicKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!share) {
|
||||||
|
throw new ApplicationError('Backup share not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (share.status !== BackupShareStatus.ACTIVE) {
|
||||||
|
throw new ApplicationError('Backup share is not active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录访问
|
||||||
|
share.recordAccess();
|
||||||
|
await this.repository.save(share);
|
||||||
|
|
||||||
|
// 解密
|
||||||
|
const decrypted = await this.encryptionService.decrypt(
|
||||||
|
share.encryptedShareData,
|
||||||
|
share.encryptionKeyId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 记录审计日志
|
||||||
|
await this.auditService.log({
|
||||||
|
shareId: share.shareId!,
|
||||||
|
userId: BigInt(query.userId),
|
||||||
|
action: 'RETRIEVE',
|
||||||
|
sourceService: query.sourceService,
|
||||||
|
sourceIp: query.sourceIp,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
encryptedShareData: decrypted,
|
||||||
|
partyIndex: share.partyIndex,
|
||||||
|
publicKey: share.publicKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 环境配置
|
||||||
|
|
||||||
|
### 7.1 .env.example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database (必须是独立的数据库实例,不能与 identity-service 共享!)
|
||||||
|
DATABASE_URL="postgresql://postgres:password@backup-db-server:5432/rwa_backup?schema=public"
|
||||||
|
|
||||||
|
# Server
|
||||||
|
APP_PORT=3002
|
||||||
|
APP_ENV="development"
|
||||||
|
|
||||||
|
# Service Authentication
|
||||||
|
SERVICE_JWT_SECRET="your-super-secret-service-jwt-key"
|
||||||
|
ALLOWED_SERVICES="identity-service,recovery-service"
|
||||||
|
|
||||||
|
# Encryption (用于二次加密备份分片)
|
||||||
|
BACKUP_ENCRYPTION_KEY="your-256-bit-encryption-key-in-hex"
|
||||||
|
BACKUP_ENCRYPTION_KEY_ID="key-v1"
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
MAX_RETRIEVE_PER_DAY=3 # 每用户每天最多获取 3 次
|
||||||
|
MAX_STORE_PER_MINUTE=10 # 每分钟最多存储 10 个
|
||||||
|
|
||||||
|
# Audit
|
||||||
|
AUDIT_LOG_RETENTION_DAYS=365 # 审计日志保留 365 天
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
PROMETHEUS_ENABLED=true
|
||||||
|
PROMETHEUS_PORT=9102
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 部署要求
|
||||||
|
|
||||||
|
### 8.1 物理隔离
|
||||||
|
|
||||||
|
**强制要求:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml (仅供参考架构,生产环境必须分开部署)
|
||||||
|
|
||||||
|
# ❌ 错误示例 - 同一台机器
|
||||||
|
services:
|
||||||
|
identity-service:
|
||||||
|
...
|
||||||
|
backup-service: # 不能在这里!
|
||||||
|
...
|
||||||
|
|
||||||
|
# ✅ 正确示例 - 分开部署
|
||||||
|
# 机器 A: docker-compose-main.yml
|
||||||
|
services:
|
||||||
|
identity-service:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 机器 B: docker-compose-backup.yml (异地机房)
|
||||||
|
services:
|
||||||
|
backup-service:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 网络安全
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# backup-service 的网络策略
|
||||||
|
- 只允许来自 identity-service 的入站连接
|
||||||
|
- 禁止任何公网访问
|
||||||
|
- 使用 VPN 或专线连接主机房和备份机房
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 数据库安全
|
||||||
|
|
||||||
|
```
|
||||||
|
- 使用独立的 PostgreSQL 实例
|
||||||
|
- 启用 TLS 加密连接
|
||||||
|
- 定期备份到第三方存储
|
||||||
|
- 启用审计日志
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 测试
|
||||||
|
|
||||||
|
### 9.1 E2E 测试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// test/e2e/backup-share.e2e-spec.ts
|
||||||
|
|
||||||
|
describe('BackupShare (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let serviceToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleFixture = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
// 生成测试用的服务令牌
|
||||||
|
serviceToken = jwt.sign(
|
||||||
|
{ service: 'identity-service' },
|
||||||
|
process.env.SERVICE_JWT_SECRET,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /backup-share/store', () => {
|
||||||
|
it('should store backup share successfully', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/backup-share/store')
|
||||||
|
.set('X-Service-Token', serviceToken)
|
||||||
|
.send({
|
||||||
|
userId: '12345',
|
||||||
|
accountSequence: 1001,
|
||||||
|
publicKey: '02' + 'a'.repeat(64),
|
||||||
|
encryptedShareData: 'encrypted-share-data-base64',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(response.body.success).toBe(true);
|
||||||
|
expect(response.body.shareId).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject duplicate share', async () => {
|
||||||
|
// First store
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/backup-share/store')
|
||||||
|
.set('X-Service-Token', serviceToken)
|
||||||
|
.send({
|
||||||
|
userId: '99999',
|
||||||
|
accountSequence: 9999,
|
||||||
|
publicKey: '02' + 'b'.repeat(64),
|
||||||
|
encryptedShareData: 'data',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Duplicate
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/backup-share/store')
|
||||||
|
.set('X-Service-Token', serviceToken)
|
||||||
|
.send({
|
||||||
|
userId: '99999',
|
||||||
|
accountSequence: 9999,
|
||||||
|
publicKey: '02' + 'b'.repeat(64),
|
||||||
|
encryptedShareData: 'data',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject unauthorized service', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/backup-share/store')
|
||||||
|
.set('X-Service-Token', 'invalid-token')
|
||||||
|
.send({})
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /backup-share/retrieve', () => {
|
||||||
|
it('should retrieve backup share with valid recovery token', async () => {
|
||||||
|
// Setup: store a share first
|
||||||
|
const storeResponse = await request(app.getHttpServer())
|
||||||
|
.post('/backup-share/store')
|
||||||
|
.set('X-Service-Token', serviceToken)
|
||||||
|
.send({
|
||||||
|
userId: '77777',
|
||||||
|
accountSequence: 7777,
|
||||||
|
publicKey: '02' + 'c'.repeat(64),
|
||||||
|
encryptedShareData: 'test-encrypted-data',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retrieve
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/backup-share/retrieve')
|
||||||
|
.set('X-Service-Token', serviceToken)
|
||||||
|
.send({
|
||||||
|
userId: '77777',
|
||||||
|
publicKey: '02' + 'c'.repeat(64),
|
||||||
|
recoveryToken: 'valid-recovery-token',
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.encryptedShareData).toBeDefined();
|
||||||
|
expect(response.body.partyIndex).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 与 identity-service 的集成
|
||||||
|
|
||||||
|
### 10.1 修改 identity-service
|
||||||
|
|
||||||
|
在 identity-service 的 `autoCreateAccount` 中添加调用 backup-service:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// identity-service/src/application/services/user-application.service.ts
|
||||||
|
|
||||||
|
// 在保存服务端分片后,调用 backup-service 存储备份分片
|
||||||
|
await this.mpcKeyShareRepository.saveServerShare({...});
|
||||||
|
|
||||||
|
// 新增: 调用 backup-service 存储备份分片
|
||||||
|
if (mpcResult.backupShareData) {
|
||||||
|
try {
|
||||||
|
await this.backupClient.storeBackupShare({
|
||||||
|
userId: account.userId.toString(),
|
||||||
|
accountSequence: account.accountSequence.value,
|
||||||
|
publicKey: mpcResult.publicKey,
|
||||||
|
encryptedShareData: mpcResult.backupShareData,
|
||||||
|
});
|
||||||
|
this.logger.log(`Backup share stored to backup-service for user: ${account.userId}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to store backup share: ${error.message}`);
|
||||||
|
// 根据业务需求决定是否回滚或继续
|
||||||
|
// 建议: 记录失败,后续通过补偿任务重试
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 配置更新
|
||||||
|
|
||||||
|
在 identity-service 的 `.env` 中添加:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup Service
|
||||||
|
BACKUP_SERVICE_URL="http://backup-server:3002"
|
||||||
|
SERVICE_JWT_SECRET="your-super-secret-service-jwt-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 监控和告警
|
||||||
|
|
||||||
|
### 11.1 关键指标
|
||||||
|
|
||||||
|
```
|
||||||
|
- backup_share_store_total # 存储总数
|
||||||
|
- backup_share_retrieve_total # 获取总数
|
||||||
|
- backup_share_revoke_total # 撤销总数
|
||||||
|
- backup_share_store_latency_ms # 存储延迟
|
||||||
|
- backup_share_retrieve_latency_ms # 获取延迟
|
||||||
|
- backup_share_error_total # 错误总数
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 告警规则
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 异常访问告警
|
||||||
|
- alert: HighBackupShareRetrieveRate
|
||||||
|
expr: rate(backup_share_retrieve_total[5m]) > 10
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Backup share retrieve rate is high"
|
||||||
|
|
||||||
|
# 服务不可用告警
|
||||||
|
- alert: BackupServiceDown
|
||||||
|
expr: up{job="backup-service"} == 0
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 开发步骤
|
||||||
|
|
||||||
|
### 12.1 初始化项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 创建 NestJS 项目
|
||||||
|
cd backend/services/backup-service
|
||||||
|
npx @nestjs/cli new . --skip-git
|
||||||
|
|
||||||
|
# 2. 安装依赖
|
||||||
|
npm install @nestjs/config @prisma/client class-validator class-transformer
|
||||||
|
npm install -D prisma
|
||||||
|
|
||||||
|
# 3. 初始化 Prisma
|
||||||
|
npx prisma init
|
||||||
|
|
||||||
|
# 4. 复制 schema 并生成客户端
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma migrate dev --name init
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.2 开发顺序
|
||||||
|
|
||||||
|
1. **基础设施层** - Prisma 配置、数据库连接
|
||||||
|
2. **领域层** - BackupShare 实体、Repository 接口
|
||||||
|
3. **应用层** - 存储/获取/撤销命令处理
|
||||||
|
4. **接口层** - REST API Controller
|
||||||
|
5. **安全层** - ServiceAuthGuard、审计日志
|
||||||
|
6. **测试** - 单元测试、E2E 测试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 检查清单
|
||||||
|
|
||||||
|
开发完成后,确保以下事项:
|
||||||
|
|
||||||
|
- [ ] 数据库部署在独立服务器
|
||||||
|
- [ ] 服务间认证已实现
|
||||||
|
- [ ] 分片数据已二次加密
|
||||||
|
- [ ] 审计日志已启用
|
||||||
|
- [ ] 访问频率限制已实现
|
||||||
|
- [ ] E2E 测试通过
|
||||||
|
- [ ] 与 identity-service 集成测试通过
|
||||||
|
- [ ] 监控指标已配置
|
||||||
|
- [ ] 告警规则已配置
|
||||||
|
- [ ] 生产环境部署在异地机房
|
||||||
|
|
@ -27,3 +27,8 @@ APP_ENV="development"
|
||||||
|
|
||||||
# Blockchain Encryption
|
# Blockchain Encryption
|
||||||
WALLET_ENCRYPTION_SALT="dev-wallet-salt"
|
WALLET_ENCRYPTION_SALT="dev-wallet-salt"
|
||||||
|
|
||||||
|
# MPC Service (NestJS 中间层)
|
||||||
|
# 调用路径: identity-service → mpc-service (NestJS) → mpc-system (Go)
|
||||||
|
MPC_SERVICE_URL="http://localhost:3001"
|
||||||
|
MPC_MODE="local" # local 使用本地模拟,remote 调用 mpc-service
|
||||||
|
|
|
||||||
|
|
@ -27,3 +27,8 @@ APP_ENV="development"
|
||||||
|
|
||||||
# Blockchain Encryption
|
# Blockchain Encryption
|
||||||
WALLET_ENCRYPTION_SALT="rwa-wallet-salt-change-in-production"
|
WALLET_ENCRYPTION_SALT="rwa-wallet-salt-change-in-production"
|
||||||
|
|
||||||
|
# MPC Service (NestJS 中间层)
|
||||||
|
# 调用路径: identity-service → mpc-service (NestJS) → mpc-system (Go)
|
||||||
|
MPC_SERVICE_URL="http://localhost:3001"
|
||||||
|
MPC_MODE="local" # local 使用本地模拟,remote 调用 mpc-service
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"prisma:studio": "prisma studio"
|
"prisma:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nestjs/axios": "^3.0.0",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
|
|
|
||||||
|
|
@ -69,22 +69,28 @@ model UserDevice {
|
||||||
model WalletAddress {
|
model WalletAddress {
|
||||||
addressId BigInt @id @default(autoincrement()) @map("address_id")
|
addressId BigInt @id @default(autoincrement()) @map("address_id")
|
||||||
userId BigInt @map("user_id")
|
userId BigInt @map("user_id")
|
||||||
|
|
||||||
chainType String @map("chain_type") @db.VarChar(20)
|
chainType String @map("chain_type") @db.VarChar(20)
|
||||||
address String @db.VarChar(100)
|
address String @db.VarChar(100)
|
||||||
|
publicKey String @map("public_key") @db.VarChar(130) // MPC公钥 (压缩/非压缩格式)
|
||||||
encryptedMnemonic String? @map("encrypted_mnemonic") @db.Text
|
|
||||||
|
// MPC 签名验证字段 - 防止地址被恶意篡改
|
||||||
|
addressDigest String @map("address_digest") @db.VarChar(66) // SHA256摘要
|
||||||
|
mpcSignatureR String @map("mpc_signature_r") @db.VarChar(66) // 签名R分量
|
||||||
|
mpcSignatureS String @map("mpc_signature_s") @db.VarChar(66) // 签名S分量
|
||||||
|
mpcSignatureV Int @map("mpc_signature_v") // 签名恢复ID
|
||||||
|
|
||||||
status String @default("ACTIVE") @db.VarChar(20)
|
status String @default("ACTIVE") @db.VarChar(20)
|
||||||
|
|
||||||
boundAt DateTime @default(now()) @map("bound_at")
|
boundAt DateTime @default(now()) @map("bound_at")
|
||||||
|
|
||||||
user UserAccount @relation(fields: [userId], references: [userId], onDelete: Cascade)
|
user UserAccount @relation(fields: [userId], references: [userId], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([userId, chainType], name: "uk_user_chain")
|
@@unique([userId, chainType], name: "uk_user_chain")
|
||||||
@@unique([chainType, address], name: "uk_chain_address")
|
@@unique([chainType, address], name: "uk_chain_address")
|
||||||
@@index([userId], name: "idx_wallet_user")
|
@@index([userId], name: "idx_wallet_user")
|
||||||
@@index([address], name: "idx_address")
|
@@index([address], name: "idx_address")
|
||||||
|
@@index([publicKey], name: "idx_public_key")
|
||||||
@@map("wallet_addresses")
|
@@map("wallet_addresses")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,3 +176,53 @@ model SmsCode {
|
||||||
@@index([expiresAt], name: "idx_sms_expires")
|
@@index([expiresAt], name: "idx_sms_expires")
|
||||||
@@map("sms_codes")
|
@@map("sms_codes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MPC 密钥分片存储 - 服务端持有的分片
|
||||||
|
model MpcKeyShare {
|
||||||
|
shareId BigInt @id @default(autoincrement()) @map("share_id")
|
||||||
|
userId BigInt @unique @map("user_id") // 一个用户只有一组MPC密钥
|
||||||
|
|
||||||
|
publicKey String @unique @map("public_key") @db.VarChar(130) // MPC公钥
|
||||||
|
partyIndex Int @map("party_index") // 当前分片的参与方索引 (0=服务端)
|
||||||
|
threshold Int @default(2) // 阈值 t
|
||||||
|
totalParties Int @default(3) @map("total_parties") // 总参与方 n
|
||||||
|
|
||||||
|
// 加密存储的分片数据 (AES加密)
|
||||||
|
encryptedShareData String @map("encrypted_share_data") @db.Text
|
||||||
|
|
||||||
|
status String @default("ACTIVE") @db.VarChar(20) // ACTIVE, ROTATED, REVOKED
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
rotatedAt DateTime? @map("rotated_at") // 最后一次密钥轮换时间
|
||||||
|
|
||||||
|
@@index([publicKey], name: "idx_mpc_public_key")
|
||||||
|
@@index([status], name: "idx_mpc_status")
|
||||||
|
@@map("mpc_key_shares")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MPC 会话记录 - 用于审计和追踪
|
||||||
|
model MpcSession {
|
||||||
|
sessionId String @id @map("session_id") @db.VarChar(50)
|
||||||
|
sessionType String @map("session_type") @db.VarChar(20) // KEYGEN, SIGNING, ROTATION
|
||||||
|
|
||||||
|
userId BigInt? @map("user_id")
|
||||||
|
publicKey String? @map("public_key") @db.VarChar(130)
|
||||||
|
|
||||||
|
status String @default("PENDING") @db.VarChar(20) // PENDING, IN_PROGRESS, COMPLETED, FAILED
|
||||||
|
errorMessage String? @map("error_message") @db.Text
|
||||||
|
|
||||||
|
// 签名相关 (仅SIGNING类型)
|
||||||
|
messageHash String? @map("message_hash") @db.VarChar(66)
|
||||||
|
signatureR String? @map("signature_r") @db.VarChar(66)
|
||||||
|
signatureS String? @map("signature_s") @db.VarChar(66)
|
||||||
|
signatureV Int? @map("signature_v")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
|
||||||
|
@@index([sessionType], name: "idx_session_type")
|
||||||
|
@@index([userId], name: "idx_session_user")
|
||||||
|
@@index([status], name: "idx_session_status")
|
||||||
|
@@index([createdAt], name: "idx_session_created")
|
||||||
|
@@map("mpc_sessions")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -125,16 +125,22 @@ export class AutoCreateAccountResponseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ description: '账户序列号 (唯一标识,用于推荐和分享)' })
|
||||||
accountSequence: number;
|
accountSequence: number;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ description: '推荐码' })
|
||||||
referralCode: string;
|
referralCode: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '助记词(仅返回一次,请妥善保管)' })
|
@ApiPropertyOptional({ description: '助记词 (MPC模式下为空)' })
|
||||||
mnemonic: string;
|
mnemonic?: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiPropertyOptional({ description: 'MPC客户端分片数据 (需安全存储,用于签名)' })
|
||||||
|
clientShareData?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'MPC公钥' })
|
||||||
|
publicKey?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '三链钱包地址 (BSC/KAVA/DST)' })
|
||||||
walletAddresses: { kava: string; dst: string; bsc: string };
|
walletAddresses: { kava: string; dst: string; bsc: string };
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,9 @@ export interface AutoCreateAccountResult {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountSequence: number;
|
accountSequence: number;
|
||||||
referralCode: string;
|
referralCode: string;
|
||||||
mnemonic: string;
|
mnemonic: string; // 兼容字段,MPC模式下为空
|
||||||
|
clientShareData?: string; // MPC 客户端分片数据 (需安全存储)
|
||||||
|
publicKey?: string; // MPC 公钥
|
||||||
walletAddresses: { kava: string; dst: string; bsc: string };
|
walletAddresses: { kava: string; dst: string; bsc: string };
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||||
|
import { MpcKeyShareRepository, MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||||
|
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||||
import {
|
import {
|
||||||
AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService,
|
AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService,
|
||||||
} from '@/domain/services';
|
} from '@/domain/services';
|
||||||
|
|
@ -12,6 +14,7 @@ import { TokenService } from './token.service';
|
||||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||||
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
||||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
|
import { MpcWalletService } from '@/infrastructure/external/mpc';
|
||||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||||
import {
|
import {
|
||||||
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
||||||
|
|
@ -24,22 +27,39 @@ import {
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserApplicationService {
|
export class UserApplicationService {
|
||||||
|
private readonly logger = new Logger(UserApplicationService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||||
private readonly userRepository: UserAccountRepository,
|
private readonly userRepository: UserAccountRepository,
|
||||||
|
@Inject(MPC_KEY_SHARE_REPOSITORY)
|
||||||
|
private readonly mpcKeyShareRepository: MpcKeyShareRepository,
|
||||||
private readonly sequenceGenerator: AccountSequenceGeneratorService,
|
private readonly sequenceGenerator: AccountSequenceGeneratorService,
|
||||||
private readonly validatorService: UserValidatorService,
|
private readonly validatorService: UserValidatorService,
|
||||||
private readonly walletGenerator: WalletGeneratorService,
|
private readonly walletGenerator: WalletGeneratorService,
|
||||||
|
private readonly mpcWalletService: MpcWalletService,
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
private readonly redisService: RedisService,
|
private readonly redisService: RedisService,
|
||||||
private readonly smsService: SmsService,
|
private readonly smsService: SmsService,
|
||||||
private readonly eventPublisher: EventPublisherService,
|
private readonly eventPublisher: EventPublisherService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动创建账户 (首次打开APP)
|
||||||
|
*
|
||||||
|
* 使用 MPC 2-of-3 协议生成钱包地址:
|
||||||
|
* - 生成三条链 (BSC/KAVA/DST) 的钱包地址
|
||||||
|
* - 计算地址摘要并用 MPC 签名
|
||||||
|
* - 签名存储在数据库中用于防止地址被篡改
|
||||||
|
*/
|
||||||
async autoCreateAccount(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> {
|
async autoCreateAccount(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> {
|
||||||
|
this.logger.log(`Creating account with MPC 2-of-3 for device: ${command.deviceId}`);
|
||||||
|
|
||||||
|
// 1. 验证设备ID
|
||||||
const deviceValidation = await this.validatorService.validateDeviceId(command.deviceId);
|
const deviceValidation = await this.validatorService.validateDeviceId(command.deviceId);
|
||||||
if (!deviceValidation.isValid) throw new ApplicationError(deviceValidation.errorMessage!);
|
if (!deviceValidation.isValid) throw new ApplicationError(deviceValidation.errorMessage!);
|
||||||
|
|
||||||
|
// 2. 验证邀请码
|
||||||
let inviterSequence: AccountSequence | null = null;
|
let inviterSequence: AccountSequence | null = null;
|
||||||
if (command.inviterReferralCode) {
|
if (command.inviterReferralCode) {
|
||||||
const referralCode = ReferralCode.create(command.inviterReferralCode);
|
const referralCode = ReferralCode.create(command.inviterReferralCode);
|
||||||
|
|
@ -49,8 +69,10 @@ export class UserApplicationService {
|
||||||
inviterSequence = inviter!.accountSequence;
|
inviterSequence = inviter!.accountSequence;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 生成账户序列号
|
||||||
const accountSequence = await this.sequenceGenerator.generateNext();
|
const accountSequence = await this.sequenceGenerator.generateNext();
|
||||||
|
|
||||||
|
// 4. 创建用户账户
|
||||||
const account = UserAccount.createAutomatic({
|
const account = UserAccount.createAutomatic({
|
||||||
accountSequence,
|
accountSequence,
|
||||||
initialDeviceId: command.deviceId,
|
initialDeviceId: command.deviceId,
|
||||||
|
|
@ -60,29 +82,67 @@ export class UserApplicationService {
|
||||||
city: CityCode.create(command.cityCode || 'DEFAULT'),
|
city: CityCode.create(command.cityCode || 'DEFAULT'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mnemonic, wallets } = this.walletGenerator.generateWalletSystem({
|
// 5. 使用 MPC 2-of-3 生成三链钱包地址
|
||||||
userId: account.userId,
|
this.logger.log(`Generating MPC wallet for account sequence: ${accountSequence.value}`);
|
||||||
|
const mpcResult = await this.mpcWalletService.generateMpcWallet({
|
||||||
|
userId: account.userId.toString(),
|
||||||
deviceId: command.deviceId,
|
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);
|
account.bindMultipleWalletAddresses(wallets);
|
||||||
|
|
||||||
|
// 8. 保存账户和钱包
|
||||||
await this.userRepository.save(account);
|
await this.userRepository.save(account);
|
||||||
await this.userRepository.saveWallets(account.userId, Array.from(wallets.values()));
|
await this.userRepository.saveWallets(account.userId, Array.from(wallets.values()));
|
||||||
|
|
||||||
|
// 9. 保存服务端 MPC 分片到数据库 (用于后续签名)
|
||||||
|
await this.mpcKeyShareRepository.saveServerShare({
|
||||||
|
userId: account.userId.value,
|
||||||
|
publicKey: mpcResult.publicKey,
|
||||||
|
partyIndex: 0, // SERVER = party 0
|
||||||
|
threshold: 2,
|
||||||
|
totalParties: 3,
|
||||||
|
encryptedShareData: mpcResult.serverShareData,
|
||||||
|
});
|
||||||
|
this.logger.log(`Server MPC share saved for user: ${account.userId.toString()}`);
|
||||||
|
|
||||||
|
// 11. 生成 Token
|
||||||
const tokens = await this.tokenService.generateTokenPair({
|
const tokens = await this.tokenService.generateTokenPair({
|
||||||
userId: account.userId.toString(),
|
userId: account.userId.toString(),
|
||||||
accountSequence: account.accountSequence.value,
|
accountSequence: account.accountSequence.value,
|
||||||
deviceId: command.deviceId,
|
deviceId: command.deviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 12. 发布领域事件
|
||||||
await this.eventPublisher.publishAll(account.domainEvents);
|
await this.eventPublisher.publishAll(account.domainEvents);
|
||||||
account.clearDomainEvents();
|
account.clearDomainEvents();
|
||||||
|
|
||||||
|
this.logger.log(`Account created successfully: sequence=${accountSequence.value}, publicKey=${mpcResult.publicKey}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: account.userId.toString(),
|
userId: account.userId.toString(),
|
||||||
accountSequence: account.accountSequence.value,
|
accountSequence: account.accountSequence.value,
|
||||||
referralCode: account.referralCode.value,
|
referralCode: account.referralCode.value,
|
||||||
mnemonic: mnemonic.value,
|
// MPC 模式下返回客户端分片数据 (而不是助记词)
|
||||||
|
mnemonic: '', // MPC 模式不使用助记词,返回空字符串
|
||||||
|
clientShareData: mpcResult.clientShareData, // 客户端需要安全存储的分片数据
|
||||||
|
publicKey: mpcResult.publicKey, // MPC 公钥
|
||||||
walletAddresses: {
|
walletAddresses: {
|
||||||
kava: wallets.get(ChainType.KAVA)!.address,
|
kava: wallets.get(ChainType.KAVA)!.address,
|
||||||
dst: wallets.get(ChainType.DST)!.address,
|
dst: wallets.get(ChainType.DST)!.address,
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,23 @@ import {
|
||||||
MnemonicEncryption,
|
MnemonicEncryption,
|
||||||
} from '@/domain/value-objects';
|
} from '@/domain/value-objects';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MPC 签名信息
|
||||||
|
*/
|
||||||
|
export interface MpcSignature {
|
||||||
|
r: string;
|
||||||
|
s: string;
|
||||||
|
v: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class WalletAddress {
|
export class WalletAddress {
|
||||||
private readonly _addressId: AddressId;
|
private readonly _addressId: AddressId;
|
||||||
private readonly _userId: UserId;
|
private readonly _userId: UserId;
|
||||||
private readonly _chainType: ChainType;
|
private readonly _chainType: ChainType;
|
||||||
private readonly _address: string;
|
private readonly _address: string;
|
||||||
private readonly _encryptedMnemonic: string;
|
private readonly _publicKey: string; // MPC 公钥
|
||||||
|
private readonly _addressDigest: string; // 地址摘要
|
||||||
|
private readonly _mpcSignature: MpcSignature; // MPC 签名
|
||||||
private _status: AddressStatus;
|
private _status: AddressStatus;
|
||||||
private readonly _boundAt: Date;
|
private readonly _boundAt: Date;
|
||||||
|
|
||||||
|
|
@ -26,7 +37,9 @@ export class WalletAddress {
|
||||||
get userId(): UserId { return this._userId; }
|
get userId(): UserId { return this._userId; }
|
||||||
get chainType(): ChainType { return this._chainType; }
|
get chainType(): ChainType { return this._chainType; }
|
||||||
get address(): string { return this._address; }
|
get address(): string { return this._address; }
|
||||||
get encryptedMnemonic(): string { return this._encryptedMnemonic; }
|
get publicKey(): string { return this._publicKey; }
|
||||||
|
get addressDigest(): string { return this._addressDigest; }
|
||||||
|
get mpcSignature(): MpcSignature { return this._mpcSignature; }
|
||||||
get status(): AddressStatus { return this._status; }
|
get status(): AddressStatus { return this._status; }
|
||||||
get boundAt(): Date { return this._boundAt; }
|
get boundAt(): Date { return this._boundAt; }
|
||||||
|
|
||||||
|
|
@ -35,7 +48,9 @@ export class WalletAddress {
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
chainType: ChainType,
|
chainType: ChainType,
|
||||||
address: string,
|
address: string,
|
||||||
encryptedMnemonic: string,
|
publicKey: string,
|
||||||
|
addressDigest: string,
|
||||||
|
mpcSignature: MpcSignature,
|
||||||
status: AddressStatus,
|
status: AddressStatus,
|
||||||
boundAt: Date,
|
boundAt: Date,
|
||||||
) {
|
) {
|
||||||
|
|
@ -43,13 +58,27 @@ export class WalletAddress {
|
||||||
this._userId = userId;
|
this._userId = userId;
|
||||||
this._chainType = chainType;
|
this._chainType = chainType;
|
||||||
this._address = address;
|
this._address = address;
|
||||||
this._encryptedMnemonic = encryptedMnemonic;
|
this._publicKey = publicKey;
|
||||||
|
this._addressDigest = addressDigest;
|
||||||
|
this._mpcSignature = mpcSignature;
|
||||||
this._status = status;
|
this._status = status;
|
||||||
this._boundAt = boundAt;
|
this._boundAt = boundAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(params: { userId: UserId; chainType: ChainType; address: string }): WalletAddress {
|
/**
|
||||||
if (!this.validateAddress(params.chainType, params.address)) {
|
* 创建 MPC 钱包地址
|
||||||
|
*
|
||||||
|
* @param params 包含 MPC 签名验证信息的参数
|
||||||
|
*/
|
||||||
|
static createMpc(params: {
|
||||||
|
userId: UserId;
|
||||||
|
chainType: ChainType;
|
||||||
|
address: string;
|
||||||
|
publicKey: string;
|
||||||
|
addressDigest: string;
|
||||||
|
signature: MpcSignature;
|
||||||
|
}): WalletAddress {
|
||||||
|
if (!this.validateEvmAddress(params.address)) {
|
||||||
throw new DomainError(`${params.chainType}地址格式错误`);
|
throw new DomainError(`${params.chainType}地址格式错误`);
|
||||||
}
|
}
|
||||||
return new WalletAddress(
|
return new WalletAddress(
|
||||||
|
|
@ -57,37 +86,27 @@ export class WalletAddress {
|
||||||
params.userId,
|
params.userId,
|
||||||
params.chainType,
|
params.chainType,
|
||||||
params.address,
|
params.address,
|
||||||
'',
|
params.publicKey,
|
||||||
AddressStatus.ACTIVE,
|
params.addressDigest,
|
||||||
new Date(),
|
params.signature,
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static createFromMnemonic(params: {
|
|
||||||
userId: UserId;
|
|
||||||
chainType: ChainType;
|
|
||||||
mnemonic: Mnemonic;
|
|
||||||
encryptionKey: string;
|
|
||||||
}): WalletAddress {
|
|
||||||
const address = this.deriveAddress(params.chainType, params.mnemonic);
|
|
||||||
const encryptedMnemonic = MnemonicEncryption.encrypt(params.mnemonic.value, params.encryptionKey);
|
|
||||||
return new WalletAddress(
|
|
||||||
AddressId.generate(),
|
|
||||||
params.userId,
|
|
||||||
params.chainType,
|
|
||||||
address,
|
|
||||||
encryptedMnemonic,
|
|
||||||
AddressStatus.ACTIVE,
|
AddressStatus.ACTIVE,
|
||||||
new Date(),
|
new Date(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从数据库重建实体
|
||||||
|
*/
|
||||||
static reconstruct(params: {
|
static reconstruct(params: {
|
||||||
addressId: string;
|
addressId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
chainType: ChainType;
|
chainType: ChainType;
|
||||||
address: string;
|
address: string;
|
||||||
encryptedMnemonic: string;
|
publicKey: string;
|
||||||
|
addressDigest: string;
|
||||||
|
mpcSignatureR: string;
|
||||||
|
mpcSignatureS: string;
|
||||||
|
mpcSignatureV: number;
|
||||||
status: AddressStatus;
|
status: AddressStatus;
|
||||||
boundAt: Date;
|
boundAt: Date;
|
||||||
}): WalletAddress {
|
}): WalletAddress {
|
||||||
|
|
@ -96,7 +115,13 @@ export class WalletAddress {
|
||||||
UserId.create(params.userId),
|
UserId.create(params.userId),
|
||||||
params.chainType,
|
params.chainType,
|
||||||
params.address,
|
params.address,
|
||||||
params.encryptedMnemonic,
|
params.publicKey,
|
||||||
|
params.addressDigest,
|
||||||
|
{
|
||||||
|
r: params.mpcSignatureR,
|
||||||
|
s: params.mpcSignatureS,
|
||||||
|
v: params.mpcSignatureV,
|
||||||
|
},
|
||||||
params.status,
|
params.status,
|
||||||
params.boundAt,
|
params.boundAt,
|
||||||
);
|
);
|
||||||
|
|
@ -110,12 +135,95 @@ export class WalletAddress {
|
||||||
this._status = AddressStatus.ACTIVE;
|
this._status = AddressStatus.ACTIVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
decryptMnemonic(encryptionKey: string): Mnemonic {
|
/**
|
||||||
if (!this._encryptedMnemonic) {
|
* 验证签名是否有效
|
||||||
throw new DomainError('该地址没有加密助记词');
|
* 用于检测地址是否被篡改
|
||||||
|
*/
|
||||||
|
async verifySignature(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { ethers } = await import('ethers');
|
||||||
|
|
||||||
|
// 计算预期的摘要
|
||||||
|
const expectedDigest = this.computeDigest();
|
||||||
|
if (expectedDigest !== this._addressDigest) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
const digestBytes = Buffer.from(this._addressDigest, 'hex');
|
||||||
|
const sig = ethers.Signature.from({
|
||||||
|
r: '0x' + this._mpcSignature.r,
|
||||||
|
s: '0x' + this._mpcSignature.s,
|
||||||
|
v: this._mpcSignature.v + 27,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig);
|
||||||
|
const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true);
|
||||||
|
|
||||||
|
return compressedRecovered.slice(2).toLowerCase() === this._publicKey.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
const mnemonicStr = MnemonicEncryption.decrypt(this._encryptedMnemonic, encryptionKey);
|
}
|
||||||
return Mnemonic.create(mnemonicStr);
|
|
||||||
|
/**
|
||||||
|
* 计算地址摘要
|
||||||
|
*/
|
||||||
|
private computeDigest(): string {
|
||||||
|
const message = `${this._chainType}:${this._address.toLowerCase()}`;
|
||||||
|
return createHash('sha256').update(message).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 EVM 地址格式
|
||||||
|
*/
|
||||||
|
private static validateEvmAddress(address: string): boolean {
|
||||||
|
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 兼容旧版本的方法 (保留但标记为废弃) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 请使用 createMpc 方法
|
||||||
|
*/
|
||||||
|
static create(params: { userId: UserId; chainType: ChainType; address: string }): WalletAddress {
|
||||||
|
if (!this.validateAddress(params.chainType, params.address)) {
|
||||||
|
throw new DomainError(`${params.chainType}地址格式错误`);
|
||||||
|
}
|
||||||
|
return new WalletAddress(
|
||||||
|
AddressId.generate(),
|
||||||
|
params.userId,
|
||||||
|
params.chainType,
|
||||||
|
params.address,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
{ r: '', s: '', v: 0 },
|
||||||
|
AddressStatus.ACTIVE,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated MPC 模式下不再使用助记词
|
||||||
|
*/
|
||||||
|
static createFromMnemonic(params: {
|
||||||
|
userId: UserId;
|
||||||
|
chainType: ChainType;
|
||||||
|
mnemonic: Mnemonic;
|
||||||
|
encryptionKey: string;
|
||||||
|
}): WalletAddress {
|
||||||
|
const address = this.deriveAddress(params.chainType, params.mnemonic);
|
||||||
|
return new WalletAddress(
|
||||||
|
AddressId.generate(),
|
||||||
|
params.userId,
|
||||||
|
params.chainType,
|
||||||
|
address,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
{ r: '', s: '', v: 0 },
|
||||||
|
AddressStatus.ACTIVE,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static deriveAddress(chainType: ChainType, mnemonic: Mnemonic): string {
|
private static deriveAddress(chainType: ChainType, mnemonic: Mnemonic): string {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { UserId } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
export interface MpcKeyShareData {
|
||||||
|
userId: bigint;
|
||||||
|
publicKey: string;
|
||||||
|
partyIndex: number;
|
||||||
|
threshold: number;
|
||||||
|
totalParties: number;
|
||||||
|
encryptedShareData: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MpcKeyShare {
|
||||||
|
shareId: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
publicKey: string;
|
||||||
|
partyIndex: number;
|
||||||
|
threshold: number;
|
||||||
|
totalParties: number;
|
||||||
|
encryptedShareData: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: Date;
|
||||||
|
rotatedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MPC_KEY_SHARE_REPOSITORY = Symbol('MPC_KEY_SHARE_REPOSITORY');
|
||||||
|
|
||||||
|
export interface MpcKeyShareRepository {
|
||||||
|
/**
|
||||||
|
* 保存服务端 MPC 分片
|
||||||
|
*/
|
||||||
|
saveServerShare(data: MpcKeyShareData): Promise<MpcKeyShare>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户ID查找分片
|
||||||
|
*/
|
||||||
|
findByUserId(userId: UserId): Promise<MpcKeyShare | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据公钥查找分片
|
||||||
|
*/
|
||||||
|
findByPublicKey(publicKey: string): Promise<MpcKeyShare | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新分片状态 (用于密钥轮换)
|
||||||
|
*/
|
||||||
|
updateStatus(shareId: bigint, status: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮换分片 (更新分片数据)
|
||||||
|
*/
|
||||||
|
rotateShare(shareId: bigint, newEncryptedData: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './mpc.module';
|
||||||
|
export * from './mpc-client.service';
|
||||||
|
export * from './mpc-wallet.service';
|
||||||
269
backend/services/identity-service/src/infrastructure/external/mpc/mpc-client.service.ts
vendored
Normal file
269
backend/services/identity-service/src/infrastructure/external/mpc/mpc-client.service.ts
vendored
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
/**
|
||||||
|
* MPC Client Service
|
||||||
|
*
|
||||||
|
* 与 mpc-service (NestJS) 通信的客户端服务
|
||||||
|
*
|
||||||
|
* 调用路径:
|
||||||
|
* identity-service → mpc-service (NestJS) → mpc-system (Go)
|
||||||
|
*
|
||||||
|
* 这种架构的优点:
|
||||||
|
* 1. 符合 DDD 边界上下文分离原则
|
||||||
|
* 2. mpc-service 可以封装 MPC 业务逻辑、重试、熔断等
|
||||||
|
* 3. 多个服务可共享 mpc-service(如 transaction-service)
|
||||||
|
* 4. MPC API Key 只需配置在 mpc-service
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export interface KeygenRequest {
|
||||||
|
sessionId: string;
|
||||||
|
threshold: number; // t in t-of-n (默认 2)
|
||||||
|
totalParties: number; // n in t-of-n (默认 3)
|
||||||
|
parties: PartyInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartyInfo {
|
||||||
|
partyId: string;
|
||||||
|
partyIndex: number;
|
||||||
|
partyType: 'SERVER' | 'CLIENT' | 'BACKUP';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeygenResult {
|
||||||
|
sessionId: string;
|
||||||
|
publicKey: string; // 压缩格式公钥 (33 bytes hex)
|
||||||
|
publicKeyUncompressed: string; // 非压缩格式公钥 (65 bytes hex)
|
||||||
|
partyShares: PartyShareResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartyShareResult {
|
||||||
|
partyId: string;
|
||||||
|
partyIndex: number;
|
||||||
|
encryptedShareData: string; // 加密的分片数据
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningRequest {
|
||||||
|
sessionId: string;
|
||||||
|
publicKey: string;
|
||||||
|
messageHash: string; // 32 bytes hex
|
||||||
|
signerParties: PartyInfo[];
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningResult {
|
||||||
|
sessionId: string;
|
||||||
|
signature: {
|
||||||
|
r: string; // 32 bytes hex
|
||||||
|
s: string; // 32 bytes hex
|
||||||
|
v: number; // recovery id (0 or 1)
|
||||||
|
};
|
||||||
|
messageHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MpcClientService {
|
||||||
|
private readonly logger = new Logger(MpcClientService.name);
|
||||||
|
private readonly mpcServiceUrl: string; // mpc-service (NestJS) URL
|
||||||
|
private readonly mpcMode: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
// 连接 mpc-service (NestJS) 而不是直接连接 mpc-system (Go)
|
||||||
|
this.mpcServiceUrl = this.configService.get<string>('MPC_SERVICE_URL', 'http://localhost:3001');
|
||||||
|
this.mpcMode = this.configService.get<string>('MPC_MODE', 'local');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成新的会话ID
|
||||||
|
*/
|
||||||
|
generateSessionId(): string {
|
||||||
|
return `mpc-${randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 2-of-3 MPC 密钥生成
|
||||||
|
*
|
||||||
|
* 三个参与方:
|
||||||
|
* - Party 0 (SERVER): 服务端持有,用于常规签名
|
||||||
|
* - Party 1 (CLIENT): 用户设备持有,返回给客户端
|
||||||
|
* - Party 2 (BACKUP): 备份服务持有,用于恢复
|
||||||
|
*
|
||||||
|
* 调用路径: identity-service → mpc-service → mpc-system (Go)
|
||||||
|
*/
|
||||||
|
async executeKeygen(request: KeygenRequest): Promise<KeygenResult> {
|
||||||
|
this.logger.log(`Starting MPC keygen: session=${request.sessionId}, t=${request.threshold}, n=${request.totalParties}`);
|
||||||
|
|
||||||
|
// 开发模式使用本地模拟
|
||||||
|
if (this.mpcMode === 'local') {
|
||||||
|
return this.executeLocalKeygen(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用 mpc-service 的 keygen 同步接口
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.post<KeygenResult>(
|
||||||
|
`${this.mpcServiceUrl}/mpc-party/keygen/participate-sync`,
|
||||||
|
{
|
||||||
|
sessionId: request.sessionId,
|
||||||
|
partyId: 'server-party',
|
||||||
|
joinToken: this.generateJoinToken(request.sessionId),
|
||||||
|
shareType: 'SERVER',
|
||||||
|
userId: request.parties.find(p => p.partyType === 'CLIENT')?.partyId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 300000, // 5分钟超时
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`MPC keygen completed: session=${request.sessionId}, publicKey=${response.data.publicKey}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`MPC keygen failed: session=${request.sessionId}`, error);
|
||||||
|
throw new Error(`MPC keygen failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成加入会话的 token
|
||||||
|
*/
|
||||||
|
private generateJoinToken(sessionId: string): string {
|
||||||
|
return createHash('sha256').update(`${sessionId}-${Date.now()}`).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 MPC 签名
|
||||||
|
*
|
||||||
|
* 至少需要 threshold 个参与方来完成签名
|
||||||
|
* 调用路径: identity-service → mpc-service → mpc-system (Go)
|
||||||
|
*/
|
||||||
|
async executeSigning(request: SigningRequest): Promise<SigningResult> {
|
||||||
|
this.logger.log(`Starting MPC signing: session=${request.sessionId}, messageHash=${request.messageHash}`);
|
||||||
|
|
||||||
|
// 开发模式使用本地模拟
|
||||||
|
if (this.mpcMode === 'local') {
|
||||||
|
return this.executeLocalSigning(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用 mpc-service 的 signing 同步接口
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.post<SigningResult>(
|
||||||
|
`${this.mpcServiceUrl}/mpc-party/signing/participate-sync`,
|
||||||
|
{
|
||||||
|
sessionId: request.sessionId,
|
||||||
|
partyId: 'server-party',
|
||||||
|
joinToken: this.generateJoinToken(request.sessionId),
|
||||||
|
messageHash: request.messageHash,
|
||||||
|
publicKey: request.publicKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 120000, // 2分钟超时
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`MPC signing completed: session=${request.sessionId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`MPC signing failed: session=${request.sessionId}`, error);
|
||||||
|
throw new Error(`MPC signing failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行本地模拟的 MPC keygen (用于开发测试)
|
||||||
|
*
|
||||||
|
* 注意: 这是一个简化的本地实现,仅用于测试
|
||||||
|
* 生产环境应该调用真正的 MPC 系统
|
||||||
|
*/
|
||||||
|
async executeLocalKeygen(request: KeygenRequest): Promise<KeygenResult> {
|
||||||
|
this.logger.log(`Starting LOCAL MPC keygen (test mode): session=${request.sessionId}`);
|
||||||
|
|
||||||
|
// 使用 ethers 生成密钥对 (简化版本,非真正的 MPC)
|
||||||
|
const { ethers } = await import('ethers');
|
||||||
|
|
||||||
|
// 生成随机私钥
|
||||||
|
const wallet = ethers.Wallet.createRandom();
|
||||||
|
const privateKey = wallet.privateKey;
|
||||||
|
const publicKey = wallet.publicKey;
|
||||||
|
|
||||||
|
// 压缩公钥 (33 bytes)
|
||||||
|
const compressedPubKey = ethers.SigningKey.computePublicKey(publicKey, true);
|
||||||
|
|
||||||
|
// 模拟分片数据 (实际上是完整私钥的加密版本,仅用于测试)
|
||||||
|
const partyShares: PartyShareResult[] = request.parties.map((party) => ({
|
||||||
|
partyId: party.partyId,
|
||||||
|
partyIndex: party.partyIndex,
|
||||||
|
encryptedShareData: this.encryptShareData(privateKey, party.partyId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: request.sessionId,
|
||||||
|
publicKey: compressedPubKey.slice(2), // 去掉 0x 前缀
|
||||||
|
publicKeyUncompressed: publicKey.slice(2),
|
||||||
|
partyShares,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行本地模拟的 MPC 签名 (用于开发测试)
|
||||||
|
*/
|
||||||
|
async executeLocalSigning(request: SigningRequest): Promise<SigningResult> {
|
||||||
|
this.logger.log(`Starting LOCAL MPC signing (test mode): session=${request.sessionId}`);
|
||||||
|
|
||||||
|
const { ethers } = await import('ethers');
|
||||||
|
|
||||||
|
// 从第一个参与方获取分片数据并解密 (测试模式)
|
||||||
|
// 实际生产环境需要多方协作签名
|
||||||
|
const signerParty = request.signerParties[0];
|
||||||
|
|
||||||
|
// 这里我们无法真正恢复私钥,所以使用一个测试签名
|
||||||
|
// 生产环境应该使用真正的 MPC 协议
|
||||||
|
const messageHashBytes = Buffer.from(request.messageHash, 'hex');
|
||||||
|
|
||||||
|
// 创建一个临时钱包用于签名 (仅测试)
|
||||||
|
const testWallet = new ethers.Wallet(
|
||||||
|
'0x' + createHash('sha256').update(request.publicKey).digest('hex'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = testWallet.signingKey.sign(messageHashBytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: request.sessionId,
|
||||||
|
signature: {
|
||||||
|
r: signature.r.slice(2),
|
||||||
|
s: signature.s.slice(2),
|
||||||
|
v: signature.v - 27, // 转换为 0 或 1
|
||||||
|
},
|
||||||
|
messageHash: request.messageHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密分片数据 (简化版本)
|
||||||
|
*/
|
||||||
|
private encryptShareData(data: string, key: string): string {
|
||||||
|
// 简化的加密,实际应该使用 AES-GCM
|
||||||
|
const cipher = createHash('sha256').update(key).digest('hex');
|
||||||
|
return Buffer.from(data).toString('base64') + '.' + cipher.slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算消息摘要 (用于签名)
|
||||||
|
*/
|
||||||
|
computeMessageHash(message: string): string {
|
||||||
|
return createHash('sha256').update(message).digest('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
291
backend/services/identity-service/src/infrastructure/external/mpc/mpc-wallet.service.ts
vendored
Normal file
291
backend/services/identity-service/src/infrastructure/external/mpc/mpc-wallet.service.ts
vendored
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
/**
|
||||||
|
* MPC Wallet Service
|
||||||
|
*
|
||||||
|
* 使用 MPC 2-of-3 协议生成三链钱包地址
|
||||||
|
* 并对地址进行签名验证
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { MpcClientService, KeygenResult, SigningResult } from './mpc-client.service';
|
||||||
|
|
||||||
|
export interface MpcWalletGenerationParams {
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChainWalletInfo {
|
||||||
|
chainType: 'KAVA' | 'DST' | 'BSC';
|
||||||
|
address: string;
|
||||||
|
publicKey: string;
|
||||||
|
addressDigest: string;
|
||||||
|
signature: {
|
||||||
|
r: string;
|
||||||
|
s: string;
|
||||||
|
v: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MpcWalletGenerationResult {
|
||||||
|
publicKey: string; // MPC 公钥
|
||||||
|
serverShareData: string; // 服务端分片 (加密后存储)
|
||||||
|
clientShareData: string; // 客户端分片 (返回给用户设备)
|
||||||
|
backupShareData: string; // 备份分片 (存储在备份服务)
|
||||||
|
wallets: ChainWalletInfo[]; // 三条链的钱包信息
|
||||||
|
sessionId: string; // MPC 会话ID
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MpcWalletService {
|
||||||
|
private readonly logger = new Logger(MpcWalletService.name);
|
||||||
|
private readonly useLocalMpc: boolean;
|
||||||
|
|
||||||
|
// 三条链的地址生成配置
|
||||||
|
private readonly chainConfigs = {
|
||||||
|
BSC: {
|
||||||
|
name: 'Binance Smart Chain',
|
||||||
|
prefix: '0x',
|
||||||
|
derivationPath: "m/44'/60'/0'/0/0", // EVM 兼容链
|
||||||
|
addressType: 'evm' as const,
|
||||||
|
},
|
||||||
|
KAVA: {
|
||||||
|
name: 'Kava EVM',
|
||||||
|
prefix: '0x',
|
||||||
|
derivationPath: "m/44'/60'/0'/0/0", // Kava EVM 使用以太坊兼容地址
|
||||||
|
addressType: 'evm' as const,
|
||||||
|
},
|
||||||
|
DST: {
|
||||||
|
name: 'Durian Star Token',
|
||||||
|
prefix: 'dst', // Cosmos Bech32 前缀
|
||||||
|
derivationPath: "m/44'/118'/0'/0/0", // Cosmos 标准路径
|
||||||
|
addressType: 'cosmos' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly mpcClient: MpcClientService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
// 开发环境使用本地模拟 MPC
|
||||||
|
this.useLocalMpc = this.configService.get<string>('MPC_MODE', 'local') === 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 MPC 2-of-3 生成三链钱包
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 生成 MPC 密钥 (2-of-3)
|
||||||
|
* 2. 从公钥派生三条链的地址
|
||||||
|
* 3. 计算地址摘要
|
||||||
|
* 4. 使用 MPC 签名对摘要进行签名
|
||||||
|
* 5. 返回完整的钱包信息
|
||||||
|
*/
|
||||||
|
async generateMpcWallet(params: MpcWalletGenerationParams): Promise<MpcWalletGenerationResult> {
|
||||||
|
this.logger.log(`Generating MPC wallet for user=${params.userId}, device=${params.deviceId}`);
|
||||||
|
|
||||||
|
// Step 1: 生成 MPC 密钥
|
||||||
|
const sessionId = this.mpcClient.generateSessionId();
|
||||||
|
const keygenResult = await this.executeKeygen(sessionId, params);
|
||||||
|
|
||||||
|
this.logger.log(`MPC keygen completed: publicKey=${keygenResult.publicKey}`);
|
||||||
|
|
||||||
|
// Step 2: 从公钥派生三条链的地址
|
||||||
|
const walletAddresses = await this.deriveChainAddresses(keygenResult.publicKey);
|
||||||
|
|
||||||
|
// Step 3: 计算地址摘要
|
||||||
|
const addressDigest = this.computeAddressDigest(walletAddresses);
|
||||||
|
|
||||||
|
// Step 4: 使用 MPC 签名对摘要进行签名
|
||||||
|
const signingSessionId = this.mpcClient.generateSessionId();
|
||||||
|
const signingResult = await this.executeSigning(
|
||||||
|
signingSessionId,
|
||||||
|
keygenResult.publicKey,
|
||||||
|
addressDigest,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`MPC signing completed: r=${signingResult.signature.r}`);
|
||||||
|
|
||||||
|
// Step 5: 构建钱包信息
|
||||||
|
const wallets: ChainWalletInfo[] = walletAddresses.map((wa) => ({
|
||||||
|
chainType: wa.chainType as 'KAVA' | 'DST' | 'BSC',
|
||||||
|
address: wa.address,
|
||||||
|
publicKey: keygenResult.publicKey,
|
||||||
|
addressDigest: this.computeSingleAddressDigest(wa.address, wa.chainType),
|
||||||
|
signature: signingResult.signature,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 提取各方分片
|
||||||
|
const serverShare = keygenResult.partyShares.find((s) => s.partyIndex === 0);
|
||||||
|
const clientShare = keygenResult.partyShares.find((s) => s.partyIndex === 1);
|
||||||
|
const backupShare = keygenResult.partyShares.find((s) => s.partyIndex === 2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: keygenResult.publicKey,
|
||||||
|
serverShareData: serverShare?.encryptedShareData || '',
|
||||||
|
clientShareData: clientShare?.encryptedShareData || '',
|
||||||
|
backupShareData: backupShare?.encryptedShareData || '',
|
||||||
|
wallets,
|
||||||
|
sessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证钱包地址签名
|
||||||
|
*
|
||||||
|
* 用于检测地址是否被篡改
|
||||||
|
*/
|
||||||
|
async verifyWalletSignature(
|
||||||
|
address: string,
|
||||||
|
chainType: string,
|
||||||
|
publicKey: string,
|
||||||
|
signature: { r: string; s: string; v: number },
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { ethers } = await import('ethers');
|
||||||
|
|
||||||
|
// 计算地址摘要
|
||||||
|
const digest = this.computeSingleAddressDigest(address, chainType);
|
||||||
|
const digestBytes = Buffer.from(digest, 'hex');
|
||||||
|
|
||||||
|
// 重建签名
|
||||||
|
const sig = ethers.Signature.from({
|
||||||
|
r: '0x' + signature.r,
|
||||||
|
s: '0x' + signature.s,
|
||||||
|
v: signature.v + 27,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 从签名恢复公钥
|
||||||
|
const recoveredPubKey = ethers.SigningKey.recoverPublicKey(digestBytes, sig);
|
||||||
|
|
||||||
|
// 压缩公钥并比较
|
||||||
|
const compressedRecovered = ethers.SigningKey.computePublicKey(recoveredPubKey, true);
|
||||||
|
|
||||||
|
return compressedRecovered.slice(2).toLowerCase() === publicKey.toLowerCase();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Signature verification failed: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 MPC 密钥生成
|
||||||
|
*/
|
||||||
|
private async executeKeygen(sessionId: string, params: MpcWalletGenerationParams): Promise<KeygenResult> {
|
||||||
|
const request = {
|
||||||
|
sessionId,
|
||||||
|
threshold: 2,
|
||||||
|
totalParties: 3,
|
||||||
|
parties: [
|
||||||
|
{ partyId: `server-${params.userId}`, partyIndex: 0, partyType: 'SERVER' as const },
|
||||||
|
{ partyId: `client-${params.deviceId}`, partyIndex: 1, partyType: 'CLIENT' as const },
|
||||||
|
{ partyId: `backup-${params.userId}`, partyIndex: 2, partyType: 'BACKUP' as const },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.useLocalMpc) {
|
||||||
|
return this.mpcClient.executeLocalKeygen(request);
|
||||||
|
}
|
||||||
|
return this.mpcClient.executeKeygen(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 MPC 签名
|
||||||
|
*/
|
||||||
|
private async executeSigning(
|
||||||
|
sessionId: string,
|
||||||
|
publicKey: string,
|
||||||
|
messageHash: string,
|
||||||
|
): Promise<SigningResult> {
|
||||||
|
const request = {
|
||||||
|
sessionId,
|
||||||
|
publicKey,
|
||||||
|
messageHash,
|
||||||
|
threshold: 2,
|
||||||
|
signerParties: [
|
||||||
|
{ partyId: 'server', partyIndex: 0, partyType: 'SERVER' as const },
|
||||||
|
{ partyId: 'backup', partyIndex: 2, partyType: 'BACKUP' as const },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.useLocalMpc) {
|
||||||
|
return this.mpcClient.executeLocalSigning(request);
|
||||||
|
}
|
||||||
|
return this.mpcClient.executeSigning(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 MPC 公钥派生三条链的地址
|
||||||
|
*
|
||||||
|
* - BSC/KAVA: EVM 地址 (keccak256)
|
||||||
|
* - DST: Cosmos Bech32 地址 (ripemd160(sha256))
|
||||||
|
*/
|
||||||
|
private async deriveChainAddresses(publicKey: string): Promise<{ chainType: string; address: string }[]> {
|
||||||
|
const { ethers } = await import('ethers');
|
||||||
|
const { bech32 } = await import('bech32');
|
||||||
|
|
||||||
|
// MPC 公钥 (压缩格式,33 bytes)
|
||||||
|
const pubKeyHex = publicKey.startsWith('0x') ? publicKey : '0x' + publicKey;
|
||||||
|
const compressedPubKeyBytes = Buffer.from(pubKeyHex.replace('0x', ''), 'hex');
|
||||||
|
|
||||||
|
// 解压公钥 (如果是压缩格式)
|
||||||
|
let uncompressedPubKey: string;
|
||||||
|
if (pubKeyHex.length === 68) {
|
||||||
|
// 压缩格式 (33 bytes = 66 hex chars + 0x)
|
||||||
|
uncompressedPubKey = ethers.SigningKey.computePublicKey(pubKeyHex, false);
|
||||||
|
} else {
|
||||||
|
uncompressedPubKey = pubKeyHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== EVM 地址派生 (BSC, KAVA) =====
|
||||||
|
// 地址 = keccak256(公钥[1:])[12:]
|
||||||
|
const pubKeyBytes = Buffer.from(uncompressedPubKey.slice(4), 'hex'); // 去掉 0x04 前缀
|
||||||
|
const addressHash = ethers.keccak256(pubKeyBytes);
|
||||||
|
const evmAddress = ethers.getAddress('0x' + addressHash.slice(-40));
|
||||||
|
|
||||||
|
// ===== Cosmos 地址派生 (DST) =====
|
||||||
|
// 地址 = bech32(prefix, ripemd160(sha256(compressed_pubkey)))
|
||||||
|
const sha256Hash = createHash('sha256').update(compressedPubKeyBytes).digest();
|
||||||
|
const ripemd160Hash = createHash('ripemd160').update(sha256Hash).digest();
|
||||||
|
const dstAddress = bech32.encode(this.chainConfigs.DST.prefix, bech32.toWords(ripemd160Hash));
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ chainType: 'BSC', address: evmAddress },
|
||||||
|
{ chainType: 'KAVA', address: evmAddress },
|
||||||
|
{ chainType: 'DST', address: dstAddress },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算三个地址的联合摘要
|
||||||
|
*
|
||||||
|
* digest = SHA256(BSC地址 + KAVA地址 + DST地址)
|
||||||
|
*/
|
||||||
|
private computeAddressDigest(addresses: { chainType: string; address: string }[]): string {
|
||||||
|
// 按链类型排序以确保一致性
|
||||||
|
const sortedAddresses = [...addresses].sort((a, b) =>
|
||||||
|
a.chainType.localeCompare(b.chainType),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 拼接地址
|
||||||
|
const concatenated = sortedAddresses.map((a) => a.address.toLowerCase()).join('');
|
||||||
|
|
||||||
|
// 计算 SHA256 摘要
|
||||||
|
return createHash('sha256').update(concatenated).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算单个地址的摘要
|
||||||
|
*/
|
||||||
|
private computeSingleAddressDigest(address: string, chainType: string): string {
|
||||||
|
const message = `${chainType}:${address.toLowerCase()}`;
|
||||||
|
return createHash('sha256').update(message).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有支持的链类型
|
||||||
|
*/
|
||||||
|
getSupportedChains(): string[] {
|
||||||
|
return Object.keys(this.chainConfigs);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/services/identity-service/src/infrastructure/external/mpc/mpc.module.ts
vendored
Normal file
16
backend/services/identity-service/src/infrastructure/external/mpc/mpc.module.ts
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { MpcWalletService } from './mpc-wallet.service';
|
||||||
|
import { MpcClientService } from './mpc-client.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
HttpModule.register({
|
||||||
|
timeout: 300000, // MPC 操作可能需要较长时间
|
||||||
|
maxRedirects: 5,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [MpcWalletService, MpcClientService],
|
||||||
|
exports: [MpcWalletService, MpcClientService],
|
||||||
|
})
|
||||||
|
export class MpcModule {}
|
||||||
|
|
@ -1,31 +1,53 @@
|
||||||
import { Module, Global } from '@nestjs/common';
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { PrismaService } from './persistence/prisma/prisma.service';
|
import { PrismaService } from './persistence/prisma/prisma.service';
|
||||||
import { UserAccountRepositoryImpl } from './persistence/repositories/user-account.repository.impl';
|
import { UserAccountRepositoryImpl } from './persistence/repositories/user-account.repository.impl';
|
||||||
|
import { MpcKeyShareRepositoryImpl } from './persistence/repositories/mpc-key-share.repository.impl';
|
||||||
import { UserAccountMapper } from './persistence/mappers/user-account.mapper';
|
import { UserAccountMapper } from './persistence/mappers/user-account.mapper';
|
||||||
import { RedisService } from './redis/redis.service';
|
import { RedisService } from './redis/redis.service';
|
||||||
import { EventPublisherService } from './kafka/event-publisher.service';
|
import { EventPublisherService } from './kafka/event-publisher.service';
|
||||||
import { SmsService } from './external/sms/sms.service';
|
import { SmsService } from './external/sms/sms.service';
|
||||||
import { WalletGeneratorServiceImpl } from './external/blockchain/wallet-generator.service.impl';
|
import { WalletGeneratorServiceImpl } from './external/blockchain/wallet-generator.service.impl';
|
||||||
|
import { MpcClientService, MpcWalletService } from './external/mpc';
|
||||||
|
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [
|
||||||
|
HttpModule.register({
|
||||||
|
timeout: 300000,
|
||||||
|
maxRedirects: 5,
|
||||||
|
}),
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
UserAccountRepositoryImpl,
|
UserAccountRepositoryImpl,
|
||||||
|
{
|
||||||
|
provide: MPC_KEY_SHARE_REPOSITORY,
|
||||||
|
useClass: MpcKeyShareRepositoryImpl,
|
||||||
|
},
|
||||||
UserAccountMapper,
|
UserAccountMapper,
|
||||||
RedisService,
|
RedisService,
|
||||||
EventPublisherService,
|
EventPublisherService,
|
||||||
SmsService,
|
SmsService,
|
||||||
WalletGeneratorServiceImpl,
|
WalletGeneratorServiceImpl,
|
||||||
|
MpcClientService,
|
||||||
|
MpcWalletService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
UserAccountRepositoryImpl,
|
UserAccountRepositoryImpl,
|
||||||
|
{
|
||||||
|
provide: MPC_KEY_SHARE_REPOSITORY,
|
||||||
|
useClass: MpcKeyShareRepositoryImpl,
|
||||||
|
},
|
||||||
UserAccountMapper,
|
UserAccountMapper,
|
||||||
RedisService,
|
RedisService,
|
||||||
EventPublisherService,
|
EventPublisherService,
|
||||||
SmsService,
|
SmsService,
|
||||||
WalletGeneratorServiceImpl,
|
WalletGeneratorServiceImpl,
|
||||||
|
MpcClientService,
|
||||||
|
MpcWalletService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class InfrastructureModule {}
|
export class InfrastructureModule {}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ export interface WalletAddressEntity {
|
||||||
userId: bigint;
|
userId: bigint;
|
||||||
chainType: string;
|
chainType: string;
|
||||||
address: string;
|
address: string;
|
||||||
encryptedMnemonic: string | null;
|
publicKey: string;
|
||||||
|
addressDigest: string;
|
||||||
|
mpcSignatureR: string;
|
||||||
|
mpcSignatureS: string;
|
||||||
|
mpcSignatureV: number;
|
||||||
status: string;
|
status: string;
|
||||||
boundAt: Date;
|
boundAt: Date;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
MpcKeyShareRepository,
|
||||||
|
MpcKeyShareData,
|
||||||
|
MpcKeyShare,
|
||||||
|
} from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||||
|
import { UserId } from '@/domain/value-objects';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MpcKeyShareRepositoryImpl implements MpcKeyShareRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async saveServerShare(data: MpcKeyShareData): Promise<MpcKeyShare> {
|
||||||
|
const result = await this.prisma.mpcKeyShare.create({
|
||||||
|
data: {
|
||||||
|
userId: data.userId,
|
||||||
|
publicKey: data.publicKey,
|
||||||
|
partyIndex: data.partyIndex,
|
||||||
|
threshold: data.threshold,
|
||||||
|
totalParties: data.totalParties,
|
||||||
|
encryptedShareData: data.encryptedShareData,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toDomain(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: UserId): Promise<MpcKeyShare | null> {
|
||||||
|
const result = await this.prisma.mpcKeyShare.findUnique({
|
||||||
|
where: { userId: BigInt(userId.value) },
|
||||||
|
});
|
||||||
|
return result ? this.toDomain(result) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByPublicKey(publicKey: string): Promise<MpcKeyShare | null> {
|
||||||
|
const result = await this.prisma.mpcKeyShare.findUnique({
|
||||||
|
where: { publicKey },
|
||||||
|
});
|
||||||
|
return result ? this.toDomain(result) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(shareId: bigint, status: string): Promise<void> {
|
||||||
|
await this.prisma.mpcKeyShare.update({
|
||||||
|
where: { shareId },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotateShare(shareId: bigint, newEncryptedData: string): Promise<void> {
|
||||||
|
await this.prisma.mpcKeyShare.update({
|
||||||
|
where: { shareId },
|
||||||
|
data: {
|
||||||
|
encryptedShareData: newEncryptedData,
|
||||||
|
rotatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(data: any): MpcKeyShare {
|
||||||
|
return {
|
||||||
|
shareId: data.shareId,
|
||||||
|
userId: data.userId,
|
||||||
|
publicKey: data.publicKey,
|
||||||
|
partyIndex: data.partyIndex,
|
||||||
|
threshold: data.threshold,
|
||||||
|
totalParties: data.totalParties,
|
||||||
|
encryptedShareData: data.encryptedShareData,
|
||||||
|
status: data.status,
|
||||||
|
createdAt: data.createdAt,
|
||||||
|
rotatedAt: data.rotatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -90,7 +90,11 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
userId: BigInt(userId.value),
|
userId: BigInt(userId.value),
|
||||||
chainType: w.chainType,
|
chainType: w.chainType,
|
||||||
address: w.address,
|
address: w.address,
|
||||||
encryptedMnemonic: w.encryptedMnemonic,
|
publicKey: w.publicKey,
|
||||||
|
addressDigest: w.addressDigest,
|
||||||
|
mpcSignatureR: w.mpcSignature.r,
|
||||||
|
mpcSignatureS: w.mpcSignature.s,
|
||||||
|
mpcSignatureV: w.mpcSignature.v,
|
||||||
status: w.status,
|
status: w.status,
|
||||||
boundAt: w.boundAt,
|
boundAt: w.boundAt,
|
||||||
})),
|
})),
|
||||||
|
|
@ -205,7 +209,11 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
||||||
userId: w.userId.toString(),
|
userId: w.userId.toString(),
|
||||||
chainType: w.chainType as ChainType,
|
chainType: w.chainType as ChainType,
|
||||||
address: w.address,
|
address: w.address,
|
||||||
encryptedMnemonic: w.encryptedMnemonic || '',
|
publicKey: w.publicKey || '',
|
||||||
|
addressDigest: w.addressDigest || '',
|
||||||
|
mpcSignatureR: w.mpcSignatureR || '',
|
||||||
|
mpcSignatureS: w.mpcSignatureS || '',
|
||||||
|
mpcSignatureV: w.mpcSignatureV || 0,
|
||||||
status: w.status as AddressStatus,
|
status: w.status as AddressStatus,
|
||||||
boundAt: w.boundAt,
|
boundAt: w.boundAt,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
/**
|
||||||
|
* E2E 测试: 自动创建账号 (MPC 2-of-3)
|
||||||
|
*
|
||||||
|
* 测试流程:
|
||||||
|
* 1. 调用 POST /user/auto-create 创建账号
|
||||||
|
* 2. 验证返回的账号信息
|
||||||
|
* 3. 验证钱包地址格式
|
||||||
|
* 4. 验证 MPC 分片数据
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
|
import * as request from 'supertest';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('AutoCreateAccount (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /user/auto-create', () => {
|
||||||
|
it('should create account with MPC 2-of-3 wallet', async () => {
|
||||||
|
const deviceId = `test-device-${Date.now()}`;
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/user/auto-create')
|
||||||
|
.send({
|
||||||
|
deviceId,
|
||||||
|
deviceName: 'Test Device',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const body = response.body;
|
||||||
|
|
||||||
|
// 验证基本字段
|
||||||
|
expect(body).toHaveProperty('userId');
|
||||||
|
expect(body).toHaveProperty('accountSequence');
|
||||||
|
expect(body).toHaveProperty('referralCode');
|
||||||
|
expect(body).toHaveProperty('accessToken');
|
||||||
|
expect(body).toHaveProperty('refreshToken');
|
||||||
|
expect(body).toHaveProperty('walletAddresses');
|
||||||
|
|
||||||
|
// 验证钱包地址格式
|
||||||
|
// BSC 和 KAVA 是 EVM 兼容链,使用 0x 前缀的地址
|
||||||
|
expect(body.walletAddresses.bsc).toMatch(/^0x[a-fA-F0-9]{40}$/);
|
||||||
|
expect(body.walletAddresses.kava).toMatch(/^0x[a-fA-F0-9]{40}$/);
|
||||||
|
// DST 是 Cosmos 链,使用 Bech32 格式 (dst1...)
|
||||||
|
expect(body.walletAddresses.dst).toMatch(/^dst1[a-z0-9]{38,58}$/);
|
||||||
|
|
||||||
|
// BSC 和 KAVA 地址应该相同 (同一个 MPC 公钥派生的 EVM 地址)
|
||||||
|
expect(body.walletAddresses.bsc).toBe(body.walletAddresses.kava);
|
||||||
|
// DST 地址是不同格式,不应该与 EVM 地址相同
|
||||||
|
expect(body.walletAddresses.dst).not.toBe(body.walletAddresses.bsc);
|
||||||
|
|
||||||
|
// 验证 MPC 相关字段
|
||||||
|
expect(body).toHaveProperty('clientShareData');
|
||||||
|
expect(body).toHaveProperty('publicKey');
|
||||||
|
expect(body.clientShareData).toBeTruthy();
|
||||||
|
expect(body.publicKey).toBeTruthy();
|
||||||
|
|
||||||
|
// MPC 模式下 mnemonic 应该为空
|
||||||
|
expect(body.mnemonic).toBe('');
|
||||||
|
|
||||||
|
// 验证账号序列号是正整数
|
||||||
|
expect(typeof body.accountSequence).toBe('number');
|
||||||
|
expect(body.accountSequence).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// 验证推荐码格式 (假设是 6-10 位字母数字)
|
||||||
|
expect(body.referralCode).toMatch(/^[A-Z0-9]{6,10}$/);
|
||||||
|
|
||||||
|
console.log('Created account:', {
|
||||||
|
userId: body.userId,
|
||||||
|
accountSequence: body.accountSequence,
|
||||||
|
referralCode: body.referralCode,
|
||||||
|
publicKey: body.publicKey?.substring(0, 20) + '...',
|
||||||
|
bscAddress: body.walletAddresses.bsc,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create different accounts for different devices', async () => {
|
||||||
|
const deviceId1 = `test-device-a-${Date.now()}`;
|
||||||
|
const deviceId2 = `test-device-b-${Date.now()}`;
|
||||||
|
|
||||||
|
const [response1, response2] = await Promise.all([
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post('/user/auto-create')
|
||||||
|
.send({ deviceId: deviceId1 }),
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post('/user/auto-create')
|
||||||
|
.send({ deviceId: deviceId2 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 两个账号应该有不同的序列号和钱包地址
|
||||||
|
expect(response1.body.accountSequence).not.toBe(response2.body.accountSequence);
|
||||||
|
expect(response1.body.walletAddresses.bsc).not.toBe(response2.body.walletAddresses.bsc);
|
||||||
|
expect(response1.body.publicKey).not.toBe(response2.body.publicKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid device id', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/user/auto-create')
|
||||||
|
.send({
|
||||||
|
deviceId: '', // 空设备ID
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle inviter referral code', async () => {
|
||||||
|
// 先创建一个账号作为邀请人
|
||||||
|
const inviterResponse = await request(app.getHttpServer())
|
||||||
|
.post('/user/auto-create')
|
||||||
|
.send({ deviceId: `inviter-${Date.now()}` })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const inviterReferralCode = inviterResponse.body.referralCode;
|
||||||
|
|
||||||
|
// 使用邀请码创建新账号
|
||||||
|
const inviteeResponse = await request(app.getHttpServer())
|
||||||
|
.post('/user/auto-create')
|
||||||
|
.send({
|
||||||
|
deviceId: `invitee-${Date.now()}`,
|
||||||
|
inviterReferralCode,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(inviteeResponse.body.accountSequence).toBeGreaterThan(
|
||||||
|
inviterResponse.body.accountSequence,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx create-next-app:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../storage/secure_storage.dart';
|
import '../storage/secure_storage.dart';
|
||||||
import '../storage/local_storage.dart';
|
import '../storage/local_storage.dart';
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
import '../services/account_service.dart';
|
||||||
|
|
||||||
// Storage Providers
|
// Storage Providers
|
||||||
final secureStorageProvider = Provider<SecureStorage>((ref) {
|
final secureStorageProvider = Provider<SecureStorage>((ref) {
|
||||||
|
|
@ -11,6 +13,22 @@ final localStorageProvider = Provider<LocalStorage>((ref) {
|
||||||
throw UnimplementedError('LocalStorage must be initialized before use');
|
throw UnimplementedError('LocalStorage must be initialized before use');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API Client Provider
|
||||||
|
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||||
|
final secureStorage = ref.watch(secureStorageProvider);
|
||||||
|
return ApiClient(secureStorage: secureStorage);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Account Service Provider
|
||||||
|
final accountServiceProvider = Provider<AccountService>((ref) {
|
||||||
|
final apiClient = ref.watch(apiClientProvider);
|
||||||
|
final secureStorage = ref.watch(secureStorageProvider);
|
||||||
|
return AccountService(
|
||||||
|
apiClient: apiClient,
|
||||||
|
secureStorage: secureStorage,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Override provider with initialized instance
|
// Override provider with initialized instance
|
||||||
ProviderContainer createProviderContainer(LocalStorage localStorage) {
|
ProviderContainer createProviderContainer(LocalStorage localStorage) {
|
||||||
return ProviderContainer(
|
return ProviderContainer(
|
||||||
|
|
|
||||||
|
|
@ -73,3 +73,40 @@ class StorageException implements Exception {
|
||||||
@override
|
@override
|
||||||
String toString() => 'StorageException: $message';
|
String toString() => 'StorageException: $message';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ApiException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final int? statusCode;
|
||||||
|
|
||||||
|
const ApiException(this.message, {this.statusCode});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'ApiException: $message (status: $statusCode)';
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnauthorizedException implements Exception {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const UnauthorizedException([this.message = '未授权,请重新登录']);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'UnauthorizedException: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
class ForbiddenException implements Exception {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const ForbiddenException([this.message = '访问被拒绝']);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'ForbiddenException: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotFoundException implements Exception {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const NotFoundException([this.message = '资源不存在']);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'NotFoundException: $message';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../storage/secure_storage.dart';
|
||||||
|
import '../storage/storage_keys.dart';
|
||||||
|
import '../errors/exceptions.dart';
|
||||||
|
|
||||||
|
/// API 客户端
|
||||||
|
///
|
||||||
|
/// 封装 Dio HTTP 客户端,提供:
|
||||||
|
/// - 自动 Token 注入
|
||||||
|
/// - 错误处理
|
||||||
|
/// - 请求/响应日志
|
||||||
|
class ApiClient {
|
||||||
|
final Dio _dio;
|
||||||
|
final SecureStorage _secureStorage;
|
||||||
|
|
||||||
|
ApiClient({
|
||||||
|
required SecureStorage secureStorage,
|
||||||
|
String? baseUrl,
|
||||||
|
}) : _secureStorage = secureStorage,
|
||||||
|
_dio = Dio(BaseOptions(
|
||||||
|
baseUrl: baseUrl ?? _defaultBaseUrl,
|
||||||
|
connectTimeout: const Duration(seconds: 30),
|
||||||
|
receiveTimeout: const Duration(seconds: 30),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
)) {
|
||||||
|
_setupInterceptors();
|
||||||
|
}
|
||||||
|
|
||||||
|
static const String _defaultBaseUrl = 'http://10.0.2.2:3001'; // Android 模拟器访问本机
|
||||||
|
|
||||||
|
/// 设置拦截器
|
||||||
|
void _setupInterceptors() {
|
||||||
|
_dio.interceptors.add(InterceptorsWrapper(
|
||||||
|
onRequest: _onRequest,
|
||||||
|
onResponse: _onResponse,
|
||||||
|
onError: _onError,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Debug 模式下添加日志
|
||||||
|
if (kDebugMode) {
|
||||||
|
_dio.interceptors.add(LogInterceptor(
|
||||||
|
requestBody: true,
|
||||||
|
responseBody: true,
|
||||||
|
error: true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求拦截器 - 自动注入 Token
|
||||||
|
Future<void> _onRequest(
|
||||||
|
RequestOptions options,
|
||||||
|
RequestInterceptorHandler handler,
|
||||||
|
) async {
|
||||||
|
// 不需要 Token 的接口
|
||||||
|
final publicPaths = [
|
||||||
|
'/user/auto-create',
|
||||||
|
'/user/login',
|
||||||
|
'/user/register',
|
||||||
|
'/user/send-sms-code',
|
||||||
|
'/user/recover-by-mnemonic',
|
||||||
|
'/user/recover-by-phone',
|
||||||
|
];
|
||||||
|
|
||||||
|
final isPublic = publicPaths.any((path) => options.path.contains(path));
|
||||||
|
|
||||||
|
if (!isPublic) {
|
||||||
|
final token = await _secureStorage.read(key: StorageKeys.accessToken);
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 响应拦截器
|
||||||
|
void _onResponse(
|
||||||
|
Response response,
|
||||||
|
ResponseInterceptorHandler handler,
|
||||||
|
) {
|
||||||
|
handler.next(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 错误拦截器
|
||||||
|
Future<void> _onError(
|
||||||
|
DioException error,
|
||||||
|
ErrorInterceptorHandler handler,
|
||||||
|
) async {
|
||||||
|
// 401 错误尝试刷新 Token
|
||||||
|
if (error.response?.statusCode == 401) {
|
||||||
|
try {
|
||||||
|
final refreshed = await _tryRefreshToken();
|
||||||
|
if (refreshed) {
|
||||||
|
// 重试原请求
|
||||||
|
final response = await _retryRequest(error.requestOptions);
|
||||||
|
return handler.resolve(response);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 刷新失败,清除登录状态
|
||||||
|
await _clearAuthData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 尝试刷新 Token
|
||||||
|
Future<bool> _tryRefreshToken() async {
|
||||||
|
final refreshToken = await _secureStorage.read(key: StorageKeys.refreshToken);
|
||||||
|
if (refreshToken == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final deviceId = await _secureStorage.read(key: StorageKeys.deviceId);
|
||||||
|
final response = await _dio.post(
|
||||||
|
'/user/auto-login',
|
||||||
|
data: {
|
||||||
|
'refreshToken': refreshToken,
|
||||||
|
'deviceId': deviceId,
|
||||||
|
},
|
||||||
|
options: Options(headers: {'Authorization': ''}), // 不带旧 Token
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = response.data;
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: StorageKeys.accessToken,
|
||||||
|
value: data['accessToken'],
|
||||||
|
);
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: StorageKeys.refreshToken,
|
||||||
|
value: data['refreshToken'],
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Token refresh failed: $e');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重试请求
|
||||||
|
Future<Response> _retryRequest(RequestOptions options) async {
|
||||||
|
final token = await _secureStorage.read(key: StorageKeys.accessToken);
|
||||||
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
return _dio.fetch(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除认证数据
|
||||||
|
Future<void> _clearAuthData() async {
|
||||||
|
await _secureStorage.delete(key: StorageKeys.accessToken);
|
||||||
|
await _secureStorage.delete(key: StorageKeys.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET 请求
|
||||||
|
Future<Response<T>> get<T>(
|
||||||
|
String path, {
|
||||||
|
Map<String, dynamic>? queryParameters,
|
||||||
|
Options? options,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _dio.get<T>(
|
||||||
|
path,
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
options: options,
|
||||||
|
);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST 请求
|
||||||
|
Future<Response<T>> post<T>(
|
||||||
|
String path, {
|
||||||
|
dynamic data,
|
||||||
|
Map<String, dynamic>? queryParameters,
|
||||||
|
Options? options,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _dio.post<T>(
|
||||||
|
path,
|
||||||
|
data: data,
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
options: options,
|
||||||
|
);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT 请求
|
||||||
|
Future<Response<T>> put<T>(
|
||||||
|
String path, {
|
||||||
|
dynamic data,
|
||||||
|
Map<String, dynamic>? queryParameters,
|
||||||
|
Options? options,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _dio.put<T>(
|
||||||
|
path,
|
||||||
|
data: data,
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
options: options,
|
||||||
|
);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE 请求
|
||||||
|
Future<Response<T>> delete<T>(
|
||||||
|
String path, {
|
||||||
|
dynamic data,
|
||||||
|
Map<String, dynamic>? queryParameters,
|
||||||
|
Options? options,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _dio.delete<T>(
|
||||||
|
path,
|
||||||
|
data: data,
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
options: options,
|
||||||
|
);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理 Dio 错误
|
||||||
|
Exception _handleDioError(DioException error) {
|
||||||
|
switch (error.type) {
|
||||||
|
case DioExceptionType.connectionTimeout:
|
||||||
|
case DioExceptionType.sendTimeout:
|
||||||
|
case DioExceptionType.receiveTimeout:
|
||||||
|
return NetworkException('网络连接超时,请稍后重试');
|
||||||
|
case DioExceptionType.badResponse:
|
||||||
|
final statusCode = error.response?.statusCode;
|
||||||
|
final data = error.response?.data;
|
||||||
|
final message = data is Map ? data['message'] ?? '请求失败' : '请求失败';
|
||||||
|
|
||||||
|
switch (statusCode) {
|
||||||
|
case 400:
|
||||||
|
return ApiException(message, statusCode: 400);
|
||||||
|
case 401:
|
||||||
|
return UnauthorizedException(message);
|
||||||
|
case 403:
|
||||||
|
return ForbiddenException(message);
|
||||||
|
case 404:
|
||||||
|
return NotFoundException(message);
|
||||||
|
case 500:
|
||||||
|
case 502:
|
||||||
|
case 503:
|
||||||
|
return ServerException('服务器错误,请稍后重试');
|
||||||
|
default:
|
||||||
|
return ApiException(message, statusCode: statusCode);
|
||||||
|
}
|
||||||
|
case DioExceptionType.cancel:
|
||||||
|
return ApiException('请求已取消');
|
||||||
|
case DioExceptionType.connectionError:
|
||||||
|
return NetworkException('网络连接失败,请检查网络设置');
|
||||||
|
default:
|
||||||
|
return NetworkException('网络错误: ${error.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
import '../storage/secure_storage.dart';
|
||||||
|
import '../storage/storage_keys.dart';
|
||||||
|
import '../errors/exceptions.dart';
|
||||||
|
|
||||||
|
/// 创建账号请求
|
||||||
|
class CreateAccountRequest {
|
||||||
|
final String deviceId;
|
||||||
|
final String? deviceName;
|
||||||
|
final String? inviterReferralCode;
|
||||||
|
final String? provinceCode;
|
||||||
|
final String? cityCode;
|
||||||
|
|
||||||
|
CreateAccountRequest({
|
||||||
|
required this.deviceId,
|
||||||
|
this.deviceName,
|
||||||
|
this.inviterReferralCode,
|
||||||
|
this.provinceCode,
|
||||||
|
this.cityCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'deviceId': deviceId,
|
||||||
|
if (deviceName != null) 'deviceName': deviceName,
|
||||||
|
if (inviterReferralCode != null)
|
||||||
|
'inviterReferralCode': inviterReferralCode,
|
||||||
|
if (provinceCode != null) 'provinceCode': provinceCode,
|
||||||
|
if (cityCode != null) 'cityCode': cityCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建账号响应
|
||||||
|
class CreateAccountResponse {
|
||||||
|
final String userId;
|
||||||
|
final int accountSequence;
|
||||||
|
final String referralCode;
|
||||||
|
final String? mnemonic;
|
||||||
|
final String? clientShareData; // MPC 客户端分片数据
|
||||||
|
final String? publicKey; // MPC 公钥
|
||||||
|
final WalletAddresses walletAddresses;
|
||||||
|
final String accessToken;
|
||||||
|
final String refreshToken;
|
||||||
|
|
||||||
|
CreateAccountResponse({
|
||||||
|
required this.userId,
|
||||||
|
required this.accountSequence,
|
||||||
|
required this.referralCode,
|
||||||
|
this.mnemonic,
|
||||||
|
this.clientShareData,
|
||||||
|
this.publicKey,
|
||||||
|
required this.walletAddresses,
|
||||||
|
required this.accessToken,
|
||||||
|
required this.refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CreateAccountResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CreateAccountResponse(
|
||||||
|
userId: json['userId'] as String,
|
||||||
|
accountSequence: json['accountSequence'] as int,
|
||||||
|
referralCode: json['referralCode'] as String,
|
||||||
|
mnemonic: json['mnemonic'] as String?,
|
||||||
|
clientShareData: json['clientShareData'] as String?,
|
||||||
|
publicKey: json['publicKey'] as String?,
|
||||||
|
walletAddresses:
|
||||||
|
WalletAddresses.fromJson(json['walletAddresses'] as Map<String, dynamic>),
|
||||||
|
accessToken: json['accessToken'] as String,
|
||||||
|
refreshToken: json['refreshToken'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 钱包地址
|
||||||
|
class WalletAddresses {
|
||||||
|
final String kava;
|
||||||
|
final String dst;
|
||||||
|
final String bsc;
|
||||||
|
|
||||||
|
WalletAddresses({
|
||||||
|
required this.kava,
|
||||||
|
required this.dst,
|
||||||
|
required this.bsc,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory WalletAddresses.fromJson(Map<String, dynamic> json) {
|
||||||
|
return WalletAddresses(
|
||||||
|
kava: json['kava'] as String,
|
||||||
|
dst: json['dst'] as String,
|
||||||
|
bsc: json['bsc'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 账号服务
|
||||||
|
///
|
||||||
|
/// 处理账号创建、恢复等功能
|
||||||
|
class AccountService {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
final SecureStorage _secureStorage;
|
||||||
|
|
||||||
|
AccountService({
|
||||||
|
required ApiClient apiClient,
|
||||||
|
required SecureStorage secureStorage,
|
||||||
|
}) : _apiClient = apiClient,
|
||||||
|
_secureStorage = secureStorage;
|
||||||
|
|
||||||
|
/// 获取或生成设备ID
|
||||||
|
Future<String> getOrCreateDeviceId() async {
|
||||||
|
// 尝试从存储读取
|
||||||
|
var deviceId = await _secureStorage.read(key: StorageKeys.deviceId);
|
||||||
|
if (deviceId != null && deviceId.isNotEmpty) {
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成新的设备ID
|
||||||
|
deviceId = const Uuid().v4();
|
||||||
|
await _secureStorage.write(key: StorageKeys.deviceId, value: deviceId);
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取设备名称
|
||||||
|
Future<String> getDeviceName() async {
|
||||||
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
|
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final info = await deviceInfo.androidInfo;
|
||||||
|
return '${info.brand} ${info.model}';
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
final info = await deviceInfo.iosInfo;
|
||||||
|
return info.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown Device';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自动创建账号 (首次打开APP)
|
||||||
|
///
|
||||||
|
/// 使用 MPC 2-of-3 协议生成钱包地址
|
||||||
|
Future<CreateAccountResponse> createAccount({
|
||||||
|
String? inviterReferralCode,
|
||||||
|
String? provinceCode,
|
||||||
|
String? cityCode,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// 获取设备信息
|
||||||
|
final deviceId = await getOrCreateDeviceId();
|
||||||
|
final deviceName = await getDeviceName();
|
||||||
|
|
||||||
|
// 调用 API
|
||||||
|
final response = await _apiClient.post(
|
||||||
|
'/user/auto-create',
|
||||||
|
data: CreateAccountRequest(
|
||||||
|
deviceId: deviceId,
|
||||||
|
deviceName: deviceName,
|
||||||
|
inviterReferralCode: inviterReferralCode,
|
||||||
|
provinceCode: provinceCode,
|
||||||
|
cityCode: cityCode,
|
||||||
|
).toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data == null) {
|
||||||
|
throw const ApiException('创建账号失败: 空响应');
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = CreateAccountResponse.fromJson(
|
||||||
|
response.data as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 保存账号数据到安全存储
|
||||||
|
await _saveAccountData(result, deviceId);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} on ApiException {
|
||||||
|
rethrow;
|
||||||
|
} catch (e) {
|
||||||
|
throw ApiException('创建账号失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存账号数据
|
||||||
|
Future<void> _saveAccountData(
|
||||||
|
CreateAccountResponse response,
|
||||||
|
String deviceId,
|
||||||
|
) async {
|
||||||
|
// 保存基本信息
|
||||||
|
await _secureStorage.write(key: StorageKeys.userId, value: response.userId);
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: StorageKeys.accountSequence,
|
||||||
|
value: response.accountSequence.toString(),
|
||||||
|
);
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: StorageKeys.referralCode,
|
||||||
|
value: response.referralCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 保存 Token
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: StorageKeys.accessToken,
|
||||||
|
value: response.accessToken,
|
||||||
|
);
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: StorageKeys.refreshToken,
|
||||||
|
value: response.refreshToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 保存钱包地址
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: StorageKeys.walletAddressBsc,
|
||||||
|
value: response.walletAddresses.bsc,
|
||||||
|
);
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: StorageKeys.walletAddressKava,
|
||||||
|
value: response.walletAddresses.kava,
|
||||||
|
);
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: StorageKeys.walletAddressDst,
|
||||||
|
value: response.walletAddresses.dst,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 保存 MPC 数据 (如果有)
|
||||||
|
if (response.clientShareData != null &&
|
||||||
|
response.clientShareData!.isNotEmpty) {
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: StorageKeys.mpcClientShareData,
|
||||||
|
value: response.clientShareData!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.publicKey != null && response.publicKey!.isNotEmpty) {
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: StorageKeys.mpcPublicKey,
|
||||||
|
value: response.publicKey!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存助记词 (如果有)
|
||||||
|
if (response.mnemonic != null && response.mnemonic!.isNotEmpty) {
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: StorageKeys.mnemonic,
|
||||||
|
value: response.mnemonic!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记钱包已创建
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: StorageKeys.isWalletCreated,
|
||||||
|
value: 'true',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否已创建账号
|
||||||
|
Future<bool> hasAccount() async {
|
||||||
|
final isCreated = await _secureStorage.read(key: StorageKeys.isWalletCreated);
|
||||||
|
return isCreated == 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取账号序列号
|
||||||
|
Future<int?> getAccountSequence() async {
|
||||||
|
final sequence = await _secureStorage.read(key: StorageKeys.accountSequence);
|
||||||
|
return sequence != null ? int.tryParse(sequence) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取推荐码
|
||||||
|
Future<String?> getReferralCode() async {
|
||||||
|
return _secureStorage.read(key: StorageKeys.referralCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取钱包地址
|
||||||
|
Future<WalletAddresses?> getWalletAddresses() async {
|
||||||
|
final bsc = await _secureStorage.read(key: StorageKeys.walletAddressBsc);
|
||||||
|
final kava = await _secureStorage.read(key: StorageKeys.walletAddressKava);
|
||||||
|
final dst = await _secureStorage.read(key: StorageKeys.walletAddressDst);
|
||||||
|
|
||||||
|
if (bsc == null || kava == null || dst == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WalletAddresses(kava: kava, dst: dst, bsc: bsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登出
|
||||||
|
Future<void> logout() async {
|
||||||
|
await _secureStorage.deleteAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,11 +8,22 @@ class StorageKeys {
|
||||||
static const String isWalletCreated = 'is_wallet_created';
|
static const String isWalletCreated = 'is_wallet_created';
|
||||||
static const String isMnemonicBackedUp = 'is_mnemonic_backed_up';
|
static const String isMnemonicBackedUp = 'is_mnemonic_backed_up';
|
||||||
|
|
||||||
|
// MPC 相关
|
||||||
|
static const String mpcClientShareData = 'mpc_client_share_data'; // MPC 客户端分片数据
|
||||||
|
static const String mpcPublicKey = 'mpc_public_key'; // MPC 公钥
|
||||||
|
static const String accountSequence = 'account_sequence'; // 账户序列号
|
||||||
|
|
||||||
|
// 钱包地址
|
||||||
|
static const String walletAddressBsc = 'wallet_address_bsc';
|
||||||
|
static const String walletAddressKava = 'wallet_address_kava';
|
||||||
|
static const String walletAddressDst = 'wallet_address_dst';
|
||||||
|
|
||||||
// User
|
// User
|
||||||
static const String userId = 'user_id';
|
static const String userId = 'user_id';
|
||||||
static const String userProfile = 'user_profile';
|
static const String userProfile = 'user_profile';
|
||||||
static const String accessToken = 'access_token';
|
static const String accessToken = 'access_token';
|
||||||
static const String refreshToken = 'refresh_token';
|
static const String refreshToken = 'refresh_token';
|
||||||
|
static const String referralCode = 'referral_code';
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
static const String locale = 'locale';
|
static const String locale = 'locale';
|
||||||
|
|
@ -20,6 +31,10 @@ class StorageKeys {
|
||||||
static const String isFirstLaunch = 'is_first_launch';
|
static const String isFirstLaunch = 'is_first_launch';
|
||||||
static const String biometricEnabled = 'biometric_enabled';
|
static const String biometricEnabled = 'biometric_enabled';
|
||||||
|
|
||||||
|
// Device
|
||||||
|
static const String deviceId = 'device_id';
|
||||||
|
static const String deviceName = 'device_name';
|
||||||
|
|
||||||
// Cache
|
// Cache
|
||||||
static const String lastSyncTime = 'last_sync_time';
|
static const String lastSyncTime = 'last_sync_time';
|
||||||
static const String cachedRankingData = 'cached_ranking_data';
|
static const String cachedRankingData = 'cached_ranking_data';
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ import 'package:go_router/go_router.dart';
|
||||||
import '../../../../routes/route_paths.dart';
|
import '../../../../routes/route_paths.dart';
|
||||||
import '../../../../routes/app_router.dart';
|
import '../../../../routes/app_router.dart';
|
||||||
|
|
||||||
/// 备份助记词页面 - 显示生成的助记词和钱包地址
|
/// 备份助记词/账户信息页面
|
||||||
/// 用户需要备份助记词后才能继续使用应用
|
/// MPC 模式下显示账户信息,传统模式下显示助记词
|
||||||
class BackupMnemonicPage extends ConsumerStatefulWidget {
|
class BackupMnemonicPage extends ConsumerStatefulWidget {
|
||||||
/// 生成的助记词列表
|
/// 生成的助记词列表 (MPC 模式下为空)
|
||||||
final List<String> mnemonicWords;
|
final List<String> mnemonicWords;
|
||||||
/// KAVA 钱包地址
|
/// KAVA 钱包地址
|
||||||
final String kavaAddress;
|
final String kavaAddress;
|
||||||
|
|
@ -16,8 +16,14 @@ class BackupMnemonicPage extends ConsumerStatefulWidget {
|
||||||
final String dstAddress;
|
final String dstAddress;
|
||||||
/// BSC 钱包地址
|
/// BSC 钱包地址
|
||||||
final String bscAddress;
|
final String bscAddress;
|
||||||
/// 序列号
|
/// 序列号 (账户唯一标识)
|
||||||
final String serialNumber;
|
final String serialNumber;
|
||||||
|
/// 推荐码
|
||||||
|
final String? referralCode;
|
||||||
|
/// MPC 公钥
|
||||||
|
final String? publicKey;
|
||||||
|
/// 是否为 MPC 模式
|
||||||
|
final bool isMpcMode;
|
||||||
|
|
||||||
const BackupMnemonicPage({
|
const BackupMnemonicPage({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -26,6 +32,9 @@ class BackupMnemonicPage extends ConsumerStatefulWidget {
|
||||||
required this.dstAddress,
|
required this.dstAddress,
|
||||||
required this.bscAddress,
|
required this.bscAddress,
|
||||||
required this.serialNumber,
|
required this.serialNumber,
|
||||||
|
this.referralCode,
|
||||||
|
this.publicKey,
|
||||||
|
this.isMpcMode = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -114,8 +123,11 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 助记词卡片
|
// MPC 模式显示账户信息卡片,否则显示助记词
|
||||||
_buildMnemonicCard(),
|
if (widget.isMpcMode)
|
||||||
|
_buildMpcInfoCard()
|
||||||
|
else if (widget.mnemonicWords.isNotEmpty)
|
||||||
|
_buildMnemonicCard(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
// 警告提示
|
// 警告提示
|
||||||
_buildWarningCard(),
|
_buildWarningCard(),
|
||||||
|
|
@ -157,10 +169,10 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 标题
|
// 标题
|
||||||
const Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'备份你的助记词',
|
widget.isMpcMode ? '账户创建成功' : '备份你的助记词',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|
@ -332,26 +344,154 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构建 MPC 信息卡片 (MPC 模式下显示)
|
||||||
|
Widget _buildMpcInfoCard() {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0x80FFFFFF),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x0D000000),
|
||||||
|
blurRadius: 2,
|
||||||
|
offset: Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 成功图标和标题
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0x3322C55E),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Color(0xFF22C55E),
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'多方安全钱包已创建',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.25,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// 说明文字
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0x1AD4AF37),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'MPC 2-of-3 多方安全计算',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.5,
|
||||||
|
color: Color(0xFF8B5A2B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'您的钱包密钥被安全地分成三份:\n'
|
||||||
|
'1. 服务器持有一份 (用于日常交易)\n'
|
||||||
|
'2. 您的设备持有一份 (已安全存储)\n'
|
||||||
|
'3. 备份服务持有一份 (用于恢复)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
height: 1.6,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 推荐码 (如果有)
|
||||||
|
if (widget.referralCode != null && widget.referralCode!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.share, color: Color(0xFFD4AF37), size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'您的推荐码: ',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
widget.referralCode!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Color(0xFFD4AF37),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _copyAddress(widget.referralCode!, '推荐码'),
|
||||||
|
child: const Icon(Icons.copy, color: Color(0xFF8B5A2B), size: 20),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 构建警告卡片
|
/// 构建警告卡片
|
||||||
Widget _buildWarningCard() {
|
Widget _buildWarningCard() {
|
||||||
|
final warningText = widget.isMpcMode
|
||||||
|
? '您的账户已安全创建。序列号是您的唯一身份标识,请妥善保管。'
|
||||||
|
: '请妥善保管您的助记词,丢失将无法恢复账号。';
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0x1A7F1D1D),
|
color: widget.isMpcMode ? const Color(0x1A22C55E) : const Color(0x1A7F1D1D),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: const Color(0x337F1D1D),
|
color: widget.isMpcMode ? const Color(0x3322C55E) : const Color(0x337F1D1D),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'请妥善保管您的助记词,丢失将无法恢复账号。',
|
warningText,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
color: Color(0xFF991B1B),
|
color: widget.isMpcMode ? const Color(0xFF166534) : const Color(0xFF991B1B),
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|
@ -509,9 +649,9 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 确认备份按钮
|
// MPC 模式直接进入主页,传统模式需要验证助记词
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: _confirmBackup,
|
onTap: widget.isMpcMode ? _enterApp : _confirmBackup,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 48,
|
height: 48,
|
||||||
|
|
@ -519,10 +659,10 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
||||||
color: const Color(0xFFD4AF37),
|
color: const Color(0xFFD4AF37),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'我已备份助记词',
|
widget.isMpcMode ? '进入应用' : '我已备份助记词',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|
@ -534,22 +674,29 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// 返回上一步
|
// 返回上一步 (MPC 模式下不显示,因为账号已创建)
|
||||||
GestureDetector(
|
if (!widget.isMpcMode)
|
||||||
onTap: _goBack,
|
GestureDetector(
|
||||||
child: const Text(
|
onTap: _goBack,
|
||||||
'返回上一步',
|
child: const Text(
|
||||||
style: TextStyle(
|
'返回上一步',
|
||||||
fontSize: 14,
|
style: TextStyle(
|
||||||
fontFamily: 'Inter',
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontFamily: 'Inter',
|
||||||
height: 1.5,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xCC8B5A2B),
|
height: 1.5,
|
||||||
|
color: Color(0xCC8B5A2B),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MPC 模式下直接进入应用
|
||||||
|
void _enterApp() {
|
||||||
|
// 跳转到主页
|
||||||
|
context.go(RoutePaths.ranking);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../../routes/route_paths.dart';
|
import '../../../../routes/route_paths.dart';
|
||||||
import '../../../../routes/app_router.dart';
|
import '../../../../routes/app_router.dart';
|
||||||
|
import '../../../../core/di/injection_container.dart';
|
||||||
|
import '../../../../core/services/account_service.dart';
|
||||||
|
|
||||||
/// 创建账号页面 - 用户首次进入应用时的引导页面
|
/// 创建账号页面 - 用户首次进入应用时的引导页面
|
||||||
/// 提供创建钱包和导入助记词两种选项
|
/// 提供创建钱包和导入助记词两种选项
|
||||||
|
|
@ -19,51 +21,70 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
|
||||||
bool _isAgreed = false;
|
bool _isAgreed = false;
|
||||||
// 创建钱包加载状态
|
// 创建钱包加载状态
|
||||||
bool _isCreating = false;
|
bool _isCreating = false;
|
||||||
|
// 错误信息
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
/// 创建钱包并跳转到备份助记词页面
|
/// 创建钱包并跳转到备份页面
|
||||||
|
///
|
||||||
|
/// 调用后端 API 使用 MPC 2-of-3 协议生成钱包地址
|
||||||
Future<void> _createWallet() async {
|
Future<void> _createWallet() async {
|
||||||
if (!_isAgreed) {
|
if (!_isAgreed) {
|
||||||
_showAgreementTip();
|
_showAgreementTip();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isCreating = true);
|
setState(() {
|
||||||
|
_isCreating = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: 实现真正的钱包创建逻辑 (bip39)
|
// 获取 AccountService
|
||||||
// 这里模拟生成钱包的过程
|
final accountService = ref.read(accountServiceProvider);
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
|
||||||
|
|
||||||
// 模拟生成的助记词 (12个单词)
|
// 调用后端 API 创建账号
|
||||||
final mockMnemonicWords = [
|
debugPrint('开始创建账号...');
|
||||||
'apple', 'banana', 'cherry', 'date',
|
final response = await accountService.createAccount();
|
||||||
'elder', 'fig', 'grape', 'honey',
|
debugPrint('账号创建成功: 序列号=${response.accountSequence}');
|
||||||
'kiwi', 'lemon', 'mango', 'nectar',
|
|
||||||
];
|
|
||||||
|
|
||||||
// 模拟生成的钱包地址
|
|
||||||
const mockKavaAddress = '0x1234567890abcdef1234567890abcdef12345678';
|
|
||||||
const mockDstAddress = '0xABCDEF1234567890abcdef1234567890ABCDEFGH';
|
|
||||||
const mockBscAddress = '0x9876543210zyxwvu9876543210zyxwvu98765432';
|
|
||||||
const mockSerialNumber = '12345678';
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// 跳转到备份助记词页面
|
// MPC 模式下,检查是否有客户端分片数据
|
||||||
|
// 如果有分片数据,需要提示用户妥善保管
|
||||||
|
final hasMpcData = response.clientShareData != null &&
|
||||||
|
response.clientShareData!.isNotEmpty;
|
||||||
|
|
||||||
|
// 跳转到钱包创建成功页面
|
||||||
context.push(
|
context.push(
|
||||||
RoutePaths.backupMnemonic,
|
RoutePaths.backupMnemonic,
|
||||||
extra: BackupMnemonicParams(
|
extra: BackupMnemonicParams(
|
||||||
mnemonicWords: mockMnemonicWords,
|
// MPC 模式下助记词为空,显示账号信息即可
|
||||||
kavaAddress: mockKavaAddress,
|
mnemonicWords: response.mnemonic?.isNotEmpty == true
|
||||||
dstAddress: mockDstAddress,
|
? response.mnemonic!.split(' ')
|
||||||
bscAddress: mockBscAddress,
|
: [], // MPC 模式下为空
|
||||||
serialNumber: mockSerialNumber,
|
kavaAddress: response.walletAddresses.kava,
|
||||||
|
dstAddress: response.walletAddresses.dst,
|
||||||
|
bscAddress: response.walletAddresses.bsc,
|
||||||
|
serialNumber: response.accountSequence.toString(),
|
||||||
|
referralCode: response.referralCode,
|
||||||
|
publicKey: response.publicKey,
|
||||||
|
isMpcMode: hasMpcData, // 标记是否为 MPC 模式
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
debugPrint('创建账号失败: $e');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = e.toString();
|
||||||
|
});
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('创建钱包失败: $e')),
|
SnackBar(
|
||||||
|
content: Text('创建账号失败: ${e.toString().replaceAll('Exception: ', '')}'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ class BackupMnemonicParams {
|
||||||
final String dstAddress;
|
final String dstAddress;
|
||||||
final String bscAddress;
|
final String bscAddress;
|
||||||
final String serialNumber;
|
final String serialNumber;
|
||||||
|
final String? referralCode; // 推荐码
|
||||||
|
final String? publicKey; // MPC 公钥
|
||||||
|
final bool isMpcMode; // 是否为 MPC 模式
|
||||||
|
|
||||||
BackupMnemonicParams({
|
BackupMnemonicParams({
|
||||||
required this.mnemonicWords,
|
required this.mnemonicWords,
|
||||||
|
|
@ -34,6 +37,9 @@ class BackupMnemonicParams {
|
||||||
required this.dstAddress,
|
required this.dstAddress,
|
||||||
required this.bscAddress,
|
required this.bscAddress,
|
||||||
required this.serialNumber,
|
required this.serialNumber,
|
||||||
|
this.referralCode,
|
||||||
|
this.publicKey,
|
||||||
|
this.isMpcMode = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,6 +100,9 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
dstAddress: params.dstAddress,
|
dstAddress: params.dstAddress,
|
||||||
bscAddress: params.bscAddress,
|
bscAddress: params.bscAddress,
|
||||||
serialNumber: params.serialNumber,
|
serialNumber: params.serialNumber,
|
||||||
|
referralCode: params.referralCode,
|
||||||
|
publicKey: params.publicKey,
|
||||||
|
isMpcMode: params.isMpcMode,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue