This commit is contained in:
Developer 2025-11-29 19:22:42 -08:00
parent f4a3a6dd1c
commit 083db83c96
31 changed files with 4666 additions and 108 deletions

View File

@ -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": []
}
}

View File

@ -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 集成测试通过
- [ ] 监控指标已配置
- [ ] 告警规则已配置
- [ ] 生产环境部署在异地机房

View File

@ -27,3 +27,8 @@ APP_ENV="development"
# Blockchain Encryption
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

View File

@ -27,3 +27,8 @@ APP_ENV="development"
# Blockchain Encryption
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

View File

@ -28,6 +28,7 @@
"prisma:studio": "prisma studio"
},
"dependencies": {
"@nestjs/axios": "^3.0.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",

View File

@ -72,8 +72,13 @@ model WalletAddress {
chainType String @map("chain_type") @db.VarChar(20)
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)
@ -85,6 +90,7 @@ model WalletAddress {
@@unique([chainType, address], name: "uk_chain_address")
@@index([userId], name: "idx_wallet_user")
@@index([address], name: "idx_address")
@@index([publicKey], name: "idx_public_key")
@@map("wallet_addresses")
}
@ -170,3 +176,53 @@ model SmsCode {
@@index([expiresAt], name: "idx_sms_expires")
@@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")
}

View File

@ -125,16 +125,22 @@ export class AutoCreateAccountResponseDto {
@ApiProperty()
userId: string;
@ApiProperty()
@ApiProperty({ description: '账户序列号 (唯一标识,用于推荐和分享)' })
accountSequence: number;
@ApiProperty()
@ApiProperty({ description: '推荐码' })
referralCode: string;
@ApiProperty({ description: '助记词(仅返回一次,请妥善保管)' })
mnemonic: string;
@ApiPropertyOptional({ description: '助记词 (MPC模式下为空)' })
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 };
@ApiProperty()

View File

@ -134,7 +134,9 @@ export interface AutoCreateAccountResult {
userId: string;
accountSequence: number;
referralCode: string;
mnemonic: string;
mnemonic: string; // 兼容字段MPC模式下为空
clientShareData?: string; // MPC 客户端分片数据 (需安全存储)
publicKey?: string; // MPC 公钥
walletAddresses: { kava: string; dst: string; bsc: string };
accessToken: string;
refreshToken: string;

View File

@ -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 { MpcKeyShareRepository, MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import {
AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService,
} from '@/domain/services';
@ -12,6 +14,7 @@ import { TokenService } from './token.service';
import { RedisService } from '@/infrastructure/redis/redis.service';
import { SmsService } from '@/infrastructure/external/sms/sms.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { MpcWalletService } from '@/infrastructure/external/mpc';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
import {
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
@ -24,22 +27,39 @@ import {
@Injectable()
export class UserApplicationService {
private readonly logger = new Logger(UserApplicationService.name);
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: UserAccountRepository,
@Inject(MPC_KEY_SHARE_REPOSITORY)
private readonly mpcKeyShareRepository: MpcKeyShareRepository,
private readonly sequenceGenerator: AccountSequenceGeneratorService,
private readonly validatorService: UserValidatorService,
private readonly walletGenerator: WalletGeneratorService,
private readonly mpcWalletService: MpcWalletService,
private readonly tokenService: TokenService,
private readonly redisService: RedisService,
private readonly smsService: SmsService,
private readonly eventPublisher: EventPublisherService,
) {}
/**
* (APP)
*
* 使 MPC 2-of-3 :
* - (BSC/KAVA/DST)
* - MPC
* -
*/
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);
if (!deviceValidation.isValid) throw new ApplicationError(deviceValidation.errorMessage!);
// 2. 验证邀请码
let inviterSequence: AccountSequence | null = null;
if (command.inviterReferralCode) {
const referralCode = ReferralCode.create(command.inviterReferralCode);
@ -49,8 +69,10 @@ export class UserApplicationService {
inviterSequence = inviter!.accountSequence;
}
// 3. 生成账户序列号
const accountSequence = await this.sequenceGenerator.generateNext();
// 4. 创建用户账户
const account = UserAccount.createAutomatic({
accountSequence,
initialDeviceId: command.deviceId,
@ -60,29 +82,67 @@ export class UserApplicationService {
city: CityCode.create(command.cityCode || 'DEFAULT'),
});
const { mnemonic, wallets } = this.walletGenerator.generateWalletSystem({
userId: account.userId,
// 5. 使用 MPC 2-of-3 生成三链钱包地址
this.logger.log(`Generating MPC wallet for account sequence: ${accountSequence.value}`);
const mpcResult = await this.mpcWalletService.generateMpcWallet({
userId: account.userId.toString(),
deviceId: command.deviceId,
});
// 6. 创建钱包地址实体 (包含 MPC 签名)
const wallets = new Map<ChainType, WalletAddress>();
for (const walletInfo of mpcResult.wallets) {
const chainType = walletInfo.chainType as ChainType;
const wallet = WalletAddress.createMpc({
userId: account.userId,
chainType,
address: walletInfo.address,
publicKey: walletInfo.publicKey,
addressDigest: walletInfo.addressDigest,
signature: walletInfo.signature,
});
wallets.set(chainType, wallet);
}
// 7. 绑定钱包地址到账户
account.bindMultipleWalletAddresses(wallets);
// 8. 保存账户和钱包
await this.userRepository.save(account);
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({
userId: account.userId.toString(),
accountSequence: account.accountSequence.value,
deviceId: command.deviceId,
});
// 12. 发布领域事件
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
this.logger.log(`Account created successfully: sequence=${accountSequence.value}, publicKey=${mpcResult.publicKey}`);
return {
userId: account.userId.toString(),
accountSequence: account.accountSequence.value,
referralCode: account.referralCode.value,
mnemonic: mnemonic.value,
// MPC 模式下返回客户端分片数据 (而不是助记词)
mnemonic: '', // MPC 模式不使用助记词,返回空字符串
clientShareData: mpcResult.clientShareData, // 客户端需要安全存储的分片数据
publicKey: mpcResult.publicKey, // MPC 公钥
walletAddresses: {
kava: wallets.get(ChainType.KAVA)!.address,
dst: wallets.get(ChainType.DST)!.address,

View File

@ -13,12 +13,23 @@ import {
MnemonicEncryption,
} from '@/domain/value-objects';
/**
* MPC
*/
export interface MpcSignature {
r: string;
s: string;
v: number;
}
export class WalletAddress {
private readonly _addressId: AddressId;
private readonly _userId: UserId;
private readonly _chainType: ChainType;
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 readonly _boundAt: Date;
@ -26,7 +37,9 @@ export class WalletAddress {
get userId(): UserId { return this._userId; }
get chainType(): ChainType { return this._chainType; }
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 boundAt(): Date { return this._boundAt; }
@ -35,7 +48,9 @@ export class WalletAddress {
userId: UserId,
chainType: ChainType,
address: string,
encryptedMnemonic: string,
publicKey: string,
addressDigest: string,
mpcSignature: MpcSignature,
status: AddressStatus,
boundAt: Date,
) {
@ -43,13 +58,27 @@ export class WalletAddress {
this._userId = userId;
this._chainType = chainType;
this._address = address;
this._encryptedMnemonic = encryptedMnemonic;
this._publicKey = publicKey;
this._addressDigest = addressDigest;
this._mpcSignature = mpcSignature;
this._status = status;
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}地址格式错误`);
}
return new WalletAddress(
@ -57,37 +86,27 @@ export class WalletAddress {
params.userId,
params.chainType,
params.address,
'',
AddressStatus.ACTIVE,
new Date(),
);
}
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,
params.publicKey,
params.addressDigest,
params.signature,
AddressStatus.ACTIVE,
new Date(),
);
}
/**
*
*/
static reconstruct(params: {
addressId: string;
userId: string;
chainType: ChainType;
address: string;
encryptedMnemonic: string;
publicKey: string;
addressDigest: string;
mpcSignatureR: string;
mpcSignatureS: string;
mpcSignatureV: number;
status: AddressStatus;
boundAt: Date;
}): WalletAddress {
@ -96,7 +115,13 @@ export class WalletAddress {
UserId.create(params.userId),
params.chainType,
params.address,
params.encryptedMnemonic,
params.publicKey,
params.addressDigest,
{
r: params.mpcSignatureR,
s: params.mpcSignatureS,
v: params.mpcSignatureV,
},
params.status,
params.boundAt,
);
@ -110,12 +135,95 @@ export class WalletAddress {
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 mnemonicStr = MnemonicEncryption.decrypt(this._encryptedMnemonic, encryptionKey);
return Mnemonic.create(mnemonicStr);
// 验证签名
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;
}
}
/**
*
*/
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 {

View File

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

View File

@ -0,0 +1,3 @@
export * from './mpc.module';
export * from './mpc-client.service';
export * from './mpc-wallet.service';

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

View 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);
}
}

View 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 {}

View File

@ -1,31 +1,53 @@
import { Module, Global } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { PrismaService } from './persistence/prisma/prisma.service';
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 { RedisService } from './redis/redis.service';
import { EventPublisherService } from './kafka/event-publisher.service';
import { SmsService } from './external/sms/sms.service';
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()
@Module({
imports: [
HttpModule.register({
timeout: 300000,
maxRedirects: 5,
}),
],
providers: [
PrismaService,
UserAccountRepositoryImpl,
{
provide: MPC_KEY_SHARE_REPOSITORY,
useClass: MpcKeyShareRepositoryImpl,
},
UserAccountMapper,
RedisService,
EventPublisherService,
SmsService,
WalletGeneratorServiceImpl,
MpcClientService,
MpcWalletService,
],
exports: [
PrismaService,
UserAccountRepositoryImpl,
{
provide: MPC_KEY_SHARE_REPOSITORY,
useClass: MpcKeyShareRepositoryImpl,
},
UserAccountMapper,
RedisService,
EventPublisherService,
SmsService,
WalletGeneratorServiceImpl,
MpcClientService,
MpcWalletService,
],
})
export class InfrastructureModule {}

View File

@ -3,7 +3,11 @@ export interface WalletAddressEntity {
userId: bigint;
chainType: string;
address: string;
encryptedMnemonic: string | null;
publicKey: string;
addressDigest: string;
mpcSignatureR: string;
mpcSignatureS: string;
mpcSignatureV: number;
status: string;
boundAt: Date;
}

View File

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

View File

@ -90,7 +90,11 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
userId: BigInt(userId.value),
chainType: w.chainType,
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,
boundAt: w.boundAt,
})),
@ -205,7 +209,11 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
userId: w.userId.toString(),
chainType: w.chainType as ChainType,
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,
boundAt: w.boundAt,
}),

View File

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

View File

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(npx create-next-app:*)"
],
"deny": [],
"ask": []
}
}

View File

@ -1,6 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../storage/secure_storage.dart';
import '../storage/local_storage.dart';
import '../network/api_client.dart';
import '../services/account_service.dart';
// Storage Providers
final secureStorageProvider = Provider<SecureStorage>((ref) {
@ -11,6 +13,22 @@ final localStorageProvider = Provider<LocalStorage>((ref) {
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
ProviderContainer createProviderContainer(LocalStorage localStorage) {
return ProviderContainer(

View File

@ -73,3 +73,40 @@ class StorageException implements Exception {
@override
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';
}

View File

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

View File

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

View File

@ -8,11 +8,22 @@ class StorageKeys {
static const String isWalletCreated = 'is_wallet_created';
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
static const String userId = 'user_id';
static const String userProfile = 'user_profile';
static const String accessToken = 'access_token';
static const String refreshToken = 'refresh_token';
static const String referralCode = 'referral_code';
// Settings
static const String locale = 'locale';
@ -20,6 +31,10 @@ class StorageKeys {
static const String isFirstLaunch = 'is_first_launch';
static const String biometricEnabled = 'biometric_enabled';
// Device
static const String deviceId = 'device_id';
static const String deviceName = 'device_name';
// Cache
static const String lastSyncTime = 'last_sync_time';
static const String cachedRankingData = 'cached_ranking_data';

View File

@ -5,10 +5,10 @@ import 'package:go_router/go_router.dart';
import '../../../../routes/route_paths.dart';
import '../../../../routes/app_router.dart';
/// -
/// 使
/// /
/// MPC
class BackupMnemonicPage extends ConsumerStatefulWidget {
///
/// (MPC )
final List<String> mnemonicWords;
/// KAVA
final String kavaAddress;
@ -16,8 +16,14 @@ class BackupMnemonicPage extends ConsumerStatefulWidget {
final String dstAddress;
/// BSC
final String bscAddress;
///
/// ()
final String serialNumber;
///
final String? referralCode;
/// MPC
final String? publicKey;
/// MPC
final bool isMpcMode;
const BackupMnemonicPage({
super.key,
@ -26,6 +32,9 @@ class BackupMnemonicPage extends ConsumerStatefulWidget {
required this.dstAddress,
required this.bscAddress,
required this.serialNumber,
this.referralCode,
this.publicKey,
this.isMpcMode = false,
});
@override
@ -114,7 +123,10 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
padding: const EdgeInsets.all(16),
child: Column(
children: [
//
// MPC
if (widget.isMpcMode)
_buildMpcInfoCard()
else if (widget.mnemonicWords.isNotEmpty)
_buildMnemonicCard(),
const SizedBox(height: 24),
//
@ -157,10 +169,10 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
),
),
//
const Expanded(
Expanded(
child: Text(
'备份你的助记词',
style: TextStyle(
widget.isMpcMode ? '账户创建成功' : '备份你的助记词',
style: const TextStyle(
fontSize: 18,
fontFamily: 'Inter',
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() {
final warningText = widget.isMpcMode
? '您的账户已安全创建。序列号是您的唯一身份标识,请妥善保管。'
: '请妥善保管您的助记词,丢失将无法恢复账号。';
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0x1A7F1D1D),
color: widget.isMpcMode ? const Color(0x1A22C55E) : const Color(0x1A7F1D1D),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0x337F1D1D),
color: widget.isMpcMode ? const Color(0x3322C55E) : const Color(0x337F1D1D),
width: 1,
),
),
child: const Text(
'请妥善保管您的助记词,丢失将无法恢复账号。',
child: Text(
warningText,
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
height: 1.5,
color: Color(0xFF991B1B),
color: widget.isMpcMode ? const Color(0xFF166534) : const Color(0xFF991B1B),
),
textAlign: TextAlign.center,
),
@ -509,9 +649,9 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
padding: const EdgeInsets.all(16),
child: Column(
children: [
//
// MPC
GestureDetector(
onTap: _confirmBackup,
onTap: widget.isMpcMode ? _enterApp : _confirmBackup,
child: Container(
width: double.infinity,
height: 48,
@ -519,10 +659,10 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
color: const Color(0xFFD4AF37),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Center(
child: Text(
'我已备份助记词',
style: TextStyle(
widget.isMpcMode ? '进入应用' : '我已备份助记词',
style: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
@ -534,7 +674,8 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
),
),
const SizedBox(height: 16),
//
// (MPC )
if (!widget.isMpcMode)
GestureDetector(
onTap: _goBack,
child: const Text(
@ -552,4 +693,10 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
),
);
}
/// MPC
void _enterApp() {
//
context.go(RoutePaths.ranking);
}
}

View File

@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../routes/route_paths.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 _isCreating = false;
//
String? _errorMessage;
///
///
///
/// API 使 MPC 2-of-3
Future<void> _createWallet() async {
if (!_isAgreed) {
_showAgreementTip();
return;
}
setState(() => _isCreating = true);
setState(() {
_isCreating = true;
_errorMessage = null;
});
try {
// TODO: (bip39)
//
await Future.delayed(const Duration(seconds: 2));
// AccountService
final accountService = ref.read(accountServiceProvider);
// (12)
final mockMnemonicWords = [
'apple', 'banana', 'cherry', 'date',
'elder', 'fig', 'grape', 'honey',
'kiwi', 'lemon', 'mango', 'nectar',
];
//
const mockKavaAddress = '0x1234567890abcdef1234567890abcdef12345678';
const mockDstAddress = '0xABCDEF1234567890abcdef1234567890ABCDEFGH';
const mockBscAddress = '0x9876543210zyxwvu9876543210zyxwvu98765432';
const mockSerialNumber = '12345678';
// API
debugPrint('开始创建账号...');
final response = await accountService.createAccount();
debugPrint('账号创建成功: 序列号=${response.accountSequence}');
if (!mounted) return;
//
// MPC
//
final hasMpcData = response.clientShareData != null &&
response.clientShareData!.isNotEmpty;
//
context.push(
RoutePaths.backupMnemonic,
extra: BackupMnemonicParams(
mnemonicWords: mockMnemonicWords,
kavaAddress: mockKavaAddress,
dstAddress: mockDstAddress,
bscAddress: mockBscAddress,
serialNumber: mockSerialNumber,
// MPC
mnemonicWords: response.mnemonic?.isNotEmpty == true
? response.mnemonic!.split(' ')
: [], // MPC
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) {
debugPrint('创建账号失败: $e');
if (!mounted) return;
setState(() {
_errorMessage = e.toString();
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('创建钱包失败: $e')),
SnackBar(
content: Text('创建账号失败: ${e.toString().replaceAll('Exception: ', '')}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
),
);
} finally {
if (mounted) {

View File

@ -27,6 +27,9 @@ class BackupMnemonicParams {
final String dstAddress;
final String bscAddress;
final String serialNumber;
final String? referralCode; //
final String? publicKey; // MPC
final bool isMpcMode; // MPC
BackupMnemonicParams({
required this.mnemonicWords,
@ -34,6 +37,9 @@ class BackupMnemonicParams {
required this.dstAddress,
required this.bscAddress,
required this.serialNumber,
this.referralCode,
this.publicKey,
this.isMpcMode = false,
});
}
@ -94,6 +100,9 @@ final appRouterProvider = Provider<GoRouter>((ref) {
dstAddress: params.dstAddress,
bscAddress: params.bscAddress,
serialNumber: params.serialNumber,
referralCode: params.referralCode,
publicKey: params.publicKey,
isMpcMode: params.isMpcMode,
);
},
),

View File

File diff suppressed because it is too large Load Diff