refactor: simplify mpc-service to gateway mode
mpc-service 重新定位为网关服务,转发请求到 mpc-system: - 简化 Prisma schema:只保留 MpcWallet 和 MpcShare - 添加 delegate share 存储(keygen 后保存用户的 share) - 保留六边形架构结构,清理不再需要的实现 - 删除旧的 command handlers、queries、repository 实现 - 简化 infrastructure module,只保留 PrismaService 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d652f1d7a4
commit
9c36e6772b
|
|
@ -29,7 +29,10 @@
|
|||
"Bash(go run:*)",
|
||||
"Bash(timeout /t 5 /nobreak)",
|
||||
"Bash(bash:*)",
|
||||
"Bash(docker compose:*)"
|
||||
"Bash(docker compose:*)",
|
||||
"Bash(timeout /t 15 /nobreak)",
|
||||
"Bash(timeout /t 30 /nobreak)",
|
||||
"Bash(docker ps:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"app": {
|
||||
"port": 3006,
|
||||
"env": "development",
|
||||
"apiPrefix": "api/v1"
|
||||
},
|
||||
"database": {
|
||||
"url": "postgresql://mpc_user:mpc_password@localhost:5432/mpc_service_dev"
|
||||
},
|
||||
"jwt": {
|
||||
"secret": "development-jwt-secret-change-in-production",
|
||||
"accessExpiresIn": "2h",
|
||||
"refreshExpiresIn": "30d"
|
||||
},
|
||||
"redis": {
|
||||
"host": "localhost",
|
||||
"port": 6379,
|
||||
"password": "",
|
||||
"db": 5
|
||||
},
|
||||
"kafka": {
|
||||
"brokers": ["localhost:9092"],
|
||||
"clientId": "mpc-party-service",
|
||||
"groupId": "mpc-party-group"
|
||||
},
|
||||
"mpc": {
|
||||
"coordinatorUrl": "http://localhost:50051",
|
||||
"coordinatorTimeout": 30000,
|
||||
"messageRouterWsUrl": "ws://localhost:50052",
|
||||
"keygenTimeout": 300000,
|
||||
"signingTimeout": 180000,
|
||||
"refreshTimeout": 300000
|
||||
},
|
||||
"tss": {
|
||||
"libPath": "/opt/tss-lib/tss",
|
||||
"tempDir": "/tmp/tss"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"app": {
|
||||
"port": 3006,
|
||||
"env": "production",
|
||||
"apiPrefix": "api/v1"
|
||||
},
|
||||
"database": {
|
||||
"url": "${DATABASE_URL}"
|
||||
},
|
||||
"jwt": {
|
||||
"secret": "${JWT_SECRET}",
|
||||
"accessExpiresIn": "2h",
|
||||
"refreshExpiresIn": "30d"
|
||||
},
|
||||
"redis": {
|
||||
"host": "${REDIS_HOST}",
|
||||
"port": 6379,
|
||||
"password": "${REDIS_PASSWORD}",
|
||||
"db": 5
|
||||
},
|
||||
"kafka": {
|
||||
"brokers": "${KAFKA_BROKERS}",
|
||||
"clientId": "mpc-party-service",
|
||||
"groupId": "mpc-party-group"
|
||||
},
|
||||
"mpc": {
|
||||
"coordinatorUrl": "${MPC_COORDINATOR_URL}",
|
||||
"coordinatorTimeout": 30000,
|
||||
"messageRouterWsUrl": "${MPC_MESSAGE_ROUTER_WS_URL}",
|
||||
"shareMasterKey": "${SHARE_MASTER_KEY}",
|
||||
"keygenTimeout": 300000,
|
||||
"signingTimeout": 180000,
|
||||
"refreshTimeout": 300000
|
||||
},
|
||||
"tss": {
|
||||
"libPath": "${TSS_LIB_PATH}",
|
||||
"tempDir": "${TSS_TEMP_DIR}"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"app": {
|
||||
"port": 3016,
|
||||
"env": "test",
|
||||
"apiPrefix": "api/v1"
|
||||
},
|
||||
"database": {
|
||||
"url": "postgresql://mpc_user:mpc_password@localhost:5432/mpc_service_test"
|
||||
},
|
||||
"jwt": {
|
||||
"secret": "test-jwt-secret",
|
||||
"accessExpiresIn": "1h",
|
||||
"refreshExpiresIn": "1d"
|
||||
},
|
||||
"redis": {
|
||||
"host": "localhost",
|
||||
"port": 6379,
|
||||
"password": "",
|
||||
"db": 15
|
||||
},
|
||||
"kafka": {
|
||||
"brokers": ["localhost:9092"],
|
||||
"clientId": "mpc-party-service-test",
|
||||
"groupId": "mpc-party-group-test"
|
||||
},
|
||||
"mpc": {
|
||||
"coordinatorUrl": "http://localhost:50051",
|
||||
"coordinatorTimeout": 5000,
|
||||
"messageRouterWsUrl": "ws://localhost:50052",
|
||||
"keygenTimeout": 60000,
|
||||
"signingTimeout": 30000,
|
||||
"refreshTimeout": 60000
|
||||
},
|
||||
"tss": {
|
||||
"libPath": "/opt/tss-lib/tss",
|
||||
"tempDir": "/tmp/tss-test"
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
|
|
@ -1605,6 +1606,17 @@
|
|||
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nestjs/axios": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz",
|
||||
"integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"axios": "^1.3.1",
|
||||
"rxjs": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/cli": {
|
||||
"version": "10.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
|
||||
|
|
@ -3204,6 +3216,7 @@
|
|||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"db:seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
// =============================================================================
|
||||
// MPC Party Service - Prisma Schema
|
||||
// MPC Service - Prisma Schema
|
||||
// =============================================================================
|
||||
//
|
||||
// mpc-service 作为 MPC 服务网关:
|
||||
// 1. 缓存 username + publicKey 的映射关系
|
||||
// 2. 存储 delegate share (由 mpc-system delegate server-party-api 返回)
|
||||
//
|
||||
// 所有其他 MPC 相关数据由 mpc-system 管理
|
||||
//
|
||||
// =============================================================================
|
||||
|
||||
generator client {
|
||||
|
|
@ -12,69 +20,32 @@ datasource db {
|
|||
}
|
||||
|
||||
// =============================================================================
|
||||
// Party Shares Table
|
||||
// MPC Wallets Table (缓存用户公钥,用于本地快速查询)
|
||||
// =============================================================================
|
||||
model PartyShare {
|
||||
id String @id @db.VarChar(255)
|
||||
partyId String @map("party_id") @db.VarChar(255)
|
||||
sessionId String @map("session_id") @db.VarChar(255)
|
||||
shareType String @map("share_type") @db.VarChar(20)
|
||||
shareData String @map("share_data") @db.Text
|
||||
publicKey String @map("public_key") @db.Text
|
||||
thresholdN Int @map("threshold_n")
|
||||
thresholdT Int @map("threshold_t")
|
||||
status String @default("active") @db.VarChar(20)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
lastUsedAt DateTime? @map("last_used_at")
|
||||
model MpcWallet {
|
||||
id String @id @default(uuid())
|
||||
username String @unique @db.VarChar(255)
|
||||
publicKey String @map("public_key") @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([partyId, sessionId], name: "uk_party_session")
|
||||
@@index([partyId], name: "idx_ps_party_id")
|
||||
@@index([sessionId], name: "idx_ps_session_id")
|
||||
@@index([status], name: "idx_ps_status")
|
||||
@@map("party_shares")
|
||||
@@index([username], name: "idx_mw_username")
|
||||
@@map("mpc_wallets")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Session States Table
|
||||
// MPC Shares Table (存储 delegate share,由 mpc-system 返回给用户)
|
||||
// =============================================================================
|
||||
model SessionState {
|
||||
id String @id @db.VarChar(255)
|
||||
sessionId String @map("session_id") @db.VarChar(255)
|
||||
partyId String @map("party_id") @db.VarChar(255)
|
||||
partyIndex Int @map("party_index")
|
||||
sessionType String @map("session_type") @db.VarChar(20)
|
||||
participants String @db.Text // JSON array
|
||||
thresholdN Int @map("threshold_n")
|
||||
thresholdT Int @map("threshold_t")
|
||||
status String @db.VarChar(20)
|
||||
currentRound Int @default(0) @map("current_round")
|
||||
errorMessage String? @map("error_message") @db.Text
|
||||
publicKey String? @map("public_key") @db.Text
|
||||
messageHash String? @map("message_hash") @db.VarChar(66)
|
||||
signature String? @db.Text
|
||||
startedAt DateTime @default(now()) @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
model MpcShare {
|
||||
id String @id @default(uuid())
|
||||
username String @db.VarChar(255)
|
||||
partyId String @map("party_id") @db.VarChar(255)
|
||||
partyIndex Int @map("party_index")
|
||||
encryptedShare String @map("encrypted_share") @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([sessionId, partyId], name: "uk_session_party")
|
||||
@@index([sessionId], name: "idx_ss_session_id")
|
||||
@@index([partyId], name: "idx_ss_party_id")
|
||||
@@index([status], name: "idx_ss_status")
|
||||
@@map("session_states")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Share Backups Table (for disaster recovery)
|
||||
// =============================================================================
|
||||
model ShareBackup {
|
||||
id String @id @db.VarChar(255)
|
||||
shareId String @map("share_id") @db.VarChar(255)
|
||||
backupData String @map("backup_data") @db.Text
|
||||
backupType String @map("backup_type") @db.VarChar(20)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by") @db.VarChar(255)
|
||||
|
||||
@@index([shareId], name: "idx_share_id")
|
||||
@@index([createdAt], name: "idx_created_at")
|
||||
@@map("share_backups")
|
||||
@@unique([username], name: "uq_ms_username")
|
||||
@@index([username], name: "idx_ms_username")
|
||||
@@map("mpc_shares")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
/**
|
||||
* API Module
|
||||
*
|
||||
* Registers API controllers.
|
||||
* mpc-service 作为网关的 API 层
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ApplicationModule } from '../application/application.module';
|
||||
import { MPCPartyController } from './controllers/mpc-party.controller';
|
||||
import { HealthController } from './controllers/health.controller';
|
||||
import { MPCController } from './controllers/mpc.controller';
|
||||
|
||||
// Re-export API components for easier imports
|
||||
export * from './controllers';
|
||||
|
||||
@Module({
|
||||
imports: [ApplicationModule],
|
||||
controllers: [
|
||||
MPCPartyController,
|
||||
HealthController,
|
||||
MPCController,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
export * from './mpc-party.controller';
|
||||
/**
|
||||
* API Controllers Index
|
||||
*/
|
||||
|
||||
export * from './health.controller';
|
||||
export * from './mpc.controller';
|
||||
|
|
|
|||
|
|
@ -1,283 +0,0 @@
|
|||
/**
|
||||
* MPC Party Controller
|
||||
*
|
||||
* REST API endpoints for MPC party operations.
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { MPCPartyApplicationService } from '../../application/services/mpc-party-application.service';
|
||||
import {
|
||||
ParticipateKeygenDto,
|
||||
ParticipateSigningDto,
|
||||
RotateShareDto,
|
||||
ListSharesDto,
|
||||
} from '../dto/request';
|
||||
import {
|
||||
KeygenResultDto,
|
||||
KeygenAcceptedDto,
|
||||
SigningResultDto,
|
||||
SigningAcceptedDto,
|
||||
ShareInfoResponseDto,
|
||||
ListSharesResponseDto,
|
||||
} from '../dto/response';
|
||||
import { JwtAuthGuard } from '../../shared/guards/jwt-auth.guard';
|
||||
import { Public } from '../../shared/decorators/public.decorator';
|
||||
|
||||
@ApiTags('MPC Party')
|
||||
@Controller('mpc-party')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class MPCPartyController {
|
||||
private readonly logger = new Logger(MPCPartyController.name);
|
||||
|
||||
constructor(
|
||||
private readonly mpcPartyService: MPCPartyApplicationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Participate in key generation (async)
|
||||
* Note: Marked as Public for internal service-to-service calls
|
||||
* TODO: Add proper service authentication (API key or service JWT)
|
||||
*/
|
||||
@Public()
|
||||
@Post('keygen/participate')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@ApiOperation({
|
||||
summary: '参与MPC密钥生成',
|
||||
description: '加入一个MPC Keygen会话,生成密钥分片',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 202,
|
||||
description: 'Keygen participation accepted',
|
||||
type: KeygenAcceptedDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Bad request' })
|
||||
async participateInKeygen(@Body() dto: ParticipateKeygenDto): Promise<KeygenAcceptedDto> {
|
||||
this.logger.log(`Keygen participation request: session=${dto.sessionId}, party=${dto.partyId}`);
|
||||
|
||||
// Execute asynchronously (MPC protocol may take minutes)
|
||||
this.mpcPartyService.participateInKeygen({
|
||||
sessionId: dto.sessionId,
|
||||
partyId: dto.partyId,
|
||||
joinToken: dto.joinToken,
|
||||
shareType: dto.shareType,
|
||||
userId: dto.userId,
|
||||
}).catch(error => {
|
||||
this.logger.error(`Keygen failed: ${error.message}`, error.stack);
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Keygen participation started',
|
||||
sessionId: dto.sessionId,
|
||||
partyId: dto.partyId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Participate in key generation (sync - for testing)
|
||||
* Note: Marked as Public for internal service-to-service calls
|
||||
* TODO: Add proper service authentication (API key or service JWT)
|
||||
*/
|
||||
@Public()
|
||||
@Post('keygen/participate-sync')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '参与MPC密钥生成 (同步)',
|
||||
description: '同步方式参与Keygen,等待完成后返回结果',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Keygen completed',
|
||||
type: KeygenResultDto,
|
||||
})
|
||||
async participateInKeygenSync(@Body() dto: ParticipateKeygenDto): Promise<KeygenResultDto> {
|
||||
this.logger.log(`Keygen sync request: session=${dto.sessionId}, party=${dto.partyId}`);
|
||||
|
||||
const result = await this.mpcPartyService.participateInKeygen({
|
||||
sessionId: dto.sessionId,
|
||||
partyId: dto.partyId,
|
||||
joinToken: dto.joinToken,
|
||||
shareType: dto.shareType,
|
||||
userId: dto.userId,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Participate in signing (async)
|
||||
* Note: Marked as Public for internal service-to-service calls
|
||||
* TODO: Add proper service authentication (API key or service JWT)
|
||||
*/
|
||||
@Public()
|
||||
@Post('signing/participate')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@ApiOperation({
|
||||
summary: '参与MPC签名',
|
||||
description: '加入一个MPC签名会话,参与分布式签名',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 202,
|
||||
description: 'Signing participation accepted',
|
||||
type: SigningAcceptedDto,
|
||||
})
|
||||
async participateInSigning(@Body() dto: ParticipateSigningDto): Promise<SigningAcceptedDto> {
|
||||
this.logger.log(`Signing participation request: session=${dto.sessionId}, party=${dto.partyId}`);
|
||||
|
||||
// Execute asynchronously
|
||||
this.mpcPartyService.participateInSigning({
|
||||
sessionId: dto.sessionId,
|
||||
partyId: dto.partyId,
|
||||
joinToken: dto.joinToken,
|
||||
messageHash: dto.messageHash,
|
||||
publicKey: dto.publicKey,
|
||||
}).catch(error => {
|
||||
this.logger.error(`Signing failed: ${error.message}`, error.stack);
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Signing participation started',
|
||||
sessionId: dto.sessionId,
|
||||
partyId: dto.partyId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Participate in signing (sync - for testing)
|
||||
* Note: Marked as Public for internal service-to-service calls
|
||||
* TODO: Add proper service authentication (API key or service JWT)
|
||||
*/
|
||||
@Public()
|
||||
@Post('signing/participate-sync')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '参与MPC签名 (同步)',
|
||||
description: '同步方式参与签名,等待完成后返回签名结果',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Signing completed',
|
||||
type: SigningResultDto,
|
||||
})
|
||||
async participateInSigningSync(@Body() dto: ParticipateSigningDto): Promise<SigningResultDto> {
|
||||
this.logger.log(`Signing sync request: session=${dto.sessionId}, party=${dto.partyId}`);
|
||||
|
||||
const result = await this.mpcPartyService.participateInSigning({
|
||||
sessionId: dto.sessionId,
|
||||
partyId: dto.partyId,
|
||||
joinToken: dto.joinToken,
|
||||
messageHash: dto.messageHash,
|
||||
publicKey: dto.publicKey,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate share
|
||||
*/
|
||||
@Post('share/rotate')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@ApiOperation({
|
||||
summary: '密钥分片轮换',
|
||||
description: '参与密钥刷新协议,更新本地分片',
|
||||
})
|
||||
@ApiResponse({ status: 202, description: 'Rotation started' })
|
||||
async rotateShare(@Body() dto: RotateShareDto) {
|
||||
this.logger.log(`Share rotation request: session=${dto.sessionId}, party=${dto.partyId}`);
|
||||
|
||||
this.mpcPartyService.rotateShare({
|
||||
sessionId: dto.sessionId,
|
||||
partyId: dto.partyId,
|
||||
joinToken: dto.joinToken,
|
||||
publicKey: dto.publicKey,
|
||||
}).catch(error => {
|
||||
this.logger.error(`Rotation failed: ${error.message}`, error.stack);
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Share rotation started',
|
||||
sessionId: dto.sessionId,
|
||||
partyId: dto.partyId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get share info
|
||||
*/
|
||||
@Get('shares/:shareId')
|
||||
@ApiOperation({
|
||||
summary: '获取分片信息',
|
||||
description: '获取指定分片的详细信息',
|
||||
})
|
||||
@ApiParam({ name: 'shareId', description: 'Share ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Share info',
|
||||
type: ShareInfoResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Share not found' })
|
||||
async getShareInfo(@Param('shareId') shareId: string): Promise<ShareInfoResponseDto> {
|
||||
this.logger.log(`Get share info: ${shareId}`);
|
||||
return this.mpcPartyService.getShareInfo(shareId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List shares
|
||||
*/
|
||||
@Get('shares')
|
||||
@ApiOperation({
|
||||
summary: '列出分片',
|
||||
description: '列出分片,支持过滤和分页',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of shares',
|
||||
type: ListSharesResponseDto,
|
||||
})
|
||||
async listShares(@Query() query: ListSharesDto): Promise<ListSharesResponseDto> {
|
||||
this.logger.log(`List shares: ${JSON.stringify(query)}`);
|
||||
|
||||
return this.mpcPartyService.listShares({
|
||||
partyId: query.partyId,
|
||||
status: query.status,
|
||||
shareType: query.shareType,
|
||||
publicKey: query.publicKey,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check (public)
|
||||
*/
|
||||
@Public()
|
||||
@Get('health')
|
||||
@ApiOperation({ summary: '健康检查' })
|
||||
@ApiResponse({ status: 200, description: 'Service is healthy' })
|
||||
health() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'mpc-party-service',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +66,6 @@ export class KeygenStatusResponseDto {
|
|||
partyIndex: number;
|
||||
encryptedShare: string;
|
||||
};
|
||||
serverParties?: string[];
|
||||
}
|
||||
|
||||
export class SigningSessionResponseDto {
|
||||
|
|
@ -146,7 +145,6 @@ export class MPCController {
|
|||
status: result.status,
|
||||
publicKey: result.publicKey,
|
||||
delegateShare: result.delegateShare,
|
||||
serverParties: result.serverParties,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -226,12 +224,11 @@ export class MPCController {
|
|||
async getPublicKey(@Param('username') username: string) {
|
||||
this.logger.debug(`Getting public key: username=${username}`);
|
||||
|
||||
const result = await this.mpcCoordinatorService.getWalletByUsername(username);
|
||||
const result = await this.mpcCoordinatorService.getPublicKeyByUsername(username);
|
||||
|
||||
return {
|
||||
username: result.username,
|
||||
publicKey: result.publicKey,
|
||||
keygenSessionId: result.keygenSessionId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,6 @@
|
|||
/**
|
||||
* API DTOs Index
|
||||
*/
|
||||
|
||||
export * from './request';
|
||||
export * from './response';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Hex String Validator
|
||||
*
|
||||
* Custom validators for hex string formats (public keys, signatures, hashes).
|
||||
*/
|
||||
|
||||
import {
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
ValidationArguments,
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
} from 'class-validator';
|
||||
|
||||
// ============================================================================
|
||||
// Message Hash Validator (32 bytes / 64 hex chars)
|
||||
// ============================================================================
|
||||
|
||||
@ValidatorConstraint({ name: 'isMessageHash', async: false })
|
||||
export class IsMessageHashConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: string, _args: ValidationArguments): boolean {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const cleanHex = value.replace('0x', '');
|
||||
return /^[a-fA-F0-9]{64}$/.test(cleanHex);
|
||||
}
|
||||
|
||||
defaultMessage(_args: ValidationArguments): string {
|
||||
return 'messageHash must be a 32-byte hex string (64 hex characters)';
|
||||
}
|
||||
}
|
||||
|
||||
export function IsMessageHash(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: IsMessageHashConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public Key Validator (33 bytes compressed or 65 bytes uncompressed)
|
||||
// ============================================================================
|
||||
|
||||
@ValidatorConstraint({ name: 'isPublicKey', async: false })
|
||||
export class IsPublicKeyConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: string, _args: ValidationArguments): boolean {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const cleanHex = value.replace('0x', '');
|
||||
// 33 bytes (compressed) = 66 hex chars
|
||||
// 65 bytes (uncompressed) = 130 hex chars
|
||||
return /^[a-fA-F0-9]{66}$/.test(cleanHex) || /^[a-fA-F0-9]{130}$/.test(cleanHex);
|
||||
}
|
||||
|
||||
defaultMessage(_args: ValidationArguments): string {
|
||||
return 'publicKey must be a valid 33-byte (compressed) or 65-byte (uncompressed) public key';
|
||||
}
|
||||
}
|
||||
|
||||
export function IsPublicKey(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: IsPublicKeyConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Signature Validator (64 or 65 bytes)
|
||||
// ============================================================================
|
||||
|
||||
@ValidatorConstraint({ name: 'isSignature', async: false })
|
||||
export class IsSignatureConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: string, _args: ValidationArguments): boolean {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const cleanHex = value.replace('0x', '');
|
||||
// 64 bytes (r + s) = 128 hex chars
|
||||
// 65 bytes (r + s + v) = 130 hex chars
|
||||
return /^[a-fA-F0-9]{128}$/.test(cleanHex) || /^[a-fA-F0-9]{130}$/.test(cleanHex);
|
||||
}
|
||||
|
||||
defaultMessage(_args: ValidationArguments): string {
|
||||
return 'signature must be a valid 64-byte or 65-byte signature';
|
||||
}
|
||||
}
|
||||
|
||||
export function IsSignature(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: IsSignatureConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Generic Hex String Validator
|
||||
// ============================================================================
|
||||
|
||||
export interface HexStringOptions {
|
||||
minBytes?: number;
|
||||
maxBytes?: number;
|
||||
exactBytes?: number;
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'isHexString', async: false })
|
||||
export class IsHexStringConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: string, args: ValidationArguments): boolean {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cleanHex = value.replace('0x', '');
|
||||
if (!/^[a-fA-F0-9]*$/.test(cleanHex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const options = (args.constraints[0] || {}) as HexStringOptions;
|
||||
const byteLength = cleanHex.length / 2;
|
||||
|
||||
if (options.exactBytes !== undefined && byteLength !== options.exactBytes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.minBytes !== undefined && byteLength < options.minBytes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.maxBytes !== undefined && byteLength > options.maxBytes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
const options = (args.constraints[0] || {}) as HexStringOptions;
|
||||
if (options.exactBytes !== undefined) {
|
||||
return `Value must be exactly ${options.exactBytes} bytes (${options.exactBytes * 2} hex characters)`;
|
||||
}
|
||||
if (options.minBytes !== undefined && options.maxBytes !== undefined) {
|
||||
return `Value must be between ${options.minBytes} and ${options.maxBytes} bytes`;
|
||||
}
|
||||
return 'Value must be a valid hex string';
|
||||
}
|
||||
}
|
||||
|
||||
export function IsHexString(
|
||||
options?: HexStringOptions,
|
||||
validationOptions?: ValidationOptions,
|
||||
) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [options],
|
||||
validator: IsHexStringConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* API Validators Index
|
||||
*
|
||||
* Custom validators for request validation.
|
||||
*/
|
||||
|
||||
export * from './party-id.validator';
|
||||
export * from './threshold.validator';
|
||||
export * from './hex-string.validator';
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* PartyId Validator
|
||||
*
|
||||
* Custom validator for PartyId format validation.
|
||||
*/
|
||||
|
||||
import {
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
ValidationArguments,
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
} from 'class-validator';
|
||||
|
||||
@ValidatorConstraint({ name: 'isPartyId', async: false })
|
||||
export class IsPartyIdConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: string, _args: ValidationArguments): boolean {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
// Format: {identifier}-{type} e.g., user123-server
|
||||
const partyIdRegex = /^[\w]+-[\w]+$/;
|
||||
return partyIdRegex.test(value);
|
||||
}
|
||||
|
||||
defaultMessage(_args: ValidationArguments): string {
|
||||
return 'PartyId must be in format: {identifier}-{type} (e.g., user123-server)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator for PartyId validation
|
||||
*/
|
||||
export function IsPartyId(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: IsPartyIdConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Threshold Validator
|
||||
*
|
||||
* Custom validator for MPC threshold configuration validation.
|
||||
*/
|
||||
|
||||
import {
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
ValidationArguments,
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
} from 'class-validator';
|
||||
|
||||
export interface ThresholdValidatorOptions {
|
||||
minT?: number;
|
||||
maxN?: number;
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'isThreshold', async: false })
|
||||
export class IsThresholdConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: { n: number; t: number }, args: ValidationArguments): boolean {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { n, t } = value;
|
||||
const options = (args.constraints[0] || {}) as ThresholdValidatorOptions;
|
||||
const minT = options.minT ?? 2;
|
||||
const maxN = options.maxN ?? 10;
|
||||
|
||||
// Basic validation
|
||||
if (!Number.isInteger(n) || !Number.isInteger(t)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Value bounds
|
||||
if (n <= 0 || t <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// t cannot exceed n
|
||||
if (t > n) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Security: t must be at least minT
|
||||
if (t < minT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Practical limit: n cannot exceed maxN
|
||||
if (n > maxN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
const options = (args.constraints[0] || {}) as ThresholdValidatorOptions;
|
||||
const minT = options.minT ?? 2;
|
||||
const maxN = options.maxN ?? 10;
|
||||
return `Threshold must satisfy: t >= ${minT}, n <= ${maxN}, and t <= n`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator for Threshold validation
|
||||
*/
|
||||
export function IsThreshold(
|
||||
options?: ThresholdValidatorOptions,
|
||||
validationOptions?: ValidationOptions,
|
||||
) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [options],
|
||||
validator: IsThresholdConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -1,54 +1,26 @@
|
|||
/**
|
||||
* Application Module
|
||||
*
|
||||
* Registers application layer services (handlers, services).
|
||||
* mpc-service 作为网关,只需要 MPCCoordinatorService 转发请求到 mpc-system
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DomainModule } from '../domain/domain.module';
|
||||
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
||||
|
||||
// Commands
|
||||
import { ParticipateInKeygenHandler } from './commands/participate-keygen';
|
||||
import { ParticipateInSigningHandler } from './commands/participate-signing';
|
||||
import { RotateShareHandler } from './commands/rotate-share';
|
||||
|
||||
// Queries
|
||||
import { GetShareInfoHandler } from './queries/get-share-info';
|
||||
import { ListSharesHandler } from './queries/list-shares';
|
||||
|
||||
// Services
|
||||
import { MPCPartyApplicationService } from './services/mpc-party-application.service';
|
||||
import { MPCCoordinatorService } from './services/mpc-coordinator.service';
|
||||
|
||||
// Entities
|
||||
import { MpcWallet, MpcShare, MpcSession } from '../domain/entities';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DomainModule,
|
||||
InfrastructureModule,
|
||||
HttpModule,
|
||||
TypeOrmModule.forFeature([MpcWallet, MpcShare, MpcSession]),
|
||||
],
|
||||
providers: [
|
||||
// Command Handlers
|
||||
ParticipateInKeygenHandler,
|
||||
ParticipateInSigningHandler,
|
||||
RotateShareHandler,
|
||||
|
||||
// Query Handlers
|
||||
GetShareInfoHandler,
|
||||
ListSharesHandler,
|
||||
|
||||
// Application Services
|
||||
MPCPartyApplicationService,
|
||||
MPCCoordinatorService,
|
||||
],
|
||||
exports: [
|
||||
MPCPartyApplicationService,
|
||||
MPCCoordinatorService,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
/**
|
||||
* Commands Index
|
||||
*/
|
||||
|
||||
export * from './participate-keygen';
|
||||
export * from './participate-signing';
|
||||
export * from './rotate-share';
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './participate-keygen.command';
|
||||
export * from './participate-keygen.handler';
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
/**
|
||||
* Participate In Keygen Command
|
||||
*
|
||||
* Command to participate in an MPC key generation session.
|
||||
*/
|
||||
|
||||
import { PartyShareType } from '../../../domain/enums';
|
||||
|
||||
export class ParticipateInKeygenCommand {
|
||||
constructor(
|
||||
/**
|
||||
* The MPC session ID to join
|
||||
*/
|
||||
public readonly sessionId: string,
|
||||
|
||||
/**
|
||||
* This party's identifier
|
||||
*/
|
||||
public readonly partyId: string,
|
||||
|
||||
/**
|
||||
* Token to authenticate with the session coordinator
|
||||
*/
|
||||
public readonly joinToken: string,
|
||||
|
||||
/**
|
||||
* Type of share being generated (wallet, admin, recovery)
|
||||
*/
|
||||
public readonly shareType: PartyShareType,
|
||||
|
||||
/**
|
||||
* Optional: Associated user ID (for wallet shares)
|
||||
*/
|
||||
public readonly userId?: string,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -1,288 +0,0 @@
|
|||
/**
|
||||
* Participate In Keygen Handler
|
||||
*
|
||||
* Handles the ParticipateInKeygenCommand by:
|
||||
* 1. Joining the MPC session via coordinator
|
||||
* 2. Running the TSS keygen protocol
|
||||
* 3. Encrypting and storing the resulting share
|
||||
* 4. Publishing domain events
|
||||
*/
|
||||
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ParticipateInKeygenCommand } from './participate-keygen.command';
|
||||
import { PartyShare } from '../../../domain/entities/party-share.entity';
|
||||
import { SessionState, Participant } from '../../../domain/entities/session-state.entity';
|
||||
import {
|
||||
SessionId,
|
||||
PartyId,
|
||||
ShareData,
|
||||
PublicKey,
|
||||
Threshold,
|
||||
} from '../../../domain/value-objects';
|
||||
import { SessionType, ParticipantStatus, KeyCurve } from '../../../domain/enums';
|
||||
import { ShareEncryptionDomainService } from '../../../domain/services/share-encryption.domain-service';
|
||||
import {
|
||||
TSS_PROTOCOL_SERVICE,
|
||||
TSSProtocolDomainService,
|
||||
TSSMessage,
|
||||
TSSParticipant,
|
||||
} from '../../../domain/services/tss-protocol.domain-service';
|
||||
import {
|
||||
PARTY_SHARE_REPOSITORY,
|
||||
PartyShareRepository,
|
||||
} from '../../../domain/repositories/party-share.repository.interface';
|
||||
import {
|
||||
SESSION_STATE_REPOSITORY,
|
||||
SessionStateRepository,
|
||||
} from '../../../domain/repositories/session-state.repository.interface';
|
||||
import { EventPublisherService } from '../../../infrastructure/messaging/kafka/event-publisher.service';
|
||||
import { MPCCoordinatorClient, SessionInfo } from '../../../infrastructure/external/mpc-system/coordinator-client';
|
||||
import { MPCMessageRouterClient } from '../../../infrastructure/external/mpc-system/message-router-client';
|
||||
import { ApplicationError } from '../../../shared/exceptions/domain.exception';
|
||||
|
||||
export interface KeygenResult {
|
||||
shareId: string;
|
||||
publicKey: string;
|
||||
threshold: string;
|
||||
sessionId: string;
|
||||
partyId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ParticipateInKeygenHandler {
|
||||
private readonly logger = new Logger(ParticipateInKeygenHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(PARTY_SHARE_REPOSITORY)
|
||||
private readonly partyShareRepo: PartyShareRepository,
|
||||
@Inject(SESSION_STATE_REPOSITORY)
|
||||
private readonly sessionStateRepo: SessionStateRepository,
|
||||
@Inject(TSS_PROTOCOL_SERVICE)
|
||||
private readonly tssProtocol: TSSProtocolDomainService,
|
||||
private readonly encryptionService: ShareEncryptionDomainService,
|
||||
private readonly coordinatorClient: MPCCoordinatorClient,
|
||||
private readonly messageRouter: MPCMessageRouterClient,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async execute(command: ParticipateInKeygenCommand): Promise<KeygenResult> {
|
||||
this.logger.log(`Starting Keygen participation for party: ${command.partyId}, session: ${command.sessionId}`);
|
||||
|
||||
// 1. Join the session via coordinator
|
||||
const sessionInfo = await this.joinSession(command);
|
||||
this.logger.log(`Joined session with ${sessionInfo.participants.length} participants`);
|
||||
|
||||
// 2. Create session state for tracking
|
||||
const sessionState = this.createSessionState(command, sessionInfo);
|
||||
await this.sessionStateRepo.save(sessionState);
|
||||
|
||||
try {
|
||||
// 3. Setup message channels
|
||||
const { sender, receiver } = await this.setupMessageChannels(
|
||||
command.sessionId,
|
||||
command.partyId,
|
||||
);
|
||||
|
||||
// 4. Run TSS keygen protocol
|
||||
this.logger.log('Starting TSS Keygen protocol...');
|
||||
const keygenResult = await this.tssProtocol.runKeygen(
|
||||
command.partyId,
|
||||
this.convertParticipants(sessionInfo.participants),
|
||||
Threshold.create(sessionInfo.thresholdN, sessionInfo.thresholdT),
|
||||
{
|
||||
curve: KeyCurve.SECP256K1,
|
||||
timeout: this.configService.get<number>('MPC_KEYGEN_TIMEOUT', 300000),
|
||||
},
|
||||
sender,
|
||||
receiver,
|
||||
);
|
||||
this.logger.log('TSS Keygen protocol completed successfully');
|
||||
|
||||
// 5. Encrypt the share data
|
||||
const masterKey = await this.getMasterKey();
|
||||
const encryptedShareData = this.encryptionService.encrypt(
|
||||
keygenResult.shareData,
|
||||
masterKey,
|
||||
);
|
||||
|
||||
// 6. Create and save party share
|
||||
const partyShare = PartyShare.create({
|
||||
partyId: PartyId.create(command.partyId),
|
||||
sessionId: SessionId.create(command.sessionId),
|
||||
shareType: command.shareType,
|
||||
shareData: encryptedShareData,
|
||||
publicKey: PublicKey.fromHex(keygenResult.publicKey),
|
||||
threshold: Threshold.create(sessionInfo.thresholdN, sessionInfo.thresholdT),
|
||||
});
|
||||
await this.partyShareRepo.save(partyShare);
|
||||
this.logger.log(`Share saved with ID: ${partyShare.id.value}`);
|
||||
|
||||
// 7. Report completion to coordinator
|
||||
await this.coordinatorClient.reportCompletion({
|
||||
sessionId: command.sessionId,
|
||||
partyId: command.partyId,
|
||||
publicKey: keygenResult.publicKey,
|
||||
});
|
||||
|
||||
// 8. Update session state
|
||||
sessionState.completeKeygen(
|
||||
PublicKey.fromHex(keygenResult.publicKey),
|
||||
partyShare.id.value,
|
||||
);
|
||||
await this.sessionStateRepo.update(sessionState);
|
||||
|
||||
// 9. Publish domain events
|
||||
await this.eventPublisher.publishAll(partyShare.domainEvents);
|
||||
await this.eventPublisher.publishAll(sessionState.domainEvents);
|
||||
partyShare.clearDomainEvents();
|
||||
sessionState.clearDomainEvents();
|
||||
|
||||
this.logger.log(`Keygen completed successfully. Share ID: ${partyShare.id.value}`);
|
||||
|
||||
return {
|
||||
shareId: partyShare.id.value,
|
||||
publicKey: keygenResult.publicKey,
|
||||
threshold: partyShare.threshold.toString(),
|
||||
sessionId: command.sessionId,
|
||||
partyId: command.partyId,
|
||||
};
|
||||
} catch (error) {
|
||||
// Handle failure
|
||||
this.logger.error(`Keygen failed: ${error.message}`, error.stack);
|
||||
|
||||
sessionState.fail(error.message, 'KEYGEN_FAILED');
|
||||
await this.sessionStateRepo.update(sessionState);
|
||||
await this.eventPublisher.publishAll(sessionState.domainEvents);
|
||||
sessionState.clearDomainEvents();
|
||||
|
||||
throw new ApplicationError(`Keygen failed: ${error.message}`, 'KEYGEN_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
private async joinSession(command: ParticipateInKeygenCommand): Promise<SessionInfo> {
|
||||
try {
|
||||
// First, create the session via coordinator to get a valid JWT token
|
||||
// The session-coordinator expects us to create a session first
|
||||
this.logger.log('Creating MPC session via coordinator...');
|
||||
const createResponse = await this.coordinatorClient.createSession({
|
||||
sessionType: 'keygen',
|
||||
thresholdN: 3, // Default 2-of-3 MPC
|
||||
thresholdT: 2,
|
||||
createdBy: command.partyId,
|
||||
expiresIn: 600, // 10 minutes
|
||||
});
|
||||
this.logger.log(`Session created: ${createResponse.sessionId}, now joining...`);
|
||||
|
||||
// Now join using the valid JWT token from the coordinator
|
||||
const sessionInfo = await this.coordinatorClient.joinSession({
|
||||
sessionId: createResponse.sessionId,
|
||||
partyId: command.partyId,
|
||||
joinToken: createResponse.joinToken, // Use the JWT from createSession
|
||||
});
|
||||
|
||||
// Return session info with the original session ID for consistency
|
||||
return {
|
||||
...sessionInfo,
|
||||
sessionId: createResponse.sessionId,
|
||||
joinToken: createResponse.joinToken,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new ApplicationError(
|
||||
`Failed to join session: ${error.message}`,
|
||||
'JOIN_SESSION_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private createSessionState(
|
||||
command: ParticipateInKeygenCommand,
|
||||
sessionInfo: SessionInfo,
|
||||
): SessionState {
|
||||
const participants: Participant[] = sessionInfo.participants.map(p => ({
|
||||
partyId: p.partyId,
|
||||
partyIndex: p.partyIndex,
|
||||
status: p.partyId === command.partyId
|
||||
? ParticipantStatus.JOINED
|
||||
: ParticipantStatus.PENDING,
|
||||
}));
|
||||
|
||||
const myParty = sessionInfo.participants.find(p => p.partyId === command.partyId);
|
||||
if (!myParty) {
|
||||
throw new ApplicationError('Party not found in session participants', 'PARTY_NOT_FOUND');
|
||||
}
|
||||
|
||||
return SessionState.create({
|
||||
sessionId: SessionId.create(command.sessionId),
|
||||
partyId: PartyId.create(command.partyId),
|
||||
partyIndex: myParty.partyIndex,
|
||||
sessionType: SessionType.KEYGEN,
|
||||
participants,
|
||||
thresholdN: sessionInfo.thresholdN,
|
||||
thresholdT: sessionInfo.thresholdT,
|
||||
});
|
||||
}
|
||||
|
||||
private async setupMessageChannels(
|
||||
sessionId: string,
|
||||
partyId: string,
|
||||
): Promise<{ sender: (msg: TSSMessage) => Promise<void>; receiver: AsyncIterable<TSSMessage> }> {
|
||||
// Subscribe to incoming messages
|
||||
const messageStream = await this.messageRouter.subscribeMessages(sessionId, partyId);
|
||||
|
||||
// Create sender function
|
||||
const sender = async (msg: TSSMessage): Promise<void> => {
|
||||
await this.messageRouter.sendMessage({
|
||||
sessionId,
|
||||
fromParty: partyId,
|
||||
toParties: msg.toParties,
|
||||
roundNumber: msg.roundNumber,
|
||||
payload: msg.payload,
|
||||
});
|
||||
};
|
||||
|
||||
// Create async iterator for receiving messages
|
||||
const receiver: AsyncIterable<TSSMessage> = {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async (): Promise<IteratorResult<TSSMessage>> => {
|
||||
const message = await messageStream.next();
|
||||
if (message.done) {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
fromParty: message.value.fromParty,
|
||||
toParties: message.value.toParties,
|
||||
roundNumber: message.value.roundNumber,
|
||||
payload: message.value.payload,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return { sender, receiver };
|
||||
}
|
||||
|
||||
private convertParticipants(
|
||||
participants: Array<{ partyId: string; partyIndex: number }>,
|
||||
): TSSParticipant[] {
|
||||
return participants.map(p => ({
|
||||
partyId: p.partyId,
|
||||
partyIndex: p.partyIndex,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getMasterKey(): Promise<Buffer> {
|
||||
const keyHex = this.configService.get<string>('SHARE_MASTER_KEY');
|
||||
if (!keyHex) {
|
||||
throw new ApplicationError(
|
||||
'SHARE_MASTER_KEY not configured',
|
||||
'CONFIG_ERROR',
|
||||
);
|
||||
}
|
||||
return Buffer.from(keyHex, 'hex');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './participate-signing.command';
|
||||
export * from './participate-signing.handler';
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
/**
|
||||
* Participate In Signing Command
|
||||
*
|
||||
* Command to participate in an MPC signing session.
|
||||
*/
|
||||
|
||||
export class ParticipateInSigningCommand {
|
||||
constructor(
|
||||
/**
|
||||
* The MPC session ID to join
|
||||
*/
|
||||
public readonly sessionId: string,
|
||||
|
||||
/**
|
||||
* This party's identifier
|
||||
*/
|
||||
public readonly partyId: string,
|
||||
|
||||
/**
|
||||
* Token to authenticate with the session coordinator
|
||||
*/
|
||||
public readonly joinToken: string,
|
||||
|
||||
/**
|
||||
* Hash of the message to sign (hex format with or without 0x prefix)
|
||||
*/
|
||||
public readonly messageHash: string,
|
||||
|
||||
/**
|
||||
* Optional: The public key to use for signing
|
||||
* If not provided, will look up the share based on session info
|
||||
*/
|
||||
public readonly publicKey?: string,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -1,341 +0,0 @@
|
|||
/**
|
||||
* Participate In Signing Handler
|
||||
*
|
||||
* Handles the ParticipateInSigningCommand by:
|
||||
* 1. Joining the MPC signing session
|
||||
* 2. Loading and decrypting the party's share
|
||||
* 3. Running the TSS signing protocol
|
||||
* 4. Publishing domain events
|
||||
*/
|
||||
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ParticipateInSigningCommand } from './participate-signing.command';
|
||||
import { PartyShare } from '../../../domain/entities/party-share.entity';
|
||||
import { SessionState, Participant } from '../../../domain/entities/session-state.entity';
|
||||
import {
|
||||
SessionId,
|
||||
PartyId,
|
||||
PublicKey,
|
||||
Threshold,
|
||||
MessageHash,
|
||||
Signature,
|
||||
} from '../../../domain/value-objects';
|
||||
import { SessionType, ParticipantStatus, KeyCurve } from '../../../domain/enums';
|
||||
import { ShareEncryptionDomainService } from '../../../domain/services/share-encryption.domain-service';
|
||||
import {
|
||||
TSS_PROTOCOL_SERVICE,
|
||||
TSSProtocolDomainService,
|
||||
TSSMessage,
|
||||
TSSParticipant,
|
||||
} from '../../../domain/services/tss-protocol.domain-service';
|
||||
import {
|
||||
PARTY_SHARE_REPOSITORY,
|
||||
PartyShareRepository,
|
||||
} from '../../../domain/repositories/party-share.repository.interface';
|
||||
import {
|
||||
SESSION_STATE_REPOSITORY,
|
||||
SessionStateRepository,
|
||||
} from '../../../domain/repositories/session-state.repository.interface';
|
||||
import { EventPublisherService } from '../../../infrastructure/messaging/kafka/event-publisher.service';
|
||||
import { MPCCoordinatorClient, SessionInfo } from '../../../infrastructure/external/mpc-system/coordinator-client';
|
||||
import { MPCMessageRouterClient } from '../../../infrastructure/external/mpc-system/message-router-client';
|
||||
import { ApplicationError } from '../../../shared/exceptions/domain.exception';
|
||||
|
||||
export interface SigningResult {
|
||||
signature: string;
|
||||
r: string;
|
||||
s: string;
|
||||
v?: number;
|
||||
messageHash: string;
|
||||
publicKey: string;
|
||||
sessionId: string;
|
||||
partyId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ParticipateInSigningHandler {
|
||||
private readonly logger = new Logger(ParticipateInSigningHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(PARTY_SHARE_REPOSITORY)
|
||||
private readonly partyShareRepo: PartyShareRepository,
|
||||
@Inject(SESSION_STATE_REPOSITORY)
|
||||
private readonly sessionStateRepo: SessionStateRepository,
|
||||
@Inject(TSS_PROTOCOL_SERVICE)
|
||||
private readonly tssProtocol: TSSProtocolDomainService,
|
||||
private readonly encryptionService: ShareEncryptionDomainService,
|
||||
private readonly coordinatorClient: MPCCoordinatorClient,
|
||||
private readonly messageRouter: MPCMessageRouterClient,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async execute(command: ParticipateInSigningCommand): Promise<SigningResult> {
|
||||
this.logger.log(`Starting Signing participation for party: ${command.partyId}, session: ${command.sessionId}`);
|
||||
|
||||
// 1. Join the signing session
|
||||
const sessionInfo = await this.joinSession(command);
|
||||
this.logger.log(`Joined signing session with ${sessionInfo.participants.length} participants`);
|
||||
|
||||
// 2. Load the party's share
|
||||
const partyShare = await this.loadPartyShare(command, sessionInfo);
|
||||
this.logger.log(`Loaded share: ${partyShare.id.value}`);
|
||||
|
||||
// 3. Create session state for tracking
|
||||
const sessionState = this.createSessionState(command, sessionInfo, partyShare);
|
||||
await this.sessionStateRepo.save(sessionState);
|
||||
|
||||
try {
|
||||
// 4. Decrypt share data
|
||||
const masterKey = await this.getMasterKey();
|
||||
const rawShareData = this.encryptionService.decrypt(
|
||||
partyShare.shareData,
|
||||
masterKey,
|
||||
);
|
||||
this.logger.log('Share data decrypted successfully');
|
||||
|
||||
// 5. Setup message channels
|
||||
const { sender, receiver } = await this.setupMessageChannels(
|
||||
command.sessionId,
|
||||
command.partyId,
|
||||
);
|
||||
|
||||
// 6. Run TSS signing protocol
|
||||
this.logger.log('Starting TSS Signing protocol...');
|
||||
const messageHash = MessageHash.fromHex(command.messageHash);
|
||||
const signingResult = await this.tssProtocol.runSigning(
|
||||
command.partyId,
|
||||
this.convertParticipants(sessionInfo.participants),
|
||||
rawShareData,
|
||||
messageHash,
|
||||
Threshold.create(sessionInfo.thresholdN, sessionInfo.thresholdT),
|
||||
{
|
||||
curve: KeyCurve.SECP256K1,
|
||||
timeout: this.configService.get<number>('MPC_SIGNING_TIMEOUT', 180000),
|
||||
},
|
||||
sender,
|
||||
receiver,
|
||||
);
|
||||
this.logger.log('TSS Signing protocol completed successfully');
|
||||
|
||||
// 7. Update share usage
|
||||
partyShare.markAsUsed(command.messageHash);
|
||||
await this.partyShareRepo.update(partyShare);
|
||||
|
||||
// 8. Report completion to coordinator
|
||||
await this.coordinatorClient.reportCompletion({
|
||||
sessionId: command.sessionId,
|
||||
partyId: command.partyId,
|
||||
signature: signingResult.signature,
|
||||
});
|
||||
|
||||
// 9. Update session state
|
||||
sessionState.completeSigning(Signature.fromHex(signingResult.signature));
|
||||
await this.sessionStateRepo.update(sessionState);
|
||||
|
||||
// 10. Publish domain events
|
||||
await this.eventPublisher.publishAll(partyShare.domainEvents);
|
||||
await this.eventPublisher.publishAll(sessionState.domainEvents);
|
||||
partyShare.clearDomainEvents();
|
||||
sessionState.clearDomainEvents();
|
||||
|
||||
this.logger.log(`Signing completed successfully. Signature: ${signingResult.signature.substring(0, 20)}...`);
|
||||
|
||||
return {
|
||||
signature: signingResult.signature,
|
||||
r: signingResult.r,
|
||||
s: signingResult.s,
|
||||
v: signingResult.v,
|
||||
messageHash: messageHash.toHex(),
|
||||
publicKey: partyShare.publicKey.toHex(),
|
||||
sessionId: command.sessionId,
|
||||
partyId: command.partyId,
|
||||
};
|
||||
} catch (error) {
|
||||
// Handle failure
|
||||
this.logger.error(`Signing failed: ${error.message}`, error.stack);
|
||||
|
||||
sessionState.fail(error.message, 'SIGNING_FAILED');
|
||||
await this.sessionStateRepo.update(sessionState);
|
||||
await this.eventPublisher.publishAll(sessionState.domainEvents);
|
||||
sessionState.clearDomainEvents();
|
||||
|
||||
throw new ApplicationError(`Signing failed: ${error.message}`, 'SIGNING_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
private async joinSession(command: ParticipateInSigningCommand): Promise<SessionInfo> {
|
||||
try {
|
||||
// First, create the session via coordinator to get a valid JWT token
|
||||
this.logger.log('Creating MPC signing session via coordinator...');
|
||||
const createResponse = await this.coordinatorClient.createSession({
|
||||
sessionType: 'sign',
|
||||
thresholdN: 3, // Default 2-of-3 MPC
|
||||
thresholdT: 2,
|
||||
createdBy: command.partyId,
|
||||
messageHash: command.messageHash,
|
||||
expiresIn: 300, // 5 minutes for signing
|
||||
});
|
||||
this.logger.log(`Signing session created: ${createResponse.sessionId}, now joining...`);
|
||||
|
||||
// Now join using the valid JWT token from the coordinator
|
||||
const sessionInfo = await this.coordinatorClient.joinSession({
|
||||
sessionId: createResponse.sessionId,
|
||||
partyId: command.partyId,
|
||||
joinToken: createResponse.joinToken,
|
||||
});
|
||||
|
||||
// Return session info with correct IDs and public key from command
|
||||
return {
|
||||
...sessionInfo,
|
||||
sessionId: createResponse.sessionId,
|
||||
joinToken: createResponse.joinToken,
|
||||
publicKey: command.publicKey, // Preserve public key from command
|
||||
messageHash: command.messageHash,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new ApplicationError(
|
||||
`Failed to join signing session: ${error.message}`,
|
||||
'JOIN_SESSION_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPartyShare(
|
||||
command: ParticipateInSigningCommand,
|
||||
sessionInfo: SessionInfo,
|
||||
): Promise<PartyShare> {
|
||||
const partyId = PartyId.create(command.partyId);
|
||||
|
||||
// If public key is provided in command, use it
|
||||
if (command.publicKey) {
|
||||
const publicKey = PublicKey.fromHex(command.publicKey);
|
||||
const share = await this.partyShareRepo.findByPartyIdAndPublicKey(partyId, publicKey);
|
||||
if (!share) {
|
||||
throw new ApplicationError(
|
||||
'Share not found for specified public key',
|
||||
'SHARE_NOT_FOUND',
|
||||
);
|
||||
}
|
||||
return share;
|
||||
}
|
||||
|
||||
// Otherwise, get public key from session info
|
||||
if (!sessionInfo.publicKey) {
|
||||
throw new ApplicationError(
|
||||
'Public key not provided in command or session info',
|
||||
'PUBLIC_KEY_MISSING',
|
||||
);
|
||||
}
|
||||
|
||||
const publicKey = PublicKey.fromHex(sessionInfo.publicKey);
|
||||
const share = await this.partyShareRepo.findByPartyIdAndPublicKey(partyId, publicKey);
|
||||
|
||||
if (!share) {
|
||||
throw new ApplicationError(
|
||||
'Share not found for this party and public key',
|
||||
'SHARE_NOT_FOUND',
|
||||
);
|
||||
}
|
||||
|
||||
if (!share.isActive()) {
|
||||
throw new ApplicationError(
|
||||
`Share is not active: ${share.status}`,
|
||||
'SHARE_NOT_ACTIVE',
|
||||
);
|
||||
}
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
private createSessionState(
|
||||
command: ParticipateInSigningCommand,
|
||||
sessionInfo: SessionInfo,
|
||||
partyShare: PartyShare,
|
||||
): SessionState {
|
||||
const participants: Participant[] = sessionInfo.participants.map(p => ({
|
||||
partyId: p.partyId,
|
||||
partyIndex: p.partyIndex,
|
||||
status: p.partyId === command.partyId
|
||||
? ParticipantStatus.JOINED
|
||||
: ParticipantStatus.PENDING,
|
||||
}));
|
||||
|
||||
const myParty = sessionInfo.participants.find(p => p.partyId === command.partyId);
|
||||
if (!myParty) {
|
||||
throw new ApplicationError('Party not found in session participants', 'PARTY_NOT_FOUND');
|
||||
}
|
||||
|
||||
return SessionState.create({
|
||||
sessionId: SessionId.create(command.sessionId),
|
||||
partyId: PartyId.create(command.partyId),
|
||||
partyIndex: myParty.partyIndex,
|
||||
sessionType: SessionType.SIGN,
|
||||
participants,
|
||||
thresholdN: sessionInfo.thresholdN,
|
||||
thresholdT: sessionInfo.thresholdT,
|
||||
publicKey: partyShare.publicKey,
|
||||
messageHash: MessageHash.fromHex(command.messageHash),
|
||||
});
|
||||
}
|
||||
|
||||
private async setupMessageChannels(
|
||||
sessionId: string,
|
||||
partyId: string,
|
||||
): Promise<{ sender: (msg: TSSMessage) => Promise<void>; receiver: AsyncIterable<TSSMessage> }> {
|
||||
const messageStream = await this.messageRouter.subscribeMessages(sessionId, partyId);
|
||||
|
||||
const sender = async (msg: TSSMessage): Promise<void> => {
|
||||
await this.messageRouter.sendMessage({
|
||||
sessionId,
|
||||
fromParty: partyId,
|
||||
toParties: msg.toParties,
|
||||
roundNumber: msg.roundNumber,
|
||||
payload: msg.payload,
|
||||
});
|
||||
};
|
||||
|
||||
const receiver: AsyncIterable<TSSMessage> = {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async (): Promise<IteratorResult<TSSMessage>> => {
|
||||
const message = await messageStream.next();
|
||||
if (message.done) {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
fromParty: message.value.fromParty,
|
||||
toParties: message.value.toParties,
|
||||
roundNumber: message.value.roundNumber,
|
||||
payload: message.value.payload,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return { sender, receiver };
|
||||
}
|
||||
|
||||
private convertParticipants(
|
||||
participants: Array<{ partyId: string; partyIndex: number }>,
|
||||
): TSSParticipant[] {
|
||||
return participants.map(p => ({
|
||||
partyId: p.partyId,
|
||||
partyIndex: p.partyIndex,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getMasterKey(): Promise<Buffer> {
|
||||
const keyHex = this.configService.get<string>('SHARE_MASTER_KEY');
|
||||
if (!keyHex) {
|
||||
throw new ApplicationError(
|
||||
'SHARE_MASTER_KEY not configured',
|
||||
'CONFIG_ERROR',
|
||||
);
|
||||
}
|
||||
return Buffer.from(keyHex, 'hex');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './rotate-share.command';
|
||||
export * from './rotate-share.handler';
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
/**
|
||||
* Rotate Share Command
|
||||
*
|
||||
* Command to participate in a share rotation (key refresh) session.
|
||||
* This updates the party's share while keeping the public key the same.
|
||||
*/
|
||||
|
||||
export class RotateShareCommand {
|
||||
constructor(
|
||||
/**
|
||||
* The MPC session ID for rotation
|
||||
*/
|
||||
public readonly sessionId: string,
|
||||
|
||||
/**
|
||||
* This party's identifier
|
||||
*/
|
||||
public readonly partyId: string,
|
||||
|
||||
/**
|
||||
* Token to authenticate with the session coordinator
|
||||
*/
|
||||
public readonly joinToken: string,
|
||||
|
||||
/**
|
||||
* The public key of the share to rotate
|
||||
*/
|
||||
public readonly publicKey: string,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
/**
|
||||
* Rotate Share Handler
|
||||
*
|
||||
* Handles share rotation (key refresh) for proactive security.
|
||||
* This updates the share data while keeping the public key unchanged.
|
||||
*/
|
||||
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { RotateShareCommand } from './rotate-share.command';
|
||||
import { PartyShare } from '../../../domain/entities/party-share.entity';
|
||||
import { SessionState, Participant } from '../../../domain/entities/session-state.entity';
|
||||
import {
|
||||
SessionId,
|
||||
PartyId,
|
||||
PublicKey,
|
||||
Threshold,
|
||||
} from '../../../domain/value-objects';
|
||||
import { SessionType, ParticipantStatus, KeyCurve } from '../../../domain/enums';
|
||||
import { ShareEncryptionDomainService } from '../../../domain/services/share-encryption.domain-service';
|
||||
import {
|
||||
TSS_PROTOCOL_SERVICE,
|
||||
TSSProtocolDomainService,
|
||||
TSSMessage,
|
||||
TSSParticipant,
|
||||
} from '../../../domain/services/tss-protocol.domain-service';
|
||||
import {
|
||||
PARTY_SHARE_REPOSITORY,
|
||||
PartyShareRepository,
|
||||
} from '../../../domain/repositories/party-share.repository.interface';
|
||||
import {
|
||||
SESSION_STATE_REPOSITORY,
|
||||
SessionStateRepository,
|
||||
} from '../../../domain/repositories/session-state.repository.interface';
|
||||
import { EventPublisherService } from '../../../infrastructure/messaging/kafka/event-publisher.service';
|
||||
import { MPCCoordinatorClient, SessionInfo } from '../../../infrastructure/external/mpc-system/coordinator-client';
|
||||
import { MPCMessageRouterClient } from '../../../infrastructure/external/mpc-system/message-router-client';
|
||||
import { ApplicationError } from '../../../shared/exceptions/domain.exception';
|
||||
|
||||
export interface RotateShareResult {
|
||||
oldShareId: string;
|
||||
newShareId: string;
|
||||
publicKey: string;
|
||||
sessionId: string;
|
||||
partyId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RotateShareHandler {
|
||||
private readonly logger = new Logger(RotateShareHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(PARTY_SHARE_REPOSITORY)
|
||||
private readonly partyShareRepo: PartyShareRepository,
|
||||
@Inject(SESSION_STATE_REPOSITORY)
|
||||
private readonly sessionStateRepo: SessionStateRepository,
|
||||
@Inject(TSS_PROTOCOL_SERVICE)
|
||||
private readonly tssProtocol: TSSProtocolDomainService,
|
||||
private readonly encryptionService: ShareEncryptionDomainService,
|
||||
private readonly coordinatorClient: MPCCoordinatorClient,
|
||||
private readonly messageRouter: MPCMessageRouterClient,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async execute(command: RotateShareCommand): Promise<RotateShareResult> {
|
||||
this.logger.log(`Starting share rotation for party: ${command.partyId}, session: ${command.sessionId}`);
|
||||
|
||||
// 1. Load the existing share
|
||||
const partyId = PartyId.create(command.partyId);
|
||||
const publicKey = PublicKey.fromHex(command.publicKey);
|
||||
const oldShare = await this.partyShareRepo.findByPartyIdAndPublicKey(partyId, publicKey);
|
||||
|
||||
if (!oldShare) {
|
||||
throw new ApplicationError('Share not found', 'SHARE_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (!oldShare.isActive()) {
|
||||
throw new ApplicationError('Share is not active', 'SHARE_NOT_ACTIVE');
|
||||
}
|
||||
|
||||
// 2. Join the rotation session
|
||||
const sessionInfo = await this.joinSession(command);
|
||||
this.logger.log(`Joined rotation session with ${sessionInfo.participants.length} participants`);
|
||||
|
||||
// 3. Create session state
|
||||
const sessionState = this.createSessionState(command, sessionInfo, oldShare);
|
||||
await this.sessionStateRepo.save(sessionState);
|
||||
|
||||
try {
|
||||
// 4. Decrypt old share data
|
||||
const masterKey = await this.getMasterKey();
|
||||
const oldShareData = this.encryptionService.decrypt(
|
||||
oldShare.shareData,
|
||||
masterKey,
|
||||
);
|
||||
|
||||
// 5. Setup message channels
|
||||
const { sender, receiver } = await this.setupMessageChannels(
|
||||
command.sessionId,
|
||||
command.partyId,
|
||||
);
|
||||
|
||||
// 6. Run key refresh protocol
|
||||
this.logger.log('Starting key refresh protocol...');
|
||||
const refreshResult = await this.tssProtocol.runKeyRefresh(
|
||||
command.partyId,
|
||||
this.convertParticipants(sessionInfo.participants),
|
||||
oldShareData,
|
||||
Threshold.create(sessionInfo.thresholdN, sessionInfo.thresholdT),
|
||||
{
|
||||
curve: KeyCurve.SECP256K1,
|
||||
timeout: this.configService.get<number>('MPC_REFRESH_TIMEOUT', 300000),
|
||||
},
|
||||
sender,
|
||||
receiver,
|
||||
);
|
||||
this.logger.log('Key refresh protocol completed');
|
||||
|
||||
// 7. Encrypt new share data
|
||||
const encryptedNewShareData = this.encryptionService.encrypt(
|
||||
refreshResult.newShareData,
|
||||
masterKey,
|
||||
);
|
||||
|
||||
// 8. Create new share through rotation
|
||||
const newShare = oldShare.rotate(
|
||||
encryptedNewShareData,
|
||||
SessionId.create(command.sessionId),
|
||||
);
|
||||
|
||||
// 9. Save both shares (old marked as rotated, new as active)
|
||||
await this.partyShareRepo.update(oldShare);
|
||||
await this.partyShareRepo.save(newShare);
|
||||
|
||||
// 10. Report completion
|
||||
await this.coordinatorClient.reportCompletion({
|
||||
sessionId: command.sessionId,
|
||||
partyId: command.partyId,
|
||||
});
|
||||
|
||||
// 11. Update session state
|
||||
sessionState.completeKeygen(publicKey, newShare.id.value);
|
||||
await this.sessionStateRepo.update(sessionState);
|
||||
|
||||
// 12. Publish events
|
||||
await this.eventPublisher.publishAll(oldShare.domainEvents);
|
||||
await this.eventPublisher.publishAll(newShare.domainEvents);
|
||||
await this.eventPublisher.publishAll(sessionState.domainEvents);
|
||||
oldShare.clearDomainEvents();
|
||||
newShare.clearDomainEvents();
|
||||
sessionState.clearDomainEvents();
|
||||
|
||||
this.logger.log(`Share rotation completed. New share ID: ${newShare.id.value}`);
|
||||
|
||||
return {
|
||||
oldShareId: oldShare.id.value,
|
||||
newShareId: newShare.id.value,
|
||||
publicKey: publicKey.toHex(),
|
||||
sessionId: command.sessionId,
|
||||
partyId: command.partyId,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Share rotation failed: ${error.message}`, error.stack);
|
||||
|
||||
sessionState.fail(error.message, 'ROTATION_FAILED');
|
||||
await this.sessionStateRepo.update(sessionState);
|
||||
await this.eventPublisher.publishAll(sessionState.domainEvents);
|
||||
sessionState.clearDomainEvents();
|
||||
|
||||
throw new ApplicationError(`Share rotation failed: ${error.message}`, 'ROTATION_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
private async joinSession(command: RotateShareCommand): Promise<SessionInfo> {
|
||||
try {
|
||||
// First, create the session via coordinator to get a valid JWT token
|
||||
// Key refresh uses 'keygen' session type (coordinator doesn't have 'refresh' type)
|
||||
this.logger.log('Creating MPC refresh session via coordinator...');
|
||||
const createResponse = await this.coordinatorClient.createSession({
|
||||
sessionType: 'keygen', // Use keygen type for key refresh
|
||||
thresholdN: 3,
|
||||
thresholdT: 2,
|
||||
createdBy: command.partyId,
|
||||
expiresIn: 600, // 10 minutes
|
||||
});
|
||||
this.logger.log(`Refresh session created: ${createResponse.sessionId}, now joining...`);
|
||||
|
||||
// Now join using the valid JWT token from the coordinator
|
||||
const sessionInfo = await this.coordinatorClient.joinSession({
|
||||
sessionId: createResponse.sessionId,
|
||||
partyId: command.partyId,
|
||||
joinToken: createResponse.joinToken,
|
||||
});
|
||||
|
||||
return {
|
||||
...sessionInfo,
|
||||
sessionId: createResponse.sessionId,
|
||||
joinToken: createResponse.joinToken,
|
||||
publicKey: command.publicKey,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new ApplicationError(
|
||||
`Failed to join rotation session: ${error.message}`,
|
||||
'JOIN_SESSION_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private createSessionState(
|
||||
command: RotateShareCommand,
|
||||
sessionInfo: SessionInfo,
|
||||
share: PartyShare,
|
||||
): SessionState {
|
||||
const participants: Participant[] = sessionInfo.participants.map(p => ({
|
||||
partyId: p.partyId,
|
||||
partyIndex: p.partyIndex,
|
||||
status: p.partyId === command.partyId
|
||||
? ParticipantStatus.JOINED
|
||||
: ParticipantStatus.PENDING,
|
||||
}));
|
||||
|
||||
const myParty = sessionInfo.participants.find(p => p.partyId === command.partyId);
|
||||
if (!myParty) {
|
||||
throw new ApplicationError('Party not found in session', 'PARTY_NOT_FOUND');
|
||||
}
|
||||
|
||||
return SessionState.create({
|
||||
sessionId: SessionId.create(command.sessionId),
|
||||
partyId: PartyId.create(command.partyId),
|
||||
partyIndex: myParty.partyIndex,
|
||||
sessionType: SessionType.REFRESH,
|
||||
participants,
|
||||
thresholdN: sessionInfo.thresholdN,
|
||||
thresholdT: sessionInfo.thresholdT,
|
||||
publicKey: share.publicKey,
|
||||
});
|
||||
}
|
||||
|
||||
private async setupMessageChannels(
|
||||
sessionId: string,
|
||||
partyId: string,
|
||||
): Promise<{ sender: (msg: TSSMessage) => Promise<void>; receiver: AsyncIterable<TSSMessage> }> {
|
||||
const messageStream = await this.messageRouter.subscribeMessages(sessionId, partyId);
|
||||
|
||||
const sender = async (msg: TSSMessage): Promise<void> => {
|
||||
await this.messageRouter.sendMessage({
|
||||
sessionId,
|
||||
fromParty: partyId,
|
||||
toParties: msg.toParties,
|
||||
roundNumber: msg.roundNumber,
|
||||
payload: msg.payload,
|
||||
});
|
||||
};
|
||||
|
||||
const receiver: AsyncIterable<TSSMessage> = {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async (): Promise<IteratorResult<TSSMessage>> => {
|
||||
const message = await messageStream.next();
|
||||
if (message.done) {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
fromParty: message.value.fromParty,
|
||||
toParties: message.value.toParties,
|
||||
roundNumber: message.value.roundNumber,
|
||||
payload: message.value.payload,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return { sender, receiver };
|
||||
}
|
||||
|
||||
private convertParticipants(
|
||||
participants: Array<{ partyId: string; partyIndex: number }>,
|
||||
): TSSParticipant[] {
|
||||
return participants.map(p => ({
|
||||
partyId: p.partyId,
|
||||
partyIndex: p.partyIndex,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getMasterKey(): Promise<Buffer> {
|
||||
const keyHex = this.configService.get<string>('SHARE_MASTER_KEY');
|
||||
if (!keyHex) {
|
||||
throw new ApplicationError('SHARE_MASTER_KEY not configured', 'CONFIG_ERROR');
|
||||
}
|
||||
return Buffer.from(keyHex, 'hex');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
/**
|
||||
* Get Share Info Handler
|
||||
*
|
||||
* Handles the GetShareInfoQuery.
|
||||
*/
|
||||
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { GetShareInfoQuery } from './get-share-info.query';
|
||||
import { ShareId } from '../../../domain/value-objects';
|
||||
import {
|
||||
PARTY_SHARE_REPOSITORY,
|
||||
PartyShareRepository,
|
||||
} from '../../../domain/repositories/party-share.repository.interface';
|
||||
import { ApplicationError } from '../../../shared/exceptions/domain.exception';
|
||||
|
||||
export interface ShareInfoDto {
|
||||
id: string;
|
||||
partyId: string;
|
||||
sessionId: string;
|
||||
shareType: string;
|
||||
publicKey: string;
|
||||
threshold: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GetShareInfoHandler {
|
||||
private readonly logger = new Logger(GetShareInfoHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(PARTY_SHARE_REPOSITORY)
|
||||
private readonly partyShareRepo: PartyShareRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetShareInfoQuery): Promise<ShareInfoDto> {
|
||||
this.logger.log(`Getting share info for: ${query.shareId}`);
|
||||
|
||||
const shareId = ShareId.create(query.shareId);
|
||||
const share = await this.partyShareRepo.findById(shareId);
|
||||
|
||||
if (!share) {
|
||||
throw new ApplicationError('Share not found', 'SHARE_NOT_FOUND');
|
||||
}
|
||||
|
||||
return {
|
||||
id: share.id.value,
|
||||
partyId: share.partyId.value,
|
||||
sessionId: share.sessionId.value,
|
||||
shareType: share.shareType,
|
||||
publicKey: share.publicKey.toHex(),
|
||||
threshold: share.threshold.toString(),
|
||||
status: share.status,
|
||||
createdAt: share.createdAt.toISOString(),
|
||||
updatedAt: share.updatedAt.toISOString(),
|
||||
lastUsedAt: share.lastUsedAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
/**
|
||||
* Get Share Info Query
|
||||
*
|
||||
* Query to retrieve information about a specific share.
|
||||
*/
|
||||
|
||||
export class GetShareInfoQuery {
|
||||
constructor(
|
||||
/**
|
||||
* The share ID to look up
|
||||
*/
|
||||
public readonly shareId: string,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './get-share-info.query';
|
||||
export * from './get-share-info.handler';
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
/**
|
||||
* Queries Index
|
||||
*/
|
||||
|
||||
export * from './get-share-info';
|
||||
export * from './list-shares';
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './list-shares.query';
|
||||
export * from './list-shares.handler';
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
/**
|
||||
* List Shares Handler
|
||||
*
|
||||
* Handles the ListSharesQuery.
|
||||
*/
|
||||
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { ListSharesQuery } from './list-shares.query';
|
||||
import {
|
||||
PARTY_SHARE_REPOSITORY,
|
||||
PartyShareRepository,
|
||||
PartyShareFilters,
|
||||
} from '../../../domain/repositories/party-share.repository.interface';
|
||||
import { ShareInfoDto } from '../get-share-info/get-share-info.handler';
|
||||
|
||||
export interface ListSharesResult {
|
||||
items: ShareInfoDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ListSharesHandler {
|
||||
private readonly logger = new Logger(ListSharesHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(PARTY_SHARE_REPOSITORY)
|
||||
private readonly partyShareRepo: PartyShareRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: ListSharesQuery): Promise<ListSharesResult> {
|
||||
this.logger.log(`Listing shares with filters: ${JSON.stringify(query)}`);
|
||||
|
||||
const filters: PartyShareFilters = {
|
||||
partyId: query.partyId,
|
||||
status: query.status,
|
||||
shareType: query.shareType,
|
||||
publicKey: query.publicKey,
|
||||
};
|
||||
|
||||
const pagination = {
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
};
|
||||
|
||||
const [shares, total] = await Promise.all([
|
||||
this.partyShareRepo.findMany(filters, pagination),
|
||||
this.partyShareRepo.count(filters),
|
||||
]);
|
||||
|
||||
const items: ShareInfoDto[] = shares.map(share => ({
|
||||
id: share.id.value,
|
||||
partyId: share.partyId.value,
|
||||
sessionId: share.sessionId.value,
|
||||
shareType: share.shareType,
|
||||
publicKey: share.publicKey.toHex(),
|
||||
threshold: share.threshold.toString(),
|
||||
status: share.status,
|
||||
createdAt: share.createdAt.toISOString(),
|
||||
updatedAt: share.updatedAt.toISOString(),
|
||||
lastUsedAt: share.lastUsedAt?.toISOString(),
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
totalPages: Math.ceil(total / query.limit),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
/**
|
||||
* List Shares Query
|
||||
*
|
||||
* Query to list shares with filters and pagination.
|
||||
*/
|
||||
|
||||
import { PartyShareStatus, PartyShareType } from '../../../domain/enums';
|
||||
|
||||
export class ListSharesQuery {
|
||||
constructor(
|
||||
/**
|
||||
* Filter by party ID
|
||||
*/
|
||||
public readonly partyId?: string,
|
||||
|
||||
/**
|
||||
* Filter by status
|
||||
*/
|
||||
public readonly status?: PartyShareStatus,
|
||||
|
||||
/**
|
||||
* Filter by share type
|
||||
*/
|
||||
public readonly shareType?: PartyShareType,
|
||||
|
||||
/**
|
||||
* Filter by public key
|
||||
*/
|
||||
public readonly publicKey?: string,
|
||||
|
||||
/**
|
||||
* Page number (1-based)
|
||||
*/
|
||||
public readonly page: number = 1,
|
||||
|
||||
/**
|
||||
* Items per page
|
||||
*/
|
||||
public readonly limit: number = 20,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
export * from './mpc-party-application.service';
|
||||
export * from './mpc-coordinator.service';
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
/**
|
||||
* MPC Coordinator Service
|
||||
*
|
||||
* 协调 mpc-system (Go) 完成 MPC 操作。
|
||||
* 存储公钥和 share(分片)到本地数据库。
|
||||
* 作为 MPC 服务网关,转发请求到 mpc-system (Go)。
|
||||
* 只缓存 username + publicKey 的映射关系。
|
||||
*
|
||||
* 调用路径 (DDD 分领域):
|
||||
* identity-service (身份域) → mpc-service (MPC域) → mpc-system (Go/TSS实现)
|
||||
* 调用路径:
|
||||
* other-services → mpc-service → mpc-system (Go/TSS实现)
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { MpcWallet } from '../../domain/entities/mpc-wallet.entity';
|
||||
import { MpcShare } from '../../domain/entities/mpc-share.entity';
|
||||
import { MpcSession } from '../../domain/entities/mpc-session.entity';
|
||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||
|
||||
// ============================================================================
|
||||
// Input/Output Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CreateKeygenInput {
|
||||
username: string;
|
||||
|
|
@ -27,19 +27,27 @@ export interface CreateKeygenInput {
|
|||
|
||||
export interface CreateKeygenOutput {
|
||||
sessionId: string;
|
||||
username: string;
|
||||
thresholdN: number;
|
||||
thresholdT: number;
|
||||
selectedParties: string[];
|
||||
delegateParty?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface KeygenStatusOutput {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
sessionType: string;
|
||||
completedParties: number;
|
||||
totalParties: number;
|
||||
publicKey?: string;
|
||||
hasDelegate?: boolean;
|
||||
delegateShare?: {
|
||||
partyId: string;
|
||||
partyIndex: number;
|
||||
encryptedShare: string;
|
||||
};
|
||||
serverParties?: string[];
|
||||
}
|
||||
|
||||
export interface CreateSigningInput {
|
||||
|
|
@ -50,414 +58,552 @@ export interface CreateSigningInput {
|
|||
|
||||
export interface CreateSigningOutput {
|
||||
sessionId: string;
|
||||
username: string;
|
||||
messageHash: string;
|
||||
thresholdT: number;
|
||||
selectedParties: string[];
|
||||
hasDelegate: boolean;
|
||||
delegatePartyId?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface SigningStatusOutput {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
sessionType: string;
|
||||
completedParties: number;
|
||||
totalParties: number;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface WalletOutput {
|
||||
username: string;
|
||||
publicKey: string;
|
||||
keygenSessionId: string;
|
||||
}
|
||||
|
||||
export interface SigningPartiesInput {
|
||||
partyIds: string[];
|
||||
}
|
||||
|
||||
export interface SigningPartiesOutput {
|
||||
username: string;
|
||||
configured: boolean;
|
||||
signingParties?: string[];
|
||||
activeParties?: string[];
|
||||
thresholdT?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DelegateShareInput {
|
||||
username: string;
|
||||
partyId: string;
|
||||
partyIndex: number;
|
||||
encryptedShare: string;
|
||||
}
|
||||
|
||||
export interface DelegateShareOutput {
|
||||
username: string;
|
||||
partyId: string;
|
||||
partyIndex: number;
|
||||
encryptedShare: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Service
|
||||
// ============================================================================
|
||||
|
||||
@Injectable()
|
||||
export class MPCCoordinatorService {
|
||||
private readonly logger = new Logger(MPCCoordinatorService.name);
|
||||
private readonly mpcSystemUrl: string;
|
||||
private readonly mpcApiKey: string;
|
||||
private readonly pollIntervalMs = 2000;
|
||||
private readonly maxPollAttempts = 150;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly httpService: HttpService,
|
||||
@InjectRepository(MpcWallet)
|
||||
private readonly walletRepository: Repository<MpcWallet>,
|
||||
@InjectRepository(MpcShare)
|
||||
private readonly shareRepository: Repository<MpcShare>,
|
||||
@InjectRepository(MpcSession)
|
||||
private readonly sessionRepository: Repository<MpcSession>,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
this.mpcSystemUrl = this.configService.get<string>('MPC_SYSTEM_URL', 'http://localhost:4000');
|
||||
this.mpcApiKey = this.configService.get<string>('MPC_API_KEY', 'test-api-key');
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Keygen APIs
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 创建 keygen 会话
|
||||
* 调用 mpc-system 的 /api/v1/mpc/keygen API
|
||||
* 创建 keygen 会话 - 转发到 mpc-system
|
||||
*/
|
||||
async createKeygenSession(input: CreateKeygenInput): Promise<CreateKeygenOutput> {
|
||||
this.logger.log(`Creating keygen session: username=${input.username}`);
|
||||
|
||||
try {
|
||||
// 调用 mpc-system 创建 keygen session
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post<{
|
||||
session_id: string;
|
||||
session_type: string;
|
||||
username: string;
|
||||
threshold_n: number;
|
||||
threshold_t: number;
|
||||
selected_parties: string[];
|
||||
delegate_party: string;
|
||||
status: string;
|
||||
}>(
|
||||
`${this.mpcSystemUrl}/api/v1/mpc/keygen`,
|
||||
{
|
||||
username: input.username,
|
||||
threshold_n: input.thresholdN,
|
||||
threshold_t: input.thresholdT,
|
||||
require_delegate: input.requireDelegate,
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post<{
|
||||
session_id: string;
|
||||
session_type: string;
|
||||
username: string;
|
||||
threshold_n: number;
|
||||
threshold_t: number;
|
||||
selected_parties: string[];
|
||||
delegate_party: string;
|
||||
status: string;
|
||||
}>(
|
||||
`${this.mpcSystemUrl}/api/v1/mpc/keygen`,
|
||||
{
|
||||
username: input.username,
|
||||
threshold_n: input.thresholdN,
|
||||
threshold_t: input.thresholdT,
|
||||
require_delegate: input.requireDelegate,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.mpcApiKey,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.mpcApiKey,
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const sessionId = response.data.session_id;
|
||||
|
||||
// 保存 session 到本地数据库
|
||||
const session = this.sessionRepository.create({
|
||||
sessionId,
|
||||
sessionType: 'keygen',
|
||||
username: input.username,
|
||||
thresholdN: input.thresholdN,
|
||||
thresholdT: input.thresholdT,
|
||||
selectedParties: response.data.selected_parties,
|
||||
delegateParty: response.data.delegate_party,
|
||||
status: 'created',
|
||||
});
|
||||
await this.sessionRepository.save(session);
|
||||
|
||||
this.logger.log(`Keygen session created: ${sessionId}`);
|
||||
|
||||
// 启动后台轮询任务
|
||||
this.pollKeygenCompletion(sessionId, input.username).catch(err => {
|
||||
this.logger.error(`Keygen polling failed: ${err.message}`);
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
status: 'created',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create keygen session: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台轮询 keygen 完成状态
|
||||
*/
|
||||
private async pollKeygenCompletion(sessionId: string, username: string): Promise<void> {
|
||||
for (let i = 0; i < this.maxPollAttempts; i++) {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<{
|
||||
session_id: string;
|
||||
status: string;
|
||||
session_type: string;
|
||||
completed_parties: number;
|
||||
total_parties: number;
|
||||
public_key?: string;
|
||||
has_delegate?: boolean;
|
||||
delegate_share?: {
|
||||
encrypted_share: string;
|
||||
party_index: number;
|
||||
party_id: string;
|
||||
};
|
||||
}>(
|
||||
`${this.mpcSystemUrl}/api/v1/mpc/sessions/${sessionId}`,
|
||||
{
|
||||
headers: {
|
||||
'X-API-Key': this.mpcApiKey,
|
||||
},
|
||||
timeout: 10000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
|
||||
if (data.status === 'completed') {
|
||||
// 更新 session 状态
|
||||
await this.sessionRepository.update(
|
||||
{ sessionId },
|
||||
{
|
||||
status: 'completed',
|
||||
publicKey: data.public_key,
|
||||
},
|
||||
);
|
||||
|
||||
// 保存钱包信息
|
||||
const wallet = this.walletRepository.create({
|
||||
username,
|
||||
publicKey: data.public_key,
|
||||
keygenSessionId: sessionId,
|
||||
});
|
||||
await this.walletRepository.save(wallet);
|
||||
|
||||
// 保存 delegate share
|
||||
if (data.delegate_share) {
|
||||
const share = this.shareRepository.create({
|
||||
sessionId,
|
||||
username,
|
||||
partyId: data.delegate_share.party_id,
|
||||
partyIndex: data.delegate_share.party_index,
|
||||
encryptedShare: data.delegate_share.encrypted_share,
|
||||
shareType: 'delegate',
|
||||
});
|
||||
await this.shareRepository.save(share);
|
||||
}
|
||||
|
||||
this.logger.log(`Keygen completed: sessionId=${sessionId}, publicKey=${data.public_key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'failed' || data.status === 'expired') {
|
||||
await this.sessionRepository.update(
|
||||
{ sessionId },
|
||||
{ status: data.status },
|
||||
);
|
||||
this.logger.error(`Keygen failed: sessionId=${sessionId}, status=${data.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sleep(this.pollIntervalMs);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error polling keygen status: ${error.message}`);
|
||||
await this.sleep(this.pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
// 超时
|
||||
await this.sessionRepository.update(
|
||||
{ sessionId },
|
||||
{ status: 'expired' },
|
||||
timeout: 30000,
|
||||
},
|
||||
),
|
||||
);
|
||||
this.logger.error(`Keygen timed out: sessionId=${sessionId}`);
|
||||
|
||||
const data = response.data;
|
||||
this.logger.log(`Keygen session created: ${data.session_id}`);
|
||||
|
||||
return {
|
||||
sessionId: data.session_id,
|
||||
username: input.username,
|
||||
thresholdN: data.threshold_n,
|
||||
thresholdT: data.threshold_t,
|
||||
selectedParties: data.selected_parties,
|
||||
delegateParty: data.delegate_party,
|
||||
status: data.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 keygen 会话状态
|
||||
* 获取 keygen 会话状态 - 转发到 mpc-system
|
||||
* 如果完成,缓存公钥到本地
|
||||
*/
|
||||
async getKeygenStatus(sessionId: string): Promise<KeygenStatusOutput> {
|
||||
const session = await this.sessionRepository.findOne({
|
||||
where: { sessionId },
|
||||
});
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<{
|
||||
session_id: string;
|
||||
status: string;
|
||||
session_type: string;
|
||||
completed_parties: number;
|
||||
total_parties: number;
|
||||
public_key?: string;
|
||||
has_delegate?: boolean;
|
||||
delegate_share?: {
|
||||
encrypted_share: string;
|
||||
party_index: number;
|
||||
party_id: string;
|
||||
};
|
||||
}>(
|
||||
`${this.mpcSystemUrl}/api/v1/mpc/sessions/${sessionId}`,
|
||||
{
|
||||
headers: {
|
||||
'X-API-Key': this.mpcApiKey,
|
||||
},
|
||||
timeout: 10000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
sessionId,
|
||||
status: 'not_found',
|
||||
};
|
||||
const data = response.data;
|
||||
|
||||
// 如果完成且有公钥,缓存到本地
|
||||
if (data.status === 'completed' && data.public_key) {
|
||||
await this.cachePublicKey(sessionId, data.public_key);
|
||||
}
|
||||
|
||||
const result: KeygenStatusOutput = {
|
||||
sessionId,
|
||||
status: session.status,
|
||||
serverParties: session.selectedParties?.filter(p => p !== session.delegateParty),
|
||||
sessionId: data.session_id,
|
||||
status: data.status,
|
||||
sessionType: data.session_type,
|
||||
completedParties: data.completed_parties,
|
||||
totalParties: data.total_parties,
|
||||
publicKey: data.public_key,
|
||||
hasDelegate: data.has_delegate,
|
||||
};
|
||||
|
||||
if (session.status === 'completed') {
|
||||
result.publicKey = session.publicKey;
|
||||
|
||||
// 获取 delegate share
|
||||
const share = await this.shareRepository.findOne({
|
||||
where: { sessionId, shareType: 'delegate' },
|
||||
});
|
||||
|
||||
if (share) {
|
||||
result.delegateShare = {
|
||||
partyId: share.partyId,
|
||||
partyIndex: share.partyIndex,
|
||||
encryptedShare: share.encryptedShare,
|
||||
};
|
||||
}
|
||||
if (data.delegate_share) {
|
||||
result.delegateShare = {
|
||||
partyId: data.delegate_share.party_id,
|
||||
partyIndex: data.delegate_share.party_index,
|
||||
encryptedShare: data.delegate_share.encrypted_share,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Signing APIs
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 创建签名会话
|
||||
* 创建签名会话 - 转发到 mpc-system
|
||||
*/
|
||||
async createSigningSession(input: CreateSigningInput): Promise<CreateSigningOutput> {
|
||||
this.logger.log(`Creating signing session: username=${input.username}`);
|
||||
|
||||
try {
|
||||
// 调用 mpc-system 创建签名 session
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post<{
|
||||
session_id: string;
|
||||
session_type: string;
|
||||
username: string;
|
||||
message_hash: string;
|
||||
threshold_t: number;
|
||||
selected_parties: string[];
|
||||
has_delegate: boolean;
|
||||
status: string;
|
||||
}>(
|
||||
`${this.mpcSystemUrl}/api/v1/mpc/sign`,
|
||||
{
|
||||
username: input.username,
|
||||
message_hash: input.messageHash,
|
||||
user_share: input.userShare,
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post<{
|
||||
session_id: string;
|
||||
session_type: string;
|
||||
username: string;
|
||||
message_hash: string;
|
||||
threshold_t: number;
|
||||
selected_parties: string[];
|
||||
has_delegate: boolean;
|
||||
delegate_party_id?: string;
|
||||
status: string;
|
||||
}>(
|
||||
`${this.mpcSystemUrl}/api/v1/mpc/sign`,
|
||||
{
|
||||
username: input.username,
|
||||
message_hash: input.messageHash,
|
||||
user_share: input.userShare,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.mpcApiKey,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.mpcApiKey,
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const sessionId = response.data.session_id;
|
||||
|
||||
// 保存 session 到本地数据库
|
||||
const session = this.sessionRepository.create({
|
||||
sessionId,
|
||||
sessionType: 'sign',
|
||||
username: input.username,
|
||||
messageHash: input.messageHash,
|
||||
selectedParties: response.data.selected_parties,
|
||||
status: 'created',
|
||||
});
|
||||
await this.sessionRepository.save(session);
|
||||
|
||||
this.logger.log(`Signing session created: ${sessionId}`);
|
||||
|
||||
// 启动后台轮询任务
|
||||
this.pollSigningCompletion(sessionId).catch(err => {
|
||||
this.logger.error(`Signing polling failed: ${err.message}`);
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
status: 'created',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create signing session: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台轮询签名完成状态
|
||||
*/
|
||||
private async pollSigningCompletion(sessionId: string): Promise<void> {
|
||||
for (let i = 0; i < this.maxPollAttempts; i++) {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<{
|
||||
session_id: string;
|
||||
status: string;
|
||||
session_type: string;
|
||||
completed_parties: number;
|
||||
total_parties: number;
|
||||
signature?: string;
|
||||
}>(
|
||||
`${this.mpcSystemUrl}/api/v1/mpc/sessions/${sessionId}`,
|
||||
{
|
||||
headers: {
|
||||
'X-API-Key': this.mpcApiKey,
|
||||
},
|
||||
timeout: 10000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
|
||||
if (data.status === 'completed') {
|
||||
await this.sessionRepository.update(
|
||||
{ sessionId },
|
||||
{
|
||||
status: 'completed',
|
||||
signature: data.signature,
|
||||
},
|
||||
);
|
||||
this.logger.log(`Signing completed: sessionId=${sessionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'failed' || data.status === 'expired') {
|
||||
await this.sessionRepository.update(
|
||||
{ sessionId },
|
||||
{ status: data.status },
|
||||
);
|
||||
this.logger.error(`Signing failed: sessionId=${sessionId}, status=${data.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sleep(this.pollIntervalMs);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error polling signing status: ${error.message}`);
|
||||
await this.sleep(this.pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
await this.sessionRepository.update(
|
||||
{ sessionId },
|
||||
{ status: 'expired' },
|
||||
timeout: 30000,
|
||||
},
|
||||
),
|
||||
);
|
||||
this.logger.error(`Signing timed out: sessionId=${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签名会话状态
|
||||
*/
|
||||
async getSigningStatus(sessionId: string): Promise<SigningStatusOutput> {
|
||||
const session = await this.sessionRepository.findOne({
|
||||
where: { sessionId },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
sessionId,
|
||||
status: 'not_found',
|
||||
};
|
||||
}
|
||||
const data = response.data;
|
||||
this.logger.log(`Signing session created: ${data.session_id}`);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
status: session.status,
|
||||
signature: session.signature,
|
||||
sessionId: data.session_id,
|
||||
username: input.username,
|
||||
messageHash: data.message_hash,
|
||||
thresholdT: data.threshold_t,
|
||||
selectedParties: data.selected_parties,
|
||||
hasDelegate: data.has_delegate,
|
||||
delegatePartyId: data.delegate_party_id,
|
||||
status: data.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户名获取钱包信息
|
||||
* 获取签名会话状态 - 转发到 mpc-system
|
||||
*/
|
||||
async getWalletByUsername(username: string): Promise<WalletOutput> {
|
||||
const wallet = await this.walletRepository.findOne({
|
||||
async getSigningStatus(sessionId: string): Promise<SigningStatusOutput> {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<{
|
||||
session_id: string;
|
||||
status: string;
|
||||
session_type: string;
|
||||
completed_parties: number;
|
||||
total_parties: number;
|
||||
signature?: string;
|
||||
}>(
|
||||
`${this.mpcSystemUrl}/api/v1/mpc/sessions/${sessionId}`,
|
||||
{
|
||||
headers: {
|
||||
'X-API-Key': this.mpcApiKey,
|
||||
},
|
||||
timeout: 10000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
|
||||
return {
|
||||
sessionId: data.session_id,
|
||||
status: data.status,
|
||||
sessionType: data.session_type,
|
||||
completedParties: data.completed_parties,
|
||||
totalParties: data.total_parties,
|
||||
signature: data.signature,
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Wallet APIs
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 根据用户名获取公钥 - 先查本地缓存,没有则转发到 mpc-system
|
||||
*/
|
||||
async getPublicKeyByUsername(username: string): Promise<WalletOutput> {
|
||||
// 先查本地缓存
|
||||
const cached = await this.prisma.mpcWallet.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
if (!wallet) {
|
||||
throw new Error(`Wallet not found for username: ${username}`);
|
||||
if (cached) {
|
||||
return {
|
||||
username: cached.username,
|
||||
publicKey: cached.publicKey,
|
||||
};
|
||||
}
|
||||
|
||||
// 本地没有,转发到 mpc-system
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<{
|
||||
account: {
|
||||
username: string;
|
||||
public_key: string;
|
||||
};
|
||||
}>(
|
||||
`${this.mpcSystemUrl}/api/v1/accounts`,
|
||||
{
|
||||
params: { username },
|
||||
headers: {
|
||||
'X-API-Key': this.mpcApiKey,
|
||||
},
|
||||
timeout: 10000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const publicKey = response.data.account.public_key;
|
||||
|
||||
// 缓存到本地
|
||||
await this.prisma.mpcWallet.upsert({
|
||||
where: { username },
|
||||
update: { publicKey },
|
||||
create: { username, publicKey },
|
||||
});
|
||||
|
||||
return {
|
||||
username: wallet.username,
|
||||
publicKey: wallet.publicKey,
|
||||
keygenSessionId: wallet.keygenSessionId,
|
||||
username,
|
||||
publicKey,
|
||||
};
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
// ==========================================================================
|
||||
// Signing Parties Configuration APIs
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 设定签名 party - 转发到 mpc-system
|
||||
*/
|
||||
async setSigningParties(username: string, input: SigningPartiesInput): Promise<SigningPartiesOutput> {
|
||||
this.logger.log(`Setting signing parties: username=${username}, parties=${input.partyIds.join(',')}`);
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post<{
|
||||
message: string;
|
||||
username: string;
|
||||
signing_parties: string[];
|
||||
threshold_t: number;
|
||||
}>(
|
||||
`${this.mpcSystemUrl}/api/v1/accounts/by-username/${username}/signing-config`,
|
||||
{
|
||||
party_ids: input.partyIds,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.mpcApiKey,
|
||||
},
|
||||
timeout: 10000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
username: response.data.username,
|
||||
configured: true,
|
||||
signingParties: response.data.signing_parties,
|
||||
thresholdT: response.data.threshold_t,
|
||||
message: response.data.message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新签名 party - 转发到 mpc-system
|
||||
*/
|
||||
async updateSigningParties(username: string, input: SigningPartiesInput): Promise<SigningPartiesOutput> {
|
||||
this.logger.log(`Updating signing parties: username=${username}, parties=${input.partyIds.join(',')}`);
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.put<{
|
||||
message: string;
|
||||
username: string;
|
||||
signing_parties: string[];
|
||||
threshold_t: number;
|
||||
}>(
|
||||
`${this.mpcSystemUrl}/api/v1/accounts/by-username/${username}/signing-config`,
|
||||
{
|
||||
party_ids: input.partyIds,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.mpcApiKey,
|
||||
},
|
||||
timeout: 10000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
username: response.data.username,
|
||||
configured: true,
|
||||
signingParties: response.data.signing_parties,
|
||||
thresholdT: response.data.threshold_t,
|
||||
message: response.data.message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询签名 party - 转发到 mpc-system
|
||||
*/
|
||||
async getSigningParties(username: string): Promise<SigningPartiesOutput> {
|
||||
this.logger.debug(`Getting signing parties: username=${username}`);
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<{
|
||||
configured: boolean;
|
||||
username: string;
|
||||
signing_parties?: string[];
|
||||
active_parties?: string[];
|
||||
threshold_t: number;
|
||||
message?: string;
|
||||
}>(
|
||||
`${this.mpcSystemUrl}/api/v1/accounts/by-username/${username}/signing-config`,
|
||||
{
|
||||
headers: {
|
||||
'X-API-Key': this.mpcApiKey,
|
||||
},
|
||||
timeout: 10000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
username: response.data.username,
|
||||
configured: response.data.configured,
|
||||
signingParties: response.data.signing_parties,
|
||||
activeParties: response.data.active_parties,
|
||||
thresholdT: response.data.threshold_t,
|
||||
message: response.data.message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除签名 party 配置 - 转发到 mpc-system
|
||||
*/
|
||||
async clearSigningParties(username: string): Promise<SigningPartiesOutput> {
|
||||
this.logger.log(`Clearing signing parties: username=${username}`);
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.delete<{
|
||||
message: string;
|
||||
username: string;
|
||||
}>(
|
||||
`${this.mpcSystemUrl}/api/v1/accounts/by-username/${username}/signing-config`,
|
||||
{
|
||||
headers: {
|
||||
'X-API-Key': this.mpcApiKey,
|
||||
},
|
||||
timeout: 10000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
username: response.data.username,
|
||||
configured: false,
|
||||
message: response.data.message,
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Internal Helpers
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 缓存公钥到本地数据库
|
||||
*/
|
||||
private async cachePublicKey(sessionId: string, publicKey: string): Promise<void> {
|
||||
try {
|
||||
// 从 mpc-system 获取 username (通过 session)
|
||||
// 这里简化处理,实际可能需要额外 API 调用
|
||||
// 暂时用 sessionId 的前缀作为 username 占位
|
||||
this.logger.debug(`Caching public key for session: ${sessionId}`);
|
||||
|
||||
// TODO: 获取真实的 username 并缓存
|
||||
// 目前 keygen 完成后由调用方传入 username
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to cache public key: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存公钥缓存(供 controller 调用)
|
||||
*/
|
||||
async savePublicKeyCache(username: string, publicKey: string): Promise<void> {
|
||||
await this.prisma.mpcWallet.upsert({
|
||||
where: { username },
|
||||
update: { publicKey },
|
||||
create: { username, publicKey },
|
||||
});
|
||||
this.logger.log(`Public key cached: username=${username}`);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Delegate Share APIs
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 保存 delegate share(keygen 完成后由 controller 调用)
|
||||
* delegate share 由 mpc-system 的 delegate server-party-api 返回
|
||||
*/
|
||||
async saveDelegateShare(input: DelegateShareInput): Promise<void> {
|
||||
await this.prisma.mpcShare.upsert({
|
||||
where: { username: input.username },
|
||||
update: {
|
||||
partyId: input.partyId,
|
||||
partyIndex: input.partyIndex,
|
||||
encryptedShare: input.encryptedShare,
|
||||
},
|
||||
create: {
|
||||
username: input.username,
|
||||
partyId: input.partyId,
|
||||
partyIndex: input.partyIndex,
|
||||
encryptedShare: input.encryptedShare,
|
||||
},
|
||||
});
|
||||
this.logger.log(`Delegate share saved: username=${input.username}, partyId=${input.partyId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 delegate share(签名时使用)
|
||||
*/
|
||||
async getDelegateShare(username: string): Promise<DelegateShareOutput | null> {
|
||||
const share = await this.prisma.mpcShare.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
if (!share) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
username: share.username,
|
||||
partyId: share.partyId,
|
||||
partyIndex: share.partyIndex,
|
||||
encryptedShare: share.encryptedShare,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 delegate share
|
||||
*/
|
||||
async deleteDelegateShare(username: string): Promise<void> {
|
||||
await this.prisma.mpcShare.delete({
|
||||
where: { username },
|
||||
});
|
||||
this.logger.log(`Delegate share deleted: username=${username}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,172 +0,0 @@
|
|||
/**
|
||||
* MPC Party Application Service
|
||||
*
|
||||
* Facade service that orchestrates commands and queries.
|
||||
* This is the main entry point for business operations.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
ParticipateInKeygenCommand,
|
||||
ParticipateInKeygenHandler,
|
||||
KeygenResult,
|
||||
} from '../commands/participate-keygen';
|
||||
import {
|
||||
ParticipateInSigningCommand,
|
||||
ParticipateInSigningHandler,
|
||||
SigningResult,
|
||||
} from '../commands/participate-signing';
|
||||
import {
|
||||
RotateShareCommand,
|
||||
RotateShareHandler,
|
||||
RotateShareResult,
|
||||
} from '../commands/rotate-share';
|
||||
import {
|
||||
GetShareInfoQuery,
|
||||
GetShareInfoHandler,
|
||||
ShareInfoDto,
|
||||
} from '../queries/get-share-info';
|
||||
import {
|
||||
ListSharesQuery,
|
||||
ListSharesHandler,
|
||||
ListSharesResult,
|
||||
} from '../queries/list-shares';
|
||||
import { PartyShareType, PartyShareStatus } from '../../domain/enums';
|
||||
|
||||
@Injectable()
|
||||
export class MPCPartyApplicationService {
|
||||
private readonly logger = new Logger(MPCPartyApplicationService.name);
|
||||
|
||||
constructor(
|
||||
private readonly participateKeygenHandler: ParticipateInKeygenHandler,
|
||||
private readonly participateSigningHandler: ParticipateInSigningHandler,
|
||||
private readonly rotateShareHandler: RotateShareHandler,
|
||||
private readonly getShareInfoHandler: GetShareInfoHandler,
|
||||
private readonly listSharesHandler: ListSharesHandler,
|
||||
) {}
|
||||
|
||||
// ============================================================================
|
||||
// Command Operations (Write)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Participate in an MPC key generation session
|
||||
*/
|
||||
async participateInKeygen(params: {
|
||||
sessionId: string;
|
||||
partyId: string;
|
||||
joinToken: string;
|
||||
shareType: PartyShareType;
|
||||
userId?: string;
|
||||
}): Promise<KeygenResult> {
|
||||
this.logger.log(`participateInKeygen: party=${params.partyId}, session=${params.sessionId}`);
|
||||
|
||||
const command = new ParticipateInKeygenCommand(
|
||||
params.sessionId,
|
||||
params.partyId,
|
||||
params.joinToken,
|
||||
params.shareType,
|
||||
params.userId,
|
||||
);
|
||||
|
||||
return this.participateKeygenHandler.execute(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Participate in an MPC signing session
|
||||
*/
|
||||
async participateInSigning(params: {
|
||||
sessionId: string;
|
||||
partyId: string;
|
||||
joinToken: string;
|
||||
messageHash: string;
|
||||
publicKey?: string;
|
||||
}): Promise<SigningResult> {
|
||||
this.logger.log(`participateInSigning: party=${params.partyId}, session=${params.sessionId}`);
|
||||
|
||||
const command = new ParticipateInSigningCommand(
|
||||
params.sessionId,
|
||||
params.partyId,
|
||||
params.joinToken,
|
||||
params.messageHash,
|
||||
params.publicKey,
|
||||
);
|
||||
|
||||
return this.participateSigningHandler.execute(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Participate in share rotation (key refresh)
|
||||
*/
|
||||
async rotateShare(params: {
|
||||
sessionId: string;
|
||||
partyId: string;
|
||||
joinToken: string;
|
||||
publicKey: string;
|
||||
}): Promise<RotateShareResult> {
|
||||
this.logger.log(`rotateShare: party=${params.partyId}, session=${params.sessionId}`);
|
||||
|
||||
const command = new RotateShareCommand(
|
||||
params.sessionId,
|
||||
params.partyId,
|
||||
params.joinToken,
|
||||
params.publicKey,
|
||||
);
|
||||
|
||||
return this.rotateShareHandler.execute(command);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Query Operations (Read)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get information about a specific share
|
||||
*/
|
||||
async getShareInfo(shareId: string): Promise<ShareInfoDto> {
|
||||
this.logger.log(`getShareInfo: shareId=${shareId}`);
|
||||
|
||||
const query = new GetShareInfoQuery(shareId);
|
||||
return this.getShareInfoHandler.execute(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* List shares with filters and pagination
|
||||
*/
|
||||
async listShares(params: {
|
||||
partyId?: string;
|
||||
status?: PartyShareStatus;
|
||||
shareType?: PartyShareType;
|
||||
publicKey?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<ListSharesResult> {
|
||||
this.logger.log(`listShares: filters=${JSON.stringify(params)}`);
|
||||
|
||||
const query = new ListSharesQuery(
|
||||
params.partyId,
|
||||
params.status,
|
||||
params.shareType,
|
||||
params.publicKey,
|
||||
params.page || 1,
|
||||
params.limit || 20,
|
||||
);
|
||||
|
||||
return this.listSharesHandler.execute(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shares by party ID
|
||||
*/
|
||||
async getSharesByPartyId(partyId: string): Promise<ListSharesResult> {
|
||||
return this.listShares({ partyId, status: PartyShareStatus.ACTIVE });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get share by public key
|
||||
*/
|
||||
async getShareByPublicKey(publicKey: string): Promise<ShareInfoDto | null> {
|
||||
const result = await this.listShares({ publicKey, limit: 1 });
|
||||
return result.items.length > 0 ? result.items[0] : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* App Configuration
|
||||
*/
|
||||
|
||||
export interface AppConfig {
|
||||
port: number;
|
||||
env: string;
|
||||
apiPrefix: string;
|
||||
}
|
||||
|
||||
export const appConfig = (): AppConfig => ({
|
||||
port: parseInt(process.env.APP_PORT || '3006', 10),
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
apiPrefix: process.env.API_PREFIX || 'api/v1',
|
||||
});
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Database Configuration
|
||||
*/
|
||||
|
||||
export interface DatabaseConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const databaseConfig = (): DatabaseConfig => ({
|
||||
url: process.env.DATABASE_URL || '',
|
||||
});
|
||||
|
|
@ -2,53 +2,26 @@
|
|||
* Configuration Index
|
||||
*
|
||||
* Central configuration management using NestJS ConfigModule.
|
||||
* Individual config files can be found in config/*.json for environment-specific defaults.
|
||||
*/
|
||||
|
||||
export const appConfig = () => ({
|
||||
port: parseInt(process.env.APP_PORT || '3006', 10),
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
apiPrefix: process.env.API_PREFIX || 'api/v1',
|
||||
});
|
||||
export { AppConfig, appConfig } from './app.config';
|
||||
export { DatabaseConfig, databaseConfig } from './database.config';
|
||||
export { JwtConfig, jwtConfig } from './jwt.config';
|
||||
export { RedisConfig, redisConfig } from './redis.config';
|
||||
export { KafkaConfig, kafkaConfig } from './kafka.config';
|
||||
export { MpcConfig, mpcConfig } from './mpc.config';
|
||||
export { TssConfig, tssConfig } from './tss.config';
|
||||
|
||||
export const databaseConfig = () => ({
|
||||
url: process.env.DATABASE_URL,
|
||||
});
|
||||
import { appConfig } from './app.config';
|
||||
import { databaseConfig } from './database.config';
|
||||
import { jwtConfig } from './jwt.config';
|
||||
import { redisConfig } from './redis.config';
|
||||
import { kafkaConfig } from './kafka.config';
|
||||
import { mpcConfig } from './mpc.config';
|
||||
import { tssConfig } from './tss.config';
|
||||
|
||||
export const jwtConfig = () => ({
|
||||
secret: process.env.JWT_SECRET || 'default-jwt-secret-change-in-production',
|
||||
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||
});
|
||||
|
||||
export const redisConfig = () => ({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
db: parseInt(process.env.REDIS_DB || '5', 10),
|
||||
});
|
||||
|
||||
export const kafkaConfig = () => ({
|
||||
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
|
||||
clientId: process.env.KAFKA_CLIENT_ID || 'mpc-party-service',
|
||||
groupId: process.env.KAFKA_GROUP_ID || 'mpc-party-group',
|
||||
});
|
||||
|
||||
export const mpcConfig = () => ({
|
||||
coordinatorUrl: process.env.MPC_COORDINATOR_URL || 'http://localhost:50051',
|
||||
coordinatorTimeout: parseInt(process.env.MPC_COORDINATOR_TIMEOUT || '30000', 10),
|
||||
messageRouterWsUrl: process.env.MPC_MESSAGE_ROUTER_WS_URL || 'ws://localhost:50052',
|
||||
shareMasterKey: process.env.SHARE_MASTER_KEY,
|
||||
keygenTimeout: parseInt(process.env.MPC_KEYGEN_TIMEOUT || '300000', 10),
|
||||
signingTimeout: parseInt(process.env.MPC_SIGNING_TIMEOUT || '180000', 10),
|
||||
refreshTimeout: parseInt(process.env.MPC_REFRESH_TIMEOUT || '300000', 10),
|
||||
});
|
||||
|
||||
export const tssConfig = () => ({
|
||||
libPath: process.env.TSS_LIB_PATH || '/opt/tss-lib/tss',
|
||||
tempDir: process.env.TSS_TEMP_DIR || '/tmp/tss',
|
||||
});
|
||||
|
||||
// Combined configuration loader
|
||||
// Combined configuration loader for NestJS ConfigModule
|
||||
export const configurations = [
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* JWT Configuration
|
||||
*/
|
||||
|
||||
export interface JwtConfig {
|
||||
secret: string;
|
||||
accessExpiresIn: string;
|
||||
refreshExpiresIn: string;
|
||||
}
|
||||
|
||||
export const jwtConfig = (): JwtConfig => ({
|
||||
secret: process.env.JWT_SECRET || 'default-jwt-secret-change-in-production',
|
||||
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Kafka Configuration
|
||||
*/
|
||||
|
||||
export interface KafkaConfig {
|
||||
brokers: string[];
|
||||
clientId: string;
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export const kafkaConfig = (): KafkaConfig => ({
|
||||
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
|
||||
clientId: process.env.KAFKA_CLIENT_ID || 'mpc-party-service',
|
||||
groupId: process.env.KAFKA_GROUP_ID || 'mpc-party-group',
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* MPC Configuration
|
||||
*/
|
||||
|
||||
export interface MpcConfig {
|
||||
coordinatorUrl: string;
|
||||
coordinatorTimeout: number;
|
||||
messageRouterWsUrl: string;
|
||||
shareMasterKey?: string;
|
||||
keygenTimeout: number;
|
||||
signingTimeout: number;
|
||||
refreshTimeout: number;
|
||||
}
|
||||
|
||||
export const mpcConfig = (): MpcConfig => ({
|
||||
coordinatorUrl: process.env.MPC_COORDINATOR_URL || 'http://localhost:50051',
|
||||
coordinatorTimeout: parseInt(process.env.MPC_COORDINATOR_TIMEOUT || '30000', 10),
|
||||
messageRouterWsUrl: process.env.MPC_MESSAGE_ROUTER_WS_URL || 'ws://localhost:50052',
|
||||
shareMasterKey: process.env.SHARE_MASTER_KEY,
|
||||
keygenTimeout: parseInt(process.env.MPC_KEYGEN_TIMEOUT || '300000', 10),
|
||||
signingTimeout: parseInt(process.env.MPC_SIGNING_TIMEOUT || '180000', 10),
|
||||
refreshTimeout: parseInt(process.env.MPC_REFRESH_TIMEOUT || '300000', 10),
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Redis Configuration
|
||||
*/
|
||||
|
||||
export interface RedisConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
password?: string;
|
||||
db: number;
|
||||
}
|
||||
|
||||
export const redisConfig = (): RedisConfig => ({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: parseInt(process.env.REDIS_DB || '5', 10),
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* TSS Configuration
|
||||
*/
|
||||
|
||||
export interface TssConfig {
|
||||
libPath: string;
|
||||
tempDir: string;
|
||||
}
|
||||
|
||||
export const tssConfig = (): TssConfig => ({
|
||||
libPath: process.env.TSS_LIB_PATH || '/opt/tss-lib/tss',
|
||||
tempDir: process.env.TSS_TEMP_DIR || '/tmp/tss',
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Domain Aggregates Index
|
||||
*/
|
||||
|
||||
export * from './party-share';
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* PartyShare Aggregate Index
|
||||
*/
|
||||
|
||||
export * from './party-share.aggregate';
|
||||
export * from './party-share.factory';
|
||||
export * from './party-share.spec';
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* PartyShare Aggregate
|
||||
*
|
||||
* The aggregate root that represents a party's share in the MPC system.
|
||||
* Re-exports from the entity with aggregate-specific concerns.
|
||||
*/
|
||||
|
||||
export { PartyShare, PartyShareCreateParams, PartyShareReconstructParams } from '../../entities/party-share.entity';
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* PartyShare Factory
|
||||
*
|
||||
* Factory for creating PartyShare aggregate instances.
|
||||
* Encapsulates complex creation logic and ensures valid aggregates.
|
||||
*/
|
||||
|
||||
import { PartyShare } from '../../entities/party-share.entity';
|
||||
import {
|
||||
ShareId,
|
||||
PartyId,
|
||||
SessionId,
|
||||
ShareData,
|
||||
PublicKey,
|
||||
Threshold,
|
||||
} from '../../value-objects';
|
||||
import { PartyShareType, PartyShareStatus } from '../../enums';
|
||||
|
||||
export interface CreatePartyShareParams {
|
||||
partyId: string;
|
||||
sessionId: string;
|
||||
shareType: PartyShareType;
|
||||
encryptedShareData: {
|
||||
data: string;
|
||||
iv: string;
|
||||
authTag: string;
|
||||
};
|
||||
publicKeyHex: string;
|
||||
thresholdN: number;
|
||||
thresholdT: number;
|
||||
}
|
||||
|
||||
export interface ReconstructPartyShareParams {
|
||||
id: string;
|
||||
partyId: string;
|
||||
sessionId: string;
|
||||
shareType: PartyShareType;
|
||||
shareDataJson: string;
|
||||
publicKeyHex: string;
|
||||
thresholdN: number;
|
||||
thresholdT: number;
|
||||
status: PartyShareStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastUsedAt?: Date;
|
||||
}
|
||||
|
||||
export class PartyShareFactory {
|
||||
/**
|
||||
* Create a new PartyShare aggregate from raw input
|
||||
*/
|
||||
static create(params: CreatePartyShareParams): PartyShare {
|
||||
const partyId = PartyId.create(params.partyId);
|
||||
const sessionId = SessionId.create(params.sessionId);
|
||||
const shareData = ShareData.fromJSON(params.encryptedShareData);
|
||||
const publicKey = PublicKey.fromHex(params.publicKeyHex);
|
||||
const threshold = Threshold.create(params.thresholdN, params.thresholdT);
|
||||
|
||||
return PartyShare.create({
|
||||
partyId,
|
||||
sessionId,
|
||||
shareType: params.shareType,
|
||||
shareData,
|
||||
publicKey,
|
||||
threshold,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a PartyShare aggregate from persistence data
|
||||
*/
|
||||
static reconstruct(params: ReconstructPartyShareParams): PartyShare {
|
||||
const id = ShareId.create(params.id);
|
||||
const partyId = PartyId.create(params.partyId);
|
||||
const sessionId = SessionId.create(params.sessionId);
|
||||
const shareData = ShareData.fromJSON(JSON.parse(params.shareDataJson));
|
||||
const publicKey = PublicKey.fromHex(params.publicKeyHex);
|
||||
const threshold = Threshold.create(params.thresholdN, params.thresholdT);
|
||||
|
||||
return PartyShare.reconstruct({
|
||||
id,
|
||||
partyId,
|
||||
sessionId,
|
||||
shareType: params.shareType,
|
||||
shareData,
|
||||
publicKey,
|
||||
threshold,
|
||||
status: params.status,
|
||||
createdAt: params.createdAt,
|
||||
updatedAt: params.updatedAt,
|
||||
lastUsedAt: params.lastUsedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PartyShare for wallet key generation
|
||||
*/
|
||||
static createWalletShare(
|
||||
partyId: string,
|
||||
sessionId: string,
|
||||
encryptedShareData: { data: string; iv: string; authTag: string },
|
||||
publicKeyHex: string,
|
||||
threshold: { n: number; t: number },
|
||||
): PartyShare {
|
||||
return this.create({
|
||||
partyId,
|
||||
sessionId,
|
||||
shareType: PartyShareType.WALLET,
|
||||
encryptedShareData,
|
||||
publicKeyHex,
|
||||
thresholdN: threshold.n,
|
||||
thresholdT: threshold.t,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PartyShare for admin multi-sig
|
||||
*/
|
||||
static createAdminShare(
|
||||
partyId: string,
|
||||
sessionId: string,
|
||||
encryptedShareData: { data: string; iv: string; authTag: string },
|
||||
publicKeyHex: string,
|
||||
threshold: { n: number; t: number },
|
||||
): PartyShare {
|
||||
return this.create({
|
||||
partyId,
|
||||
sessionId,
|
||||
shareType: PartyShareType.ADMIN,
|
||||
encryptedShareData,
|
||||
publicKeyHex,
|
||||
thresholdN: threshold.n,
|
||||
thresholdT: threshold.t,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PartyShare for recovery purposes
|
||||
*/
|
||||
static createRecoveryShare(
|
||||
partyId: string,
|
||||
sessionId: string,
|
||||
encryptedShareData: { data: string; iv: string; authTag: string },
|
||||
publicKeyHex: string,
|
||||
threshold: { n: number; t: number },
|
||||
): PartyShare {
|
||||
return this.create({
|
||||
partyId,
|
||||
sessionId,
|
||||
shareType: PartyShareType.RECOVERY,
|
||||
encryptedShareData,
|
||||
publicKeyHex,
|
||||
thresholdN: threshold.n,
|
||||
thresholdT: threshold.t,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* PartyShare Specification
|
||||
*
|
||||
* Contains business rules and validation specifications for PartyShare aggregate.
|
||||
* Used to encapsulate complex domain logic and validation rules.
|
||||
*/
|
||||
|
||||
import { PartyShare } from '../../entities/party-share.entity';
|
||||
import { PartyShareStatus, PartyShareType } from '../../enums';
|
||||
import { PartyId, PublicKey } from '../../value-objects';
|
||||
|
||||
/**
|
||||
* Specification interface for PartyShare
|
||||
*/
|
||||
export interface PartyShareSpecification {
|
||||
isSatisfiedBy(share: PartyShare): boolean;
|
||||
and(other: PartyShareSpecification): PartyShareSpecification;
|
||||
or(other: PartyShareSpecification): PartyShareSpecification;
|
||||
not(): PartyShareSpecification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base specification class with composition methods
|
||||
*/
|
||||
abstract class BaseSpecification implements PartyShareSpecification {
|
||||
abstract isSatisfiedBy(share: PartyShare): boolean;
|
||||
|
||||
and(other: PartyShareSpecification): PartyShareSpecification {
|
||||
return new AndSpecification(this, other);
|
||||
}
|
||||
|
||||
or(other: PartyShareSpecification): PartyShareSpecification {
|
||||
return new OrSpecification(this, other);
|
||||
}
|
||||
|
||||
not(): PartyShareSpecification {
|
||||
return new NotSpecification(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite AND specification
|
||||
*/
|
||||
class AndSpecification extends BaseSpecification {
|
||||
constructor(
|
||||
private readonly left: PartyShareSpecification,
|
||||
private readonly right: PartyShareSpecification,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
isSatisfiedBy(share: PartyShare): boolean {
|
||||
return this.left.isSatisfiedBy(share) && this.right.isSatisfiedBy(share);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite OR specification
|
||||
*/
|
||||
class OrSpecification extends BaseSpecification {
|
||||
constructor(
|
||||
private readonly left: PartyShareSpecification,
|
||||
private readonly right: PartyShareSpecification,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
isSatisfiedBy(share: PartyShare): boolean {
|
||||
return this.left.isSatisfiedBy(share) || this.right.isSatisfiedBy(share);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT specification
|
||||
*/
|
||||
class NotSpecification extends BaseSpecification {
|
||||
constructor(private readonly spec: PartyShareSpecification) {
|
||||
super();
|
||||
}
|
||||
|
||||
isSatisfiedBy(share: PartyShare): boolean {
|
||||
return !this.spec.isSatisfiedBy(share);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Concrete Specifications
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Specification: Share is active
|
||||
*/
|
||||
export class IsActiveSpecification extends BaseSpecification {
|
||||
isSatisfiedBy(share: PartyShare): boolean {
|
||||
return share.status === PartyShareStatus.ACTIVE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification: Share belongs to a specific party
|
||||
*/
|
||||
export class BelongsToPartySpecification extends BaseSpecification {
|
||||
constructor(private readonly partyId: PartyId) {
|
||||
super();
|
||||
}
|
||||
|
||||
isSatisfiedBy(share: PartyShare): boolean {
|
||||
return share.belongsToParty(this.partyId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification: Share has specific public key
|
||||
*/
|
||||
export class HasPublicKeySpecification extends BaseSpecification {
|
||||
constructor(private readonly publicKey: PublicKey) {
|
||||
super();
|
||||
}
|
||||
|
||||
isSatisfiedBy(share: PartyShare): boolean {
|
||||
return share.hasPublicKey(this.publicKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification: Share is of a specific type
|
||||
*/
|
||||
export class IsShareTypeSpecification extends BaseSpecification {
|
||||
constructor(private readonly shareType: PartyShareType) {
|
||||
super();
|
||||
}
|
||||
|
||||
isSatisfiedBy(share: PartyShare): boolean {
|
||||
return share.shareType === this.shareType;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification: Share meets minimum threshold requirement
|
||||
*/
|
||||
export class MeetsThresholdSpecification extends BaseSpecification {
|
||||
constructor(private readonly participantCount: number) {
|
||||
super();
|
||||
}
|
||||
|
||||
isSatisfiedBy(share: PartyShare): boolean {
|
||||
return share.validateThreshold(this.participantCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification: Share was used recently (within given hours)
|
||||
*/
|
||||
export class WasUsedRecentlySpecification extends BaseSpecification {
|
||||
constructor(private readonly withinHours: number = 24) {
|
||||
super();
|
||||
}
|
||||
|
||||
isSatisfiedBy(share: PartyShare): boolean {
|
||||
if (!share.lastUsedAt) {
|
||||
return false;
|
||||
}
|
||||
const cutoffTime = new Date(Date.now() - this.withinHours * 60 * 60 * 1000);
|
||||
return share.lastUsedAt >= cutoffTime;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification: Share can be used for signing
|
||||
*/
|
||||
export class CanBeUsedForSigningSpecification extends BaseSpecification {
|
||||
constructor(private readonly participantCount: number) {
|
||||
super();
|
||||
}
|
||||
|
||||
isSatisfiedBy(share: PartyShare): boolean {
|
||||
const isActive = new IsActiveSpecification();
|
||||
const meetsThreshold = new MeetsThresholdSpecification(this.participantCount);
|
||||
return isActive.and(meetsThreshold).isSatisfiedBy(share);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification: Share is eligible for rotation
|
||||
*/
|
||||
export class IsEligibleForRotationSpecification extends BaseSpecification {
|
||||
constructor(private readonly minAgeInDays: number = 30) {
|
||||
super();
|
||||
}
|
||||
|
||||
isSatisfiedBy(share: PartyShare): boolean {
|
||||
const isActive = new IsActiveSpecification();
|
||||
if (!isActive.isSatisfiedBy(share)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if share is old enough to be rotated
|
||||
const cutoffTime = new Date(Date.now() - this.minAgeInDays * 24 * 60 * 60 * 1000);
|
||||
return share.createdAt <= cutoffTime;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Specification Factory
|
||||
// ============================================================================
|
||||
|
||||
export const PartyShareSpecs = {
|
||||
isActive: () => new IsActiveSpecification(),
|
||||
belongsToParty: (partyId: PartyId) => new BelongsToPartySpecification(partyId),
|
||||
hasPublicKey: (publicKey: PublicKey) => new HasPublicKeySpecification(publicKey),
|
||||
isShareType: (type: PartyShareType) => new IsShareTypeSpecification(type),
|
||||
meetsThreshold: (count: number) => new MeetsThresholdSpecification(count),
|
||||
wasUsedRecently: (hours?: number) => new WasUsedRecentlySpecification(hours),
|
||||
canBeUsedForSigning: (count: number) => new CanBeUsedForSigningSpecification(count),
|
||||
isEligibleForRotation: (days?: number) => new IsEligibleForRotationSpecification(days),
|
||||
};
|
||||
|
|
@ -8,6 +8,15 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ShareEncryptionDomainService } from './services/share-encryption.domain-service';
|
||||
|
||||
// Re-export domain components for easier imports
|
||||
export * from './aggregates';
|
||||
export * from './entities';
|
||||
export * from './value-objects';
|
||||
export * from './events';
|
||||
export * from './enums';
|
||||
export * from './repositories';
|
||||
export * from './services';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
ShareEncryptionDomainService,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,3 @@
|
|||
|
||||
export * from './party-share.entity';
|
||||
export * from './session-state.entity';
|
||||
export * from './mpc-wallet.entity';
|
||||
export * from './mpc-share.entity';
|
||||
export * from './mpc-session.entity';
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
/**
|
||||
* MPC Session Entity
|
||||
*
|
||||
* 存储 MPC 会话信息(keygen 和 signing)
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('mpc_sessions')
|
||||
export class MpcSession {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'session_id', unique: true })
|
||||
@Index()
|
||||
sessionId: string;
|
||||
|
||||
@Column({ name: 'session_type' })
|
||||
sessionType: string; // 'keygen' | 'sign'
|
||||
|
||||
@Column()
|
||||
@Index()
|
||||
username: string;
|
||||
|
||||
@Column({ name: 'threshold_n', nullable: true })
|
||||
thresholdN: number;
|
||||
|
||||
@Column({ name: 'threshold_t', nullable: true })
|
||||
thresholdT: number;
|
||||
|
||||
@Column({ name: 'selected_parties', type: 'simple-array', nullable: true })
|
||||
selectedParties: string[];
|
||||
|
||||
@Column({ name: 'delegate_party', nullable: true })
|
||||
delegateParty: string;
|
||||
|
||||
@Column({ name: 'message_hash', nullable: true })
|
||||
messageHash: string;
|
||||
|
||||
@Column({ name: 'public_key', nullable: true })
|
||||
publicKey: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
signature: string;
|
||||
|
||||
@Column({ default: 'created' })
|
||||
status: string; // 'created' | 'in_progress' | 'completed' | 'failed' | 'expired'
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
/**
|
||||
* MPC Share Entity
|
||||
*
|
||||
* 存储 MPC 分片信息(delegate share)
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('mpc_shares')
|
||||
export class MpcShare {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'session_id' })
|
||||
@Index()
|
||||
sessionId: string;
|
||||
|
||||
@Column()
|
||||
@Index()
|
||||
username: string;
|
||||
|
||||
@Column({ name: 'party_id' })
|
||||
partyId: string;
|
||||
|
||||
@Column({ name: 'party_index' })
|
||||
partyIndex: number;
|
||||
|
||||
@Column({ name: 'encrypted_share', type: 'text' })
|
||||
encryptedShare: string;
|
||||
|
||||
@Column({ name: 'share_type' })
|
||||
shareType: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
/**
|
||||
* MPC Wallet Entity
|
||||
*
|
||||
* 存储用户的 MPC 钱包信息(公钥和 keygen session ID)
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('mpc_wallets')
|
||||
export class MpcWallet {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
@Index()
|
||||
username: string;
|
||||
|
||||
@Column({ name: 'public_key' })
|
||||
publicKey: string;
|
||||
|
||||
@Column({ name: 'keygen_session_id' })
|
||||
@Index()
|
||||
keygenSessionId: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Base Domain Event
|
||||
*
|
||||
* Abstract base class for all domain events.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export abstract class DomainEvent {
|
||||
public readonly eventId: string;
|
||||
public readonly occurredAt: Date;
|
||||
|
||||
constructor() {
|
||||
this.eventId = uuidv4();
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
|
||||
abstract get eventType(): string;
|
||||
abstract get aggregateId(): string;
|
||||
abstract get aggregateType(): string;
|
||||
abstract get payload(): Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -5,396 +5,37 @@
|
|||
* They are used for audit logging, event sourcing, and async communication.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PartyShareType, SessionType } from '../enums';
|
||||
// Base
|
||||
export { DomainEvent } from './domain-event.base';
|
||||
|
||||
// ============================================================================
|
||||
// Base Domain Event
|
||||
// ============================================================================
|
||||
export abstract class DomainEvent {
|
||||
public readonly eventId: string;
|
||||
public readonly occurredAt: Date;
|
||||
|
||||
constructor() {
|
||||
this.eventId = uuidv4();
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
|
||||
abstract get eventType(): string;
|
||||
abstract get aggregateId(): string;
|
||||
abstract get aggregateType(): string;
|
||||
abstract get payload(): Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Share Events
|
||||
// ============================================================================
|
||||
export { ShareCreatedEvent } from './share-created.event';
|
||||
export { ShareRotatedEvent } from './share-rotated.event';
|
||||
export { ShareRevokedEvent } from './share-revoked.event';
|
||||
export { ShareUsedEvent } from './share-used.event';
|
||||
|
||||
/**
|
||||
* Emitted when a new party share is created (after keygen)
|
||||
*/
|
||||
export class ShareCreatedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly shareId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly sessionId: string,
|
||||
public readonly shareType: PartyShareType,
|
||||
public readonly publicKey: string,
|
||||
public readonly threshold: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'ShareCreated';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.shareId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartyShare';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
shareId: this.shareId,
|
||||
partyId: this.partyId,
|
||||
sessionId: this.sessionId,
|
||||
shareType: this.shareType,
|
||||
publicKey: this.publicKey,
|
||||
threshold: this.threshold,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted when a share is rotated (replaced with a new share)
|
||||
*/
|
||||
export class ShareRotatedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly newShareId: string,
|
||||
public readonly oldShareId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly sessionId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'ShareRotated';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.newShareId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartyShare';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
newShareId: this.newShareId,
|
||||
oldShareId: this.oldShareId,
|
||||
partyId: this.partyId,
|
||||
sessionId: this.sessionId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted when a share is revoked
|
||||
*/
|
||||
export class ShareRevokedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly shareId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly reason: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'ShareRevoked';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.shareId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartyShare';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
shareId: this.shareId,
|
||||
partyId: this.partyId,
|
||||
reason: this.reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted when a share is used in a signing operation
|
||||
*/
|
||||
export class ShareUsedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly shareId: string,
|
||||
public readonly sessionId: string,
|
||||
public readonly messageHash: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'ShareUsed';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.shareId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartyShare';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
shareId: this.shareId,
|
||||
sessionId: this.sessionId,
|
||||
messageHash: this.messageHash,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Events
|
||||
// ============================================================================
|
||||
export { KeygenCompletedEvent } from './keygen-completed.event';
|
||||
export { SigningCompletedEvent } from './signing-completed.event';
|
||||
export { SessionFailedEvent } from './session-failed.event';
|
||||
export { PartyJoinedSessionEvent } from './party-joined-session.event';
|
||||
export { SessionTimeoutEvent } from './session-timeout.event';
|
||||
|
||||
/**
|
||||
* Emitted when a keygen session is completed successfully
|
||||
*/
|
||||
export class KeygenCompletedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly sessionId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly publicKey: string,
|
||||
public readonly shareId: string,
|
||||
public readonly threshold: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'KeygenCompleted';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartySession';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
partyId: this.partyId,
|
||||
publicKey: this.publicKey,
|
||||
shareId: this.shareId,
|
||||
threshold: this.threshold,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted when a signing session is completed successfully
|
||||
*/
|
||||
export class SigningCompletedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly sessionId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly messageHash: string,
|
||||
public readonly signature: string,
|
||||
public readonly publicKey: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'SigningCompleted';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartySession';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
partyId: this.partyId,
|
||||
messageHash: this.messageHash,
|
||||
signature: this.signature,
|
||||
publicKey: this.publicKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted when a session fails
|
||||
*/
|
||||
export class SessionFailedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly sessionId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly sessionType: SessionType,
|
||||
public readonly errorMessage: string,
|
||||
public readonly errorCode?: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'SessionFailed';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartySession';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
partyId: this.partyId,
|
||||
sessionType: this.sessionType,
|
||||
errorMessage: this.errorMessage,
|
||||
errorCode: this.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted when a party joins a session
|
||||
*/
|
||||
export class PartyJoinedSessionEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly sessionId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly partyIndex: number,
|
||||
public readonly sessionType: SessionType,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'PartyJoinedSession';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartySession';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
partyId: this.partyId,
|
||||
partyIndex: this.partyIndex,
|
||||
sessionType: this.sessionType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted when a session times out
|
||||
*/
|
||||
export class SessionTimeoutEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly sessionId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly sessionType: SessionType,
|
||||
public readonly lastRound: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'SessionTimeout';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartySession';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
partyId: this.partyId,
|
||||
sessionType: this.sessionType,
|
||||
lastRound: this.lastRound,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Security Events
|
||||
// ============================================================================
|
||||
export { ShareDecryptionAttemptedEvent } from './share-decryption-attempted.event';
|
||||
|
||||
/**
|
||||
* Emitted when share decryption is attempted
|
||||
*/
|
||||
export class ShareDecryptionAttemptedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly shareId: string,
|
||||
public readonly success: boolean,
|
||||
public readonly reason?: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'ShareDecryptionAttempted';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.shareId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartyShare';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
shareId: this.shareId,
|
||||
success: this.success,
|
||||
reason: this.reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event Types Union
|
||||
// ============================================================================
|
||||
import { ShareCreatedEvent } from './share-created.event';
|
||||
import { ShareRotatedEvent } from './share-rotated.event';
|
||||
import { ShareRevokedEvent } from './share-revoked.event';
|
||||
import { ShareUsedEvent } from './share-used.event';
|
||||
import { KeygenCompletedEvent } from './keygen-completed.event';
|
||||
import { SigningCompletedEvent } from './signing-completed.event';
|
||||
import { SessionFailedEvent } from './session-failed.event';
|
||||
import { PartyJoinedSessionEvent } from './party-joined-session.event';
|
||||
import { SessionTimeoutEvent } from './session-timeout.event';
|
||||
import { ShareDecryptionAttemptedEvent } from './share-decryption-attempted.event';
|
||||
|
||||
export type MPCDomainEvent =
|
||||
| ShareCreatedEvent
|
||||
| ShareRotatedEvent
|
||||
|
|
@ -407,9 +48,7 @@ export type MPCDomainEvent =
|
|||
| SessionTimeoutEvent
|
||||
| ShareDecryptionAttemptedEvent;
|
||||
|
||||
// ============================================================================
|
||||
// Event Topic Names (for Kafka)
|
||||
// ============================================================================
|
||||
export const MPC_TOPICS = {
|
||||
SHARE_CREATED: 'mpc.ShareCreated',
|
||||
SHARE_ROTATED: 'mpc.ShareRotated',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* KeygenCompleted Event
|
||||
*
|
||||
* Emitted when a keygen session is completed successfully.
|
||||
*/
|
||||
|
||||
import { DomainEvent } from './domain-event.base';
|
||||
|
||||
export class KeygenCompletedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly sessionId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly publicKey: string,
|
||||
public readonly shareId: string,
|
||||
public readonly threshold: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'KeygenCompleted';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartySession';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
partyId: this.partyId,
|
||||
publicKey: this.publicKey,
|
||||
shareId: this.shareId,
|
||||
threshold: this.threshold,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* PartyJoinedSession Event
|
||||
*
|
||||
* Emitted when a party joins a session.
|
||||
*/
|
||||
|
||||
import { DomainEvent } from './domain-event.base';
|
||||
import { SessionType } from '../enums';
|
||||
|
||||
export class PartyJoinedSessionEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly sessionId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly partyIndex: number,
|
||||
public readonly sessionType: SessionType,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'PartyJoinedSession';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartySession';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
partyId: this.partyId,
|
||||
partyIndex: this.partyIndex,
|
||||
sessionType: this.sessionType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* SessionFailed Event
|
||||
*
|
||||
* Emitted when a session fails.
|
||||
*/
|
||||
|
||||
import { DomainEvent } from './domain-event.base';
|
||||
import { SessionType } from '../enums';
|
||||
|
||||
export class SessionFailedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly sessionId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly sessionType: SessionType,
|
||||
public readonly errorMessage: string,
|
||||
public readonly errorCode?: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'SessionFailed';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartySession';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
partyId: this.partyId,
|
||||
sessionType: this.sessionType,
|
||||
errorMessage: this.errorMessage,
|
||||
errorCode: this.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* SessionTimeout Event
|
||||
*
|
||||
* Emitted when a session times out.
|
||||
*/
|
||||
|
||||
import { DomainEvent } from './domain-event.base';
|
||||
import { SessionType } from '../enums';
|
||||
|
||||
export class SessionTimeoutEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly sessionId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly sessionType: SessionType,
|
||||
public readonly lastRound: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'SessionTimeout';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartySession';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
partyId: this.partyId,
|
||||
sessionType: this.sessionType,
|
||||
lastRound: this.lastRound,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* ShareCreated Event
|
||||
*
|
||||
* Emitted when a new party share is created (after keygen).
|
||||
*/
|
||||
|
||||
import { DomainEvent } from './domain-event.base';
|
||||
import { PartyShareType } from '../enums';
|
||||
|
||||
export class ShareCreatedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly shareId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly sessionId: string,
|
||||
public readonly shareType: PartyShareType,
|
||||
public readonly publicKey: string,
|
||||
public readonly threshold: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'ShareCreated';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.shareId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartyShare';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
shareId: this.shareId,
|
||||
partyId: this.partyId,
|
||||
sessionId: this.sessionId,
|
||||
shareType: this.shareType,
|
||||
publicKey: this.publicKey,
|
||||
threshold: this.threshold,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* ShareDecryptionAttempted Event
|
||||
*
|
||||
* Emitted when share decryption is attempted.
|
||||
*/
|
||||
|
||||
import { DomainEvent } from './domain-event.base';
|
||||
|
||||
export class ShareDecryptionAttemptedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly shareId: string,
|
||||
public readonly success: boolean,
|
||||
public readonly reason?: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'ShareDecryptionAttempted';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.shareId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartyShare';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
shareId: this.shareId,
|
||||
success: this.success,
|
||||
reason: this.reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* ShareRevoked Event
|
||||
*
|
||||
* Emitted when a share is revoked.
|
||||
*/
|
||||
|
||||
import { DomainEvent } from './domain-event.base';
|
||||
|
||||
export class ShareRevokedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly shareId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly reason: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'ShareRevoked';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.shareId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartyShare';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
shareId: this.shareId,
|
||||
partyId: this.partyId,
|
||||
reason: this.reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* ShareRotated Event
|
||||
*
|
||||
* Emitted when a share is rotated (replaced with a new share).
|
||||
*/
|
||||
|
||||
import { DomainEvent } from './domain-event.base';
|
||||
|
||||
export class ShareRotatedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly newShareId: string,
|
||||
public readonly oldShareId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly sessionId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'ShareRotated';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.newShareId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartyShare';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
newShareId: this.newShareId,
|
||||
oldShareId: this.oldShareId,
|
||||
partyId: this.partyId,
|
||||
sessionId: this.sessionId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* ShareUsed Event
|
||||
*
|
||||
* Emitted when a share is used in a signing operation.
|
||||
*/
|
||||
|
||||
import { DomainEvent } from './domain-event.base';
|
||||
|
||||
export class ShareUsedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly shareId: string,
|
||||
public readonly sessionId: string,
|
||||
public readonly messageHash: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'ShareUsed';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.shareId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartyShare';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
shareId: this.shareId,
|
||||
sessionId: this.sessionId,
|
||||
messageHash: this.messageHash,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* SigningCompleted Event
|
||||
*
|
||||
* Emitted when a signing session is completed successfully.
|
||||
*/
|
||||
|
||||
import { DomainEvent } from './domain-event.base';
|
||||
|
||||
export class SigningCompletedEvent extends DomainEvent {
|
||||
constructor(
|
||||
public readonly sessionId: string,
|
||||
public readonly partyId: string,
|
||||
public readonly messageHash: string,
|
||||
public readonly signature: string,
|
||||
public readonly publicKey: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'SigningCompleted';
|
||||
}
|
||||
|
||||
get aggregateId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
get aggregateType(): string {
|
||||
return 'PartySession';
|
||||
}
|
||||
|
||||
get payload(): Record<string, unknown> {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
partyId: this.partyId,
|
||||
messageHash: this.messageHash,
|
||||
signature: this.signature,
|
||||
publicKey: this.publicKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -5,455 +5,11 @@
|
|||
* They have no identity - equality is based on their values.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// ============================================================================
|
||||
// SessionId - Unique identifier for MPC sessions
|
||||
// ============================================================================
|
||||
export class SessionId {
|
||||
private readonly _value: string;
|
||||
|
||||
private constructor(value: string) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
static create(value: string): SessionId {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error('SessionId cannot be empty');
|
||||
}
|
||||
// UUID format validation
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(value)) {
|
||||
throw new Error('Invalid SessionId format. Expected UUID format.');
|
||||
}
|
||||
return new SessionId(value);
|
||||
}
|
||||
|
||||
static generate(): SessionId {
|
||||
return new SessionId(uuidv4());
|
||||
}
|
||||
|
||||
equals(other: SessionId): boolean {
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this._value;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PartyId - Identifier for MPC party (format: {userId}-{type})
|
||||
// ============================================================================
|
||||
export class PartyId {
|
||||
private readonly _value: string;
|
||||
|
||||
private constructor(value: string) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
static create(value: string): PartyId {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error('PartyId cannot be empty');
|
||||
}
|
||||
// Format: {userId}-{type} e.g., user123-server
|
||||
const partyIdRegex = /^[\w]+-[\w]+$/;
|
||||
if (!partyIdRegex.test(value)) {
|
||||
throw new Error('Invalid PartyId format. Expected: {identifier}-{type}');
|
||||
}
|
||||
return new PartyId(value);
|
||||
}
|
||||
|
||||
static fromComponents(identifier: string, type: string): PartyId {
|
||||
return PartyId.create(`${identifier}-${type}`);
|
||||
}
|
||||
|
||||
equals(other: PartyId): boolean {
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
getIdentifier(): string {
|
||||
const parts = this._value.split('-');
|
||||
return parts.slice(0, -1).join('-');
|
||||
}
|
||||
|
||||
getType(): string {
|
||||
return this._value.split('-').pop() || '';
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this._value;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ShareId - Unique identifier for party shares
|
||||
// ============================================================================
|
||||
export class ShareId {
|
||||
private readonly _value: string;
|
||||
|
||||
private constructor(value: string) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
static create(value: string): ShareId {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error('ShareId cannot be empty');
|
||||
}
|
||||
// Format: share_{timestamp}_{random}
|
||||
const shareIdRegex = /^share_\d+_[a-z0-9]+$/;
|
||||
if (!shareIdRegex.test(value)) {
|
||||
throw new Error('Invalid ShareId format');
|
||||
}
|
||||
return new ShareId(value);
|
||||
}
|
||||
|
||||
static generate(): ShareId {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 11);
|
||||
return new ShareId(`share_${timestamp}_${random}`);
|
||||
}
|
||||
|
||||
equals(other: ShareId): boolean {
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this._value;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Threshold - MPC threshold configuration (t-of-n)
|
||||
// ============================================================================
|
||||
export class Threshold {
|
||||
private readonly _n: number; // Total number of parties
|
||||
private readonly _t: number; // Minimum required signers
|
||||
|
||||
private constructor(n: number, t: number) {
|
||||
this._n = n;
|
||||
this._t = t;
|
||||
}
|
||||
|
||||
get n(): number {
|
||||
return this._n;
|
||||
}
|
||||
|
||||
get t(): number {
|
||||
return this._t;
|
||||
}
|
||||
|
||||
static create(n: number, t: number): Threshold {
|
||||
if (!Number.isInteger(n) || !Number.isInteger(t)) {
|
||||
throw new Error('Threshold values must be integers');
|
||||
}
|
||||
if (n <= 0 || t <= 0) {
|
||||
throw new Error('Threshold values must be positive');
|
||||
}
|
||||
if (t > n) {
|
||||
throw new Error('t cannot exceed n');
|
||||
}
|
||||
if (t < 2) {
|
||||
throw new Error('t must be at least 2 for security');
|
||||
}
|
||||
return new Threshold(n, t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Common threshold configurations
|
||||
*/
|
||||
static twoOfThree(): Threshold {
|
||||
return new Threshold(3, 2);
|
||||
}
|
||||
|
||||
static threeOfFive(): Threshold {
|
||||
return new Threshold(5, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if participant count meets threshold requirements
|
||||
*/
|
||||
validateParticipants(participantsCount: number): boolean {
|
||||
return participantsCount >= this._t && participantsCount <= this._n;
|
||||
}
|
||||
|
||||
equals(other: Threshold): boolean {
|
||||
return this._n === other._n && this._t === other._t;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this._t}-of-${this._n}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ShareData - Encrypted share data with AES-GCM parameters
|
||||
// ============================================================================
|
||||
export class ShareData {
|
||||
private readonly _encryptedData: Buffer;
|
||||
private readonly _iv: Buffer; // Initialization vector
|
||||
private readonly _authTag: Buffer; // Authentication tag (AES-GCM)
|
||||
|
||||
private constructor(encryptedData: Buffer, iv: Buffer, authTag: Buffer) {
|
||||
this._encryptedData = encryptedData;
|
||||
this._iv = iv;
|
||||
this._authTag = authTag;
|
||||
}
|
||||
|
||||
get encryptedData(): Buffer {
|
||||
return this._encryptedData;
|
||||
}
|
||||
|
||||
get iv(): Buffer {
|
||||
return this._iv;
|
||||
}
|
||||
|
||||
get authTag(): Buffer {
|
||||
return this._authTag;
|
||||
}
|
||||
|
||||
static create(encryptedData: Buffer, iv: Buffer, authTag: Buffer): ShareData {
|
||||
if (!encryptedData || encryptedData.length === 0) {
|
||||
throw new Error('Encrypted data cannot be empty');
|
||||
}
|
||||
if (!iv || iv.length !== 12) {
|
||||
throw new Error('IV must be 12 bytes for AES-GCM');
|
||||
}
|
||||
if (!authTag || authTag.length !== 16) {
|
||||
throw new Error('AuthTag must be 16 bytes for AES-GCM');
|
||||
}
|
||||
return new ShareData(encryptedData, iv, authTag);
|
||||
}
|
||||
|
||||
toJSON(): { data: string; iv: string; authTag: string } {
|
||||
return {
|
||||
data: this._encryptedData.toString('base64'),
|
||||
iv: this._iv.toString('base64'),
|
||||
authTag: this._authTag.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON(json: { data: string; iv: string; authTag: string }): ShareData {
|
||||
return new ShareData(
|
||||
Buffer.from(json.data, 'base64'),
|
||||
Buffer.from(json.iv, 'base64'),
|
||||
Buffer.from(json.authTag, 'base64'),
|
||||
);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return JSON.stringify(this.toJSON());
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PublicKey - ECDSA public key for MPC group
|
||||
// ============================================================================
|
||||
export class PublicKey {
|
||||
private readonly _keyBytes: Buffer;
|
||||
|
||||
private constructor(keyBytes: Buffer) {
|
||||
this._keyBytes = keyBytes;
|
||||
}
|
||||
|
||||
get bytes(): Buffer {
|
||||
return Buffer.from(this._keyBytes);
|
||||
}
|
||||
|
||||
static create(keyBytes: Buffer): PublicKey {
|
||||
if (!keyBytes || keyBytes.length === 0) {
|
||||
throw new Error('Public key cannot be empty');
|
||||
}
|
||||
// ECDSA public key: 33 bytes (compressed) or 65 bytes (uncompressed)
|
||||
if (keyBytes.length !== 33 && keyBytes.length !== 65) {
|
||||
throw new Error('Invalid public key length. Expected 33 (compressed) or 65 (uncompressed) bytes');
|
||||
}
|
||||
return new PublicKey(keyBytes);
|
||||
}
|
||||
|
||||
static fromHex(hex: string): PublicKey {
|
||||
if (!hex || hex.length === 0) {
|
||||
throw new Error('Public key hex string cannot be empty');
|
||||
}
|
||||
return PublicKey.create(Buffer.from(hex, 'hex'));
|
||||
}
|
||||
|
||||
static fromBase64(base64: string): PublicKey {
|
||||
if (!base64 || base64.length === 0) {
|
||||
throw new Error('Public key base64 string cannot be empty');
|
||||
}
|
||||
return PublicKey.create(Buffer.from(base64, 'base64'));
|
||||
}
|
||||
|
||||
toHex(): string {
|
||||
return this._keyBytes.toString('hex');
|
||||
}
|
||||
|
||||
toBase64(): string {
|
||||
return this._keyBytes.toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the public key is in compressed format
|
||||
*/
|
||||
isCompressed(): boolean {
|
||||
return this._keyBytes.length === 33;
|
||||
}
|
||||
|
||||
equals(other: PublicKey): boolean {
|
||||
return this._keyBytes.equals(other._keyBytes);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.toHex();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Signature - ECDSA signature
|
||||
// ============================================================================
|
||||
export class Signature {
|
||||
private readonly _r: Buffer;
|
||||
private readonly _s: Buffer;
|
||||
private readonly _v?: number; // Recovery parameter (optional)
|
||||
|
||||
private constructor(r: Buffer, s: Buffer, v?: number) {
|
||||
this._r = r;
|
||||
this._s = s;
|
||||
this._v = v;
|
||||
}
|
||||
|
||||
get r(): Buffer {
|
||||
return Buffer.from(this._r);
|
||||
}
|
||||
|
||||
get s(): Buffer {
|
||||
return Buffer.from(this._s);
|
||||
}
|
||||
|
||||
get v(): number | undefined {
|
||||
return this._v;
|
||||
}
|
||||
|
||||
static create(r: Buffer, s: Buffer, v?: number): Signature {
|
||||
if (!r || r.length !== 32) {
|
||||
throw new Error('r must be 32 bytes');
|
||||
}
|
||||
if (!s || s.length !== 32) {
|
||||
throw new Error('s must be 32 bytes');
|
||||
}
|
||||
if (v !== undefined && (v < 0 || v > 1)) {
|
||||
throw new Error('v must be 0 or 1');
|
||||
}
|
||||
return new Signature(r, s, v);
|
||||
}
|
||||
|
||||
static fromHex(hex: string): Signature {
|
||||
const buffer = Buffer.from(hex.replace('0x', ''), 'hex');
|
||||
if (buffer.length === 64) {
|
||||
return new Signature(buffer.subarray(0, 32), buffer.subarray(32, 64));
|
||||
} else if (buffer.length === 65) {
|
||||
return new Signature(
|
||||
buffer.subarray(0, 32),
|
||||
buffer.subarray(32, 64),
|
||||
buffer[64],
|
||||
);
|
||||
}
|
||||
throw new Error('Invalid signature length');
|
||||
}
|
||||
|
||||
toHex(): string {
|
||||
if (this._v !== undefined) {
|
||||
return Buffer.concat([this._r, this._s, Buffer.from([this._v])]).toString('hex');
|
||||
}
|
||||
return Buffer.concat([this._r, this._s]).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to DER format
|
||||
*/
|
||||
toDER(): Buffer {
|
||||
const rLen = this._r[0] >= 0x80 ? 33 : 32;
|
||||
const sLen = this._s[0] >= 0x80 ? 33 : 32;
|
||||
const totalLen = rLen + sLen + 4;
|
||||
|
||||
const der = Buffer.alloc(2 + totalLen);
|
||||
let offset = 0;
|
||||
|
||||
der[offset++] = 0x30; // SEQUENCE
|
||||
der[offset++] = totalLen;
|
||||
der[offset++] = 0x02; // INTEGER (r)
|
||||
der[offset++] = rLen;
|
||||
if (rLen === 33) der[offset++] = 0x00;
|
||||
this._r.copy(der, offset);
|
||||
offset += 32;
|
||||
der[offset++] = 0x02; // INTEGER (s)
|
||||
der[offset++] = sLen;
|
||||
if (sLen === 33) der[offset++] = 0x00;
|
||||
this._s.copy(der, offset);
|
||||
|
||||
return der;
|
||||
}
|
||||
|
||||
equals(other: Signature): boolean {
|
||||
return this._r.equals(other._r) && this._s.equals(other._s) && this._v === other._v;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.toHex();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MessageHash - Hash of message to be signed
|
||||
// ============================================================================
|
||||
export class MessageHash {
|
||||
private readonly _hash: Buffer;
|
||||
|
||||
private constructor(hash: Buffer) {
|
||||
this._hash = hash;
|
||||
}
|
||||
|
||||
get bytes(): Buffer {
|
||||
return Buffer.from(this._hash);
|
||||
}
|
||||
|
||||
static create(hash: Buffer): MessageHash {
|
||||
if (!hash || hash.length !== 32) {
|
||||
throw new Error('Message hash must be 32 bytes');
|
||||
}
|
||||
return new MessageHash(hash);
|
||||
}
|
||||
|
||||
static fromHex(hex: string): MessageHash {
|
||||
const cleanHex = hex.replace('0x', '');
|
||||
if (cleanHex.length !== 64) {
|
||||
throw new Error('Message hash must be 32 bytes (64 hex characters)');
|
||||
}
|
||||
return MessageHash.create(Buffer.from(cleanHex, 'hex'));
|
||||
}
|
||||
|
||||
toHex(): string {
|
||||
return '0x' + this._hash.toString('hex');
|
||||
}
|
||||
|
||||
equals(other: MessageHash): boolean {
|
||||
return this._hash.equals(other._hash);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.toHex();
|
||||
}
|
||||
}
|
||||
export { SessionId } from './session-id.vo';
|
||||
export { PartyId } from './party-id.vo';
|
||||
export { ShareId } from './share-id.vo';
|
||||
export { Threshold } from './threshold.vo';
|
||||
export { ShareData } from './share-data.vo';
|
||||
export { PublicKey } from './public-key.vo';
|
||||
export { Signature } from './signature.vo';
|
||||
export { MessageHash } from './message-hash.vo';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* MessageHash Value Object
|
||||
*
|
||||
* Hash of message to be signed.
|
||||
*/
|
||||
|
||||
export class MessageHash {
|
||||
private readonly _hash: Buffer;
|
||||
|
||||
private constructor(hash: Buffer) {
|
||||
this._hash = hash;
|
||||
}
|
||||
|
||||
get bytes(): Buffer {
|
||||
return Buffer.from(this._hash);
|
||||
}
|
||||
|
||||
static create(hash: Buffer): MessageHash {
|
||||
if (!hash || hash.length !== 32) {
|
||||
throw new Error('Message hash must be 32 bytes');
|
||||
}
|
||||
return new MessageHash(hash);
|
||||
}
|
||||
|
||||
static fromHex(hex: string): MessageHash {
|
||||
const cleanHex = hex.replace('0x', '');
|
||||
if (cleanHex.length !== 64) {
|
||||
throw new Error('Message hash must be 32 bytes (64 hex characters)');
|
||||
}
|
||||
return MessageHash.create(Buffer.from(cleanHex, 'hex'));
|
||||
}
|
||||
|
||||
toHex(): string {
|
||||
return '0x' + this._hash.toString('hex');
|
||||
}
|
||||
|
||||
equals(other: MessageHash): boolean {
|
||||
return this._hash.equals(other._hash);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.toHex();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* PartyId Value Object
|
||||
*
|
||||
* Identifier for MPC party (format: {userId}-{type})
|
||||
*/
|
||||
|
||||
export class PartyId {
|
||||
private readonly _value: string;
|
||||
|
||||
private constructor(value: string) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
static create(value: string): PartyId {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error('PartyId cannot be empty');
|
||||
}
|
||||
// Format: {userId}-{type} e.g., user123-server
|
||||
const partyIdRegex = /^[\w]+-[\w]+$/;
|
||||
if (!partyIdRegex.test(value)) {
|
||||
throw new Error('Invalid PartyId format. Expected: {identifier}-{type}');
|
||||
}
|
||||
return new PartyId(value);
|
||||
}
|
||||
|
||||
static fromComponents(identifier: string, type: string): PartyId {
|
||||
return PartyId.create(`${identifier}-${type}`);
|
||||
}
|
||||
|
||||
equals(other: PartyId): boolean {
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
getIdentifier(): string {
|
||||
const parts = this._value.split('-');
|
||||
return parts.slice(0, -1).join('-');
|
||||
}
|
||||
|
||||
getType(): string {
|
||||
return this._value.split('-').pop() || '';
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this._value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* PublicKey Value Object
|
||||
*
|
||||
* ECDSA public key for MPC group.
|
||||
*/
|
||||
|
||||
export class PublicKey {
|
||||
private readonly _keyBytes: Buffer;
|
||||
|
||||
private constructor(keyBytes: Buffer) {
|
||||
this._keyBytes = keyBytes;
|
||||
}
|
||||
|
||||
get bytes(): Buffer {
|
||||
return Buffer.from(this._keyBytes);
|
||||
}
|
||||
|
||||
static create(keyBytes: Buffer): PublicKey {
|
||||
if (!keyBytes || keyBytes.length === 0) {
|
||||
throw new Error('Public key cannot be empty');
|
||||
}
|
||||
// ECDSA public key: 33 bytes (compressed) or 65 bytes (uncompressed)
|
||||
if (keyBytes.length !== 33 && keyBytes.length !== 65) {
|
||||
throw new Error('Invalid public key length. Expected 33 (compressed) or 65 (uncompressed) bytes');
|
||||
}
|
||||
return new PublicKey(keyBytes);
|
||||
}
|
||||
|
||||
static fromHex(hex: string): PublicKey {
|
||||
if (!hex || hex.length === 0) {
|
||||
throw new Error('Public key hex string cannot be empty');
|
||||
}
|
||||
return PublicKey.create(Buffer.from(hex, 'hex'));
|
||||
}
|
||||
|
||||
static fromBase64(base64: string): PublicKey {
|
||||
if (!base64 || base64.length === 0) {
|
||||
throw new Error('Public key base64 string cannot be empty');
|
||||
}
|
||||
return PublicKey.create(Buffer.from(base64, 'base64'));
|
||||
}
|
||||
|
||||
toHex(): string {
|
||||
return this._keyBytes.toString('hex');
|
||||
}
|
||||
|
||||
toBase64(): string {
|
||||
return this._keyBytes.toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the public key is in compressed format
|
||||
*/
|
||||
isCompressed(): boolean {
|
||||
return this._keyBytes.length === 33;
|
||||
}
|
||||
|
||||
equals(other: PublicKey): boolean {
|
||||
return this._keyBytes.equals(other._keyBytes);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.toHex();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* SessionId Value Object
|
||||
*
|
||||
* Unique identifier for MPC sessions.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export class SessionId {
|
||||
private readonly _value: string;
|
||||
|
||||
private constructor(value: string) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
static create(value: string): SessionId {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error('SessionId cannot be empty');
|
||||
}
|
||||
// UUID format validation
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(value)) {
|
||||
throw new Error('Invalid SessionId format. Expected UUID format.');
|
||||
}
|
||||
return new SessionId(value);
|
||||
}
|
||||
|
||||
static generate(): SessionId {
|
||||
return new SessionId(uuidv4());
|
||||
}
|
||||
|
||||
equals(other: SessionId): boolean {
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this._value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* ShareData Value Object
|
||||
*
|
||||
* Encrypted share data with AES-GCM parameters.
|
||||
*/
|
||||
|
||||
export class ShareData {
|
||||
private readonly _encryptedData: Buffer;
|
||||
private readonly _iv: Buffer; // Initialization vector
|
||||
private readonly _authTag: Buffer; // Authentication tag (AES-GCM)
|
||||
|
||||
private constructor(encryptedData: Buffer, iv: Buffer, authTag: Buffer) {
|
||||
this._encryptedData = encryptedData;
|
||||
this._iv = iv;
|
||||
this._authTag = authTag;
|
||||
}
|
||||
|
||||
get encryptedData(): Buffer {
|
||||
return this._encryptedData;
|
||||
}
|
||||
|
||||
get iv(): Buffer {
|
||||
return this._iv;
|
||||
}
|
||||
|
||||
get authTag(): Buffer {
|
||||
return this._authTag;
|
||||
}
|
||||
|
||||
static create(encryptedData: Buffer, iv: Buffer, authTag: Buffer): ShareData {
|
||||
if (!encryptedData || encryptedData.length === 0) {
|
||||
throw new Error('Encrypted data cannot be empty');
|
||||
}
|
||||
if (!iv || iv.length !== 12) {
|
||||
throw new Error('IV must be 12 bytes for AES-GCM');
|
||||
}
|
||||
if (!authTag || authTag.length !== 16) {
|
||||
throw new Error('AuthTag must be 16 bytes for AES-GCM');
|
||||
}
|
||||
return new ShareData(encryptedData, iv, authTag);
|
||||
}
|
||||
|
||||
toJSON(): { data: string; iv: string; authTag: string } {
|
||||
return {
|
||||
data: this._encryptedData.toString('base64'),
|
||||
iv: this._iv.toString('base64'),
|
||||
authTag: this._authTag.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON(json: { data: string; iv: string; authTag: string }): ShareData {
|
||||
return new ShareData(
|
||||
Buffer.from(json.data, 'base64'),
|
||||
Buffer.from(json.iv, 'base64'),
|
||||
Buffer.from(json.authTag, 'base64'),
|
||||
);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return JSON.stringify(this.toJSON());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* ShareId Value Object
|
||||
*
|
||||
* Unique identifier for party shares.
|
||||
*/
|
||||
|
||||
export class ShareId {
|
||||
private readonly _value: string;
|
||||
|
||||
private constructor(value: string) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
static create(value: string): ShareId {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error('ShareId cannot be empty');
|
||||
}
|
||||
// Format: share_{timestamp}_{random}
|
||||
const shareIdRegex = /^share_\d+_[a-z0-9]+$/;
|
||||
if (!shareIdRegex.test(value)) {
|
||||
throw new Error('Invalid ShareId format');
|
||||
}
|
||||
return new ShareId(value);
|
||||
}
|
||||
|
||||
static generate(): ShareId {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 11);
|
||||
return new ShareId(`share_${timestamp}_${random}`);
|
||||
}
|
||||
|
||||
equals(other: ShareId): boolean {
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this._value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Signature Value Object
|
||||
*
|
||||
* ECDSA signature.
|
||||
*/
|
||||
|
||||
export class Signature {
|
||||
private readonly _r: Buffer;
|
||||
private readonly _s: Buffer;
|
||||
private readonly _v?: number; // Recovery parameter (optional)
|
||||
|
||||
private constructor(r: Buffer, s: Buffer, v?: number) {
|
||||
this._r = r;
|
||||
this._s = s;
|
||||
this._v = v;
|
||||
}
|
||||
|
||||
get r(): Buffer {
|
||||
return Buffer.from(this._r);
|
||||
}
|
||||
|
||||
get s(): Buffer {
|
||||
return Buffer.from(this._s);
|
||||
}
|
||||
|
||||
get v(): number | undefined {
|
||||
return this._v;
|
||||
}
|
||||
|
||||
static create(r: Buffer, s: Buffer, v?: number): Signature {
|
||||
if (!r || r.length !== 32) {
|
||||
throw new Error('r must be 32 bytes');
|
||||
}
|
||||
if (!s || s.length !== 32) {
|
||||
throw new Error('s must be 32 bytes');
|
||||
}
|
||||
if (v !== undefined && (v < 0 || v > 1)) {
|
||||
throw new Error('v must be 0 or 1');
|
||||
}
|
||||
return new Signature(r, s, v);
|
||||
}
|
||||
|
||||
static fromHex(hex: string): Signature {
|
||||
const buffer = Buffer.from(hex.replace('0x', ''), 'hex');
|
||||
if (buffer.length === 64) {
|
||||
return new Signature(buffer.subarray(0, 32), buffer.subarray(32, 64));
|
||||
} else if (buffer.length === 65) {
|
||||
return new Signature(
|
||||
buffer.subarray(0, 32),
|
||||
buffer.subarray(32, 64),
|
||||
buffer[64],
|
||||
);
|
||||
}
|
||||
throw new Error('Invalid signature length');
|
||||
}
|
||||
|
||||
toHex(): string {
|
||||
if (this._v !== undefined) {
|
||||
return Buffer.concat([this._r, this._s, Buffer.from([this._v])]).toString('hex');
|
||||
}
|
||||
return Buffer.concat([this._r, this._s]).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to DER format
|
||||
*/
|
||||
toDER(): Buffer {
|
||||
const rLen = this._r[0] >= 0x80 ? 33 : 32;
|
||||
const sLen = this._s[0] >= 0x80 ? 33 : 32;
|
||||
const totalLen = rLen + sLen + 4;
|
||||
|
||||
const der = Buffer.alloc(2 + totalLen);
|
||||
let offset = 0;
|
||||
|
||||
der[offset++] = 0x30; // SEQUENCE
|
||||
der[offset++] = totalLen;
|
||||
der[offset++] = 0x02; // INTEGER (r)
|
||||
der[offset++] = rLen;
|
||||
if (rLen === 33) der[offset++] = 0x00;
|
||||
this._r.copy(der, offset);
|
||||
offset += 32;
|
||||
der[offset++] = 0x02; // INTEGER (s)
|
||||
der[offset++] = sLen;
|
||||
if (sLen === 33) der[offset++] = 0x00;
|
||||
this._s.copy(der, offset);
|
||||
|
||||
return der;
|
||||
}
|
||||
|
||||
equals(other: Signature): boolean {
|
||||
return this._r.equals(other._r) && this._s.equals(other._s) && this._v === other._v;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.toHex();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Threshold Value Object
|
||||
*
|
||||
* MPC threshold configuration (t-of-n)
|
||||
*/
|
||||
|
||||
export class Threshold {
|
||||
private readonly _n: number; // Total number of parties
|
||||
private readonly _t: number; // Minimum required signers
|
||||
|
||||
private constructor(n: number, t: number) {
|
||||
this._n = n;
|
||||
this._t = t;
|
||||
}
|
||||
|
||||
get n(): number {
|
||||
return this._n;
|
||||
}
|
||||
|
||||
get t(): number {
|
||||
return this._t;
|
||||
}
|
||||
|
||||
static create(n: number, t: number): Threshold {
|
||||
if (!Number.isInteger(n) || !Number.isInteger(t)) {
|
||||
throw new Error('Threshold values must be integers');
|
||||
}
|
||||
if (n <= 0 || t <= 0) {
|
||||
throw new Error('Threshold values must be positive');
|
||||
}
|
||||
if (t > n) {
|
||||
throw new Error('t cannot exceed n');
|
||||
}
|
||||
if (t < 2) {
|
||||
throw new Error('t must be at least 2 for security');
|
||||
}
|
||||
return new Threshold(n, t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Common threshold configurations
|
||||
*/
|
||||
static twoOfThree(): Threshold {
|
||||
return new Threshold(3, 2);
|
||||
}
|
||||
|
||||
static threeOfFive(): Threshold {
|
||||
return new Threshold(5, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if participant count meets threshold requirements
|
||||
*/
|
||||
validateParticipants(participantsCount: number): boolean {
|
||||
return participantsCount >= this._t && participantsCount <= this._n;
|
||||
}
|
||||
|
||||
equals(other: Threshold): boolean {
|
||||
return this._n === other._n && this._t === other._t;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this._t}-of-${this._n}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
/**
|
||||
* MPC Coordinator Client
|
||||
*
|
||||
* Client for communicating with the external MPC Session Coordinator.
|
||||
*
|
||||
* Flow:
|
||||
* 1. CreateSession - Creates a new MPC session and returns a JWT joinToken
|
||||
* 2. JoinSession - Parties join using the JWT token
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosInstance, AxiosError } from 'axios';
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
sessionType: 'keygen' | 'sign';
|
||||
thresholdN: number;
|
||||
thresholdT: number;
|
||||
createdBy: string;
|
||||
messageHash?: string;
|
||||
expiresIn?: number; // seconds
|
||||
}
|
||||
|
||||
export interface CreateSessionResponse {
|
||||
sessionId: string;
|
||||
joinToken: string; // JWT token for joining
|
||||
status: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export interface JoinSessionRequest {
|
||||
sessionId: string;
|
||||
partyId: string;
|
||||
joinToken: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
sessionId: string;
|
||||
sessionType: 'keygen' | 'sign' | 'refresh';
|
||||
thresholdN: number;
|
||||
thresholdT: number;
|
||||
participants: Array<{ partyId: string; partyIndex: number }>;
|
||||
publicKey?: string;
|
||||
messageHash?: string;
|
||||
joinToken?: string; // JWT token for this session
|
||||
}
|
||||
|
||||
export interface ReportCompletionRequest {
|
||||
sessionId: string;
|
||||
partyId: string;
|
||||
publicKey?: string;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface SessionStatus {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
completedParties: string[];
|
||||
failedParties: string[];
|
||||
result?: {
|
||||
publicKey?: string;
|
||||
signature?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MPCCoordinatorClient implements OnModuleInit {
|
||||
private readonly logger = new Logger(MPCCoordinatorClient.name);
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const baseURL = this.configService.get<string>('MPC_COORDINATOR_URL');
|
||||
if (!baseURL) {
|
||||
this.logger.warn('MPC_COORDINATOR_URL not configured');
|
||||
}
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL,
|
||||
timeout: this.configService.get<number>('MPC_COORDINATOR_TIMEOUT', 30000),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add request interceptor for logging
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
this.logger.debug(`Request: ${config.method?.toUpperCase()} ${config.url}`);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
this.logger.error('Request error', error);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// Add response interceptor for logging
|
||||
this.client.interceptors.response.use(
|
||||
(response) => {
|
||||
this.logger.debug(`Response: ${response.status} ${response.config.url}`);
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
this.logger.error(`Response error: ${error.response?.status} ${error.config?.url}`);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an MPC session
|
||||
*
|
||||
* This must be called first to get a valid JWT joinToken
|
||||
*/
|
||||
async createSession(request: CreateSessionRequest): Promise<CreateSessionResponse> {
|
||||
this.logger.log(`Creating session: type=${request.sessionType}, ${request.thresholdT}-of-${request.thresholdN}`);
|
||||
|
||||
try {
|
||||
const response = await this.client.post('/api/v1/sessions', {
|
||||
sessionType: request.sessionType,
|
||||
thresholdN: request.thresholdN,
|
||||
thresholdT: request.thresholdT,
|
||||
createdBy: request.createdBy,
|
||||
messageHash: request.messageHash,
|
||||
expiresIn: request.expiresIn || 600, // Default 10 minutes
|
||||
});
|
||||
|
||||
this.logger.log(`Session created: ${response.data.sessionId}`);
|
||||
|
||||
return {
|
||||
sessionId: response.data.sessionId,
|
||||
joinToken: response.data.joinToken,
|
||||
status: response.data.status,
|
||||
expiresAt: response.data.expiresAt,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = this.getErrorMessage(error);
|
||||
this.logger.error(`Failed to create session: ${message}`);
|
||||
throw new Error(`Failed to create MPC session: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join an MPC session using a JWT token
|
||||
*
|
||||
* The joinToken must be obtained from createSession()
|
||||
*/
|
||||
async joinSession(request: JoinSessionRequest): Promise<SessionInfo> {
|
||||
this.logger.log(`Joining session: ${request.sessionId}`);
|
||||
|
||||
try {
|
||||
const response = await this.client.post('/api/v1/sessions/join', {
|
||||
joinToken: request.joinToken,
|
||||
partyId: request.partyId,
|
||||
deviceType: 'server', // Required field
|
||||
deviceId: 'mpc-service',
|
||||
});
|
||||
|
||||
// Response format: { sessionId, partyIndex, status, participants }
|
||||
return {
|
||||
sessionId: response.data.sessionId,
|
||||
sessionType: 'keygen', // JoinByToken doesn't return session type, assume keygen
|
||||
thresholdN: 3, // Default 2-of-3
|
||||
thresholdT: 2,
|
||||
participants: response.data.participants?.map((p: any) => ({
|
||||
partyId: p.partyId,
|
||||
partyIndex: p.partyIndex || 0,
|
||||
})) || [],
|
||||
publicKey: undefined,
|
||||
messageHash: undefined,
|
||||
joinToken: request.joinToken,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = this.getErrorMessage(error);
|
||||
this.logger.error(`Failed to join session: ${message}`);
|
||||
throw new Error(`Failed to join MPC session: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report session completion
|
||||
*/
|
||||
async reportCompletion(request: ReportCompletionRequest): Promise<void> {
|
||||
this.logger.log(`Reporting completion for session: ${request.sessionId}`);
|
||||
|
||||
try {
|
||||
await this.client.post('/api/v1/sessions/report-completion', {
|
||||
session_id: request.sessionId,
|
||||
party_id: request.partyId,
|
||||
public_key: request.publicKey,
|
||||
signature: request.signature,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = this.getErrorMessage(error);
|
||||
this.logger.error(`Failed to report completion: ${message}`);
|
||||
throw new Error(`Failed to report completion: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session status
|
||||
*/
|
||||
async getSessionStatus(sessionId: string): Promise<SessionStatus> {
|
||||
this.logger.log(`Getting status for session: ${sessionId}`);
|
||||
|
||||
try {
|
||||
const response = await this.client.get(`/api/v1/sessions/${sessionId}/status`);
|
||||
|
||||
return {
|
||||
sessionId: response.data.session_id,
|
||||
status: response.data.status,
|
||||
completedParties: response.data.completed_parties || [],
|
||||
failedParties: response.data.failed_parties || [],
|
||||
result: response.data.result,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = this.getErrorMessage(error);
|
||||
this.logger.error(`Failed to get session status: ${message}`);
|
||||
throw new Error(`Failed to get session status: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report session failure
|
||||
*/
|
||||
async reportFailure(sessionId: string, partyId: string, errorMessage: string): Promise<void> {
|
||||
this.logger.log(`Reporting failure for session: ${sessionId}`);
|
||||
|
||||
try {
|
||||
await this.client.post('/api/v1/sessions/report-failure', {
|
||||
session_id: sessionId,
|
||||
party_id: partyId,
|
||||
error_message: errorMessage,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = this.getErrorMessage(error);
|
||||
this.logger.error(`Failed to report failure: ${message}`);
|
||||
// Don't throw - failure reporting is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
private getErrorMessage(error: unknown): string {
|
||||
if (axios.isAxiosError(error)) {
|
||||
return error.response?.data?.message || error.message;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Unknown error';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './coordinator-client';
|
||||
export * from './message-router-client';
|
||||
|
|
@ -1,449 +0,0 @@
|
|||
/**
|
||||
* MPC Message Router Client
|
||||
*
|
||||
* Hybrid client for message exchange between MPC parties.
|
||||
* Strategy: Try WebSocket first, fallback to HTTP polling if WebSocket fails.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export interface MPCMessage {
|
||||
fromParty: string;
|
||||
toParties?: string[];
|
||||
roundNumber: number;
|
||||
payload: Buffer;
|
||||
}
|
||||
|
||||
export interface SendMessageRequest {
|
||||
sessionId: string;
|
||||
fromParty: string;
|
||||
toParties?: string[];
|
||||
roundNumber: number;
|
||||
payload: Buffer;
|
||||
}
|
||||
|
||||
export interface MessageStream {
|
||||
next(): Promise<{ value: MPCMessage; done: false } | { done: true; value: undefined }>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
type TransportMode = 'websocket' | 'http';
|
||||
|
||||
interface ConnectionState {
|
||||
sessionId: string;
|
||||
partyId: string;
|
||||
mode: TransportMode;
|
||||
closed: boolean;
|
||||
ws?: WebSocket;
|
||||
lastPollTime?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MPCMessageRouterClient implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(MPCMessageRouterClient.name);
|
||||
private wsUrl: string;
|
||||
private httpUrl: string;
|
||||
private connections: Map<string, ConnectionState> = new Map();
|
||||
|
||||
// Configuration
|
||||
private readonly WS_CONNECT_TIMEOUT_MS = 5000; // 5 seconds to establish WebSocket
|
||||
private readonly POLL_INTERVAL_MS = 100; // Poll every 100ms (HTTP fallback)
|
||||
private readonly POLL_TIMEOUT_MS = 300000; // 5 minute total timeout
|
||||
private readonly REQUEST_TIMEOUT_MS = 10000; // 10 second per-request timeout
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.wsUrl = this.configService.get<string>('MPC_MESSAGE_ROUTER_WS_URL') || '';
|
||||
this.httpUrl = this.wsUrl.replace('ws://', 'http://').replace('wss://', 'https://');
|
||||
|
||||
if (!this.wsUrl) {
|
||||
this.logger.warn('MPC_MESSAGE_ROUTER_WS_URL not configured');
|
||||
} else {
|
||||
this.logger.log(`Message router configured - WS: ${this.wsUrl}, HTTP: ${this.httpUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
for (const [key, state] of this.connections) {
|
||||
this.logger.debug(`Closing connection: ${key}`);
|
||||
state.closed = true;
|
||||
if (state.ws) {
|
||||
state.ws.close();
|
||||
}
|
||||
}
|
||||
this.connections.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to messages - tries WebSocket first, falls back to HTTP polling
|
||||
*/
|
||||
async subscribeMessages(sessionId: string, partyId: string): Promise<MessageStream> {
|
||||
const connectionKey = `${sessionId}:${partyId}`;
|
||||
this.logger.log(`Subscribing to messages: ${connectionKey}`);
|
||||
|
||||
// Try WebSocket first
|
||||
try {
|
||||
const stream = await this.tryWebSocketSubscribe(sessionId, partyId);
|
||||
this.logger.log(`WebSocket connection established for ${connectionKey}`);
|
||||
return stream;
|
||||
} catch (wsError) {
|
||||
this.logger.warn(`WebSocket failed for ${connectionKey}, falling back to HTTP polling: ${wsError}`);
|
||||
}
|
||||
|
||||
// Fallback to HTTP polling
|
||||
return this.httpPollingSubscribe(sessionId, partyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to establish WebSocket connection with timeout
|
||||
*/
|
||||
private async tryWebSocketSubscribe(sessionId: string, partyId: string): Promise<MessageStream> {
|
||||
const connectionKey = `${sessionId}:${partyId}`;
|
||||
const url = `${this.wsUrl}/sessions/${sessionId}/messages?party_id=${partyId}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
const connectTimeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error('WebSocket connection timeout'));
|
||||
}, this.WS_CONNECT_TIMEOUT_MS);
|
||||
|
||||
const state: ConnectionState = {
|
||||
sessionId,
|
||||
partyId,
|
||||
mode: 'websocket',
|
||||
closed: false,
|
||||
ws,
|
||||
};
|
||||
|
||||
const messageQueue: MPCMessage[] = [];
|
||||
const waiters: Array<{
|
||||
resolve: (value: { value: MPCMessage; done: false } | { done: true; value: undefined }) => void;
|
||||
reject: (error: Error) => void;
|
||||
}> = [];
|
||||
let error: Error | null = null;
|
||||
|
||||
ws.on('open', () => {
|
||||
clearTimeout(connectTimeout);
|
||||
this.connections.set(connectionKey, state);
|
||||
this.logger.debug(`WebSocket connected: ${connectionKey}`);
|
||||
|
||||
resolve({
|
||||
next: () => {
|
||||
return new Promise((res, rej) => {
|
||||
if (error) {
|
||||
rej(error);
|
||||
return;
|
||||
}
|
||||
if (messageQueue.length > 0) {
|
||||
res({ value: messageQueue.shift()!, done: false });
|
||||
return;
|
||||
}
|
||||
if (state.closed) {
|
||||
res({ done: true, value: undefined });
|
||||
return;
|
||||
}
|
||||
waiters.push({ resolve: res, reject: rej });
|
||||
});
|
||||
},
|
||||
close: () => {
|
||||
state.closed = true;
|
||||
ws.close();
|
||||
this.connections.delete(connectionKey);
|
||||
this.logger.debug(`WebSocket closed: ${connectionKey}`);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('message', (data: Buffer) => {
|
||||
try {
|
||||
const parsed = JSON.parse(data.toString());
|
||||
const message: MPCMessage = {
|
||||
fromParty: parsed.from_party,
|
||||
toParties: parsed.to_parties,
|
||||
roundNumber: parsed.round_number,
|
||||
payload: Buffer.from(parsed.payload, 'base64'),
|
||||
};
|
||||
|
||||
if (waiters.length > 0) {
|
||||
const waiter = waiters.shift()!;
|
||||
waiter.resolve({ value: message, done: false });
|
||||
} else {
|
||||
messageQueue.push(message);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to parse WebSocket message', err);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
clearTimeout(connectTimeout);
|
||||
this.logger.error(`WebSocket error: ${connectionKey}`, err);
|
||||
error = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
// If not yet connected, reject the promise
|
||||
if (!this.connections.has(connectionKey)) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject all waiting consumers
|
||||
while (waiters.length > 0) {
|
||||
const waiter = waiters.shift()!;
|
||||
waiter.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
clearTimeout(connectTimeout);
|
||||
this.logger.debug(`WebSocket closed: ${connectionKey}`);
|
||||
state.closed = true;
|
||||
this.connections.delete(connectionKey);
|
||||
|
||||
// If not yet connected, reject
|
||||
if (!this.connections.has(connectionKey) && !error) {
|
||||
reject(new Error('WebSocket closed before connection established'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve all waiting consumers with done
|
||||
while (waiters.length > 0) {
|
||||
const waiter = waiters.shift()!;
|
||||
waiter.resolve({ done: true, value: undefined });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP polling fallback
|
||||
*/
|
||||
private async httpPollingSubscribe(sessionId: string, partyId: string): Promise<MessageStream> {
|
||||
const connectionKey = `${sessionId}:${partyId}`;
|
||||
this.logger.log(`Starting HTTP polling for ${connectionKey}`);
|
||||
|
||||
const state: ConnectionState = {
|
||||
sessionId,
|
||||
partyId,
|
||||
mode: 'http',
|
||||
closed: false,
|
||||
lastPollTime: 0,
|
||||
};
|
||||
this.connections.set(connectionKey, state);
|
||||
|
||||
const messageQueue: MPCMessage[] = [];
|
||||
let error: Error | null = null;
|
||||
const startTime = Date.now();
|
||||
|
||||
// Background polling
|
||||
const pollLoop = async () => {
|
||||
while (!state.closed) {
|
||||
if (Date.now() - startTime > this.POLL_TIMEOUT_MS) {
|
||||
this.logger.warn(`HTTP polling timeout for ${connectionKey}`);
|
||||
state.closed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = await this.fetchPendingMessages(sessionId, partyId);
|
||||
for (const msg of messages) {
|
||||
messageQueue.push(msg);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.debug(`HTTP poll error for ${connectionKey}: ${err}`);
|
||||
}
|
||||
|
||||
if (!state.closed) {
|
||||
await this.sleep(this.POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pollLoop().catch((err) => {
|
||||
this.logger.error(`HTTP polling failed for ${connectionKey}`, err);
|
||||
error = err instanceof Error ? err : new Error(String(err));
|
||||
});
|
||||
|
||||
return {
|
||||
next: async () => {
|
||||
const waitStart = Date.now();
|
||||
const maxWait = 30000;
|
||||
|
||||
while (Date.now() - waitStart < maxWait) {
|
||||
if (error) throw error;
|
||||
|
||||
if (messageQueue.length > 0) {
|
||||
return { value: messageQueue.shift()!, done: false as const };
|
||||
}
|
||||
|
||||
if (state.closed && messageQueue.length === 0) {
|
||||
return { done: true as const, value: undefined };
|
||||
}
|
||||
|
||||
await this.sleep(50);
|
||||
}
|
||||
|
||||
if (state.closed) {
|
||||
return { done: true as const, value: undefined };
|
||||
}
|
||||
|
||||
// No messages received in time, but not closed - keep waiting
|
||||
return { done: true as const, value: undefined };
|
||||
},
|
||||
close: () => {
|
||||
state.closed = true;
|
||||
this.connections.delete(connectionKey);
|
||||
this.logger.debug(`HTTP polling stopped: ${connectionKey}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch pending messages via HTTP
|
||||
*/
|
||||
private async fetchPendingMessages(sessionId: string, partyId: string): Promise<MPCMessage[]> {
|
||||
const url = `${this.httpUrl}/api/v1/messages/pending?session_id=${sessionId}&party_id=${partyId}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const messages: MPCMessage[] = [];
|
||||
|
||||
if (data.messages && Array.isArray(data.messages)) {
|
||||
for (const msg of data.messages) {
|
||||
messages.push({
|
||||
fromParty: msg.from_party,
|
||||
toParties: msg.to_parties,
|
||||
roundNumber: msg.round_number,
|
||||
payload: Buffer.from(msg.payload, 'base64'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message - uses WebSocket if connected, otherwise HTTP
|
||||
*/
|
||||
async sendMessage(request: SendMessageRequest): Promise<void> {
|
||||
const connectionKey = `${request.sessionId}:${request.fromParty}`;
|
||||
const state = this.connections.get(connectionKey);
|
||||
|
||||
// Try WebSocket if available
|
||||
if (state?.mode === 'websocket' && state.ws?.readyState === WebSocket.OPEN) {
|
||||
const message = JSON.stringify({
|
||||
from_party: request.fromParty,
|
||||
to_parties: request.toParties,
|
||||
round_number: request.roundNumber,
|
||||
payload: request.payload.toString('base64'),
|
||||
});
|
||||
state.ws.send(message);
|
||||
this.logger.debug(`Message sent via WebSocket: session=${request.sessionId}, round=${request.roundNumber}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to HTTP
|
||||
await this.sendMessageViaHttp(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message via HTTP POST
|
||||
*/
|
||||
private async sendMessageViaHttp(request: SendMessageRequest): Promise<void> {
|
||||
const url = `${this.httpUrl}/api/v1/messages/route`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: request.sessionId,
|
||||
from_party: request.fromParty,
|
||||
to_parties: request.toParties,
|
||||
round_number: request.roundNumber,
|
||||
payload: request.payload.toString('base64'),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
this.logger.debug(`Message sent via HTTP: session=${request.sessionId}, round=${request.roundNumber}`);
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to send message via HTTP', err);
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check connection status
|
||||
*/
|
||||
isConnected(sessionId: string, partyId: string): boolean {
|
||||
const connectionKey = `${sessionId}:${partyId}`;
|
||||
const state = this.connections.get(connectionKey);
|
||||
if (!state || state.closed) return false;
|
||||
|
||||
if (state.mode === 'websocket') {
|
||||
return state.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
return true; // HTTP polling is always "connected" while active
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current transport mode
|
||||
*/
|
||||
getTransportMode(sessionId: string, partyId: string): TransportMode | null {
|
||||
const connectionKey = `${sessionId}:${partyId}`;
|
||||
const state = this.connections.get(connectionKey);
|
||||
return state?.mode ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
disconnect(sessionId: string, partyId: string): void {
|
||||
const connectionKey = `${sessionId}:${partyId}`;
|
||||
const state = this.connections.get(connectionKey);
|
||||
|
||||
if (state) {
|
||||
state.closed = true;
|
||||
if (state.ws) {
|
||||
state.ws.close();
|
||||
}
|
||||
this.connections.delete(connectionKey);
|
||||
this.logger.debug(`Disconnected: ${connectionKey} (mode: ${state.mode})`);
|
||||
}
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './tss-wrapper';
|
||||
|
|
@ -1,400 +0,0 @@
|
|||
/**
|
||||
* TSS-Lib Wrapper
|
||||
*
|
||||
* Wrapper for interacting with the MPC System (mpc-system) deployed on 192.168.1.111.
|
||||
* This implementation calls the mpc-system APIs to coordinate TSS operations.
|
||||
*
|
||||
* Architecture:
|
||||
* - account-service (port 4000): Creates keygen/signing sessions
|
||||
* - session-coordinator (port 8081): Coordinates TSS sessions
|
||||
* - server-party-api (port 8083): Generates user shares (synchronous)
|
||||
* - server-party-1/2/3 (internal): Server TSS participants
|
||||
*
|
||||
* Flow for keygen:
|
||||
* 1. Create session via account-service
|
||||
* 2. Call server-party-api to generate and return user's share
|
||||
* 3. User's share is returned directly (not stored on server)
|
||||
*
|
||||
* Security: User holds their own share, server parties hold their shares.
|
||||
* 2-of-3 threshold: user + any 1 server party can sign.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import {
|
||||
TSSProtocolDomainService,
|
||||
TSSParticipant,
|
||||
TSSConfig,
|
||||
TSSMessage,
|
||||
KeygenResult,
|
||||
SigningResult,
|
||||
} from '../../../domain/services/tss-protocol.domain-service';
|
||||
import {
|
||||
Threshold,
|
||||
PublicKey,
|
||||
Signature,
|
||||
MessageHash,
|
||||
} from '../../../domain/value-objects';
|
||||
import { KeyCurve } from '../../../domain/enums';
|
||||
|
||||
interface CreateKeygenSessionResponse {
|
||||
session_id: string;
|
||||
session_type: string;
|
||||
threshold_n: number;
|
||||
threshold_t: number;
|
||||
join_tokens: Record<string, string>;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface SessionStatusResponse {
|
||||
session_id: string;
|
||||
status: string;
|
||||
completed_parties: number;
|
||||
total_parties: number;
|
||||
public_key?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface GenerateUserShareResponse {
|
||||
success: boolean;
|
||||
session_id: string;
|
||||
party_id: string;
|
||||
party_index: number;
|
||||
share_data: string; // hex encoded
|
||||
public_key: string; // hex encoded
|
||||
}
|
||||
|
||||
interface SignWithUserShareResponse {
|
||||
success: boolean;
|
||||
session_id: string;
|
||||
party_id: string;
|
||||
signature: string;
|
||||
r: string;
|
||||
s: string;
|
||||
v: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TSSWrapper implements TSSProtocolDomainService {
|
||||
private readonly logger = new Logger(TSSWrapper.name);
|
||||
private readonly accountServiceUrl: string;
|
||||
private readonly sessionCoordinatorUrl: string;
|
||||
private readonly serverPartyApiUrl: string;
|
||||
private readonly axiosClient: AxiosInstance;
|
||||
private readonly mpcApiKey: string;
|
||||
private readonly pollIntervalMs = 2000;
|
||||
private readonly maxPollAttempts = 300; // 10 minutes max
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
// MPC System URLs (deployed on 192.168.1.111)
|
||||
this.accountServiceUrl = this.configService.get<string>('MPC_ACCOUNT_SERVICE_URL') || 'http://192.168.1.111:4000';
|
||||
this.sessionCoordinatorUrl = this.configService.get<string>('MPC_SESSION_COORDINATOR_URL') || 'http://192.168.1.111:8081';
|
||||
this.serverPartyApiUrl = this.configService.get<string>('MPC_SERVER_PARTY_API_URL') || 'http://192.168.1.111:8083';
|
||||
this.mpcApiKey = this.configService.get<string>('MPC_API_KEY') || '';
|
||||
|
||||
this.axiosClient = axios.create({
|
||||
timeout: 600000, // 10 minutes for TSS operations
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.mpcApiKey && { 'X-API-Key': this.mpcApiKey }),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`TSSWrapper initialized:`);
|
||||
this.logger.log(` account-service: ${this.accountServiceUrl}`);
|
||||
this.logger.log(` session-coordinator: ${this.sessionCoordinatorUrl}`);
|
||||
this.logger.log(` server-party-api: ${this.serverPartyApiUrl}`);
|
||||
}
|
||||
|
||||
async runKeygen(
|
||||
partyId: string,
|
||||
participants: TSSParticipant[],
|
||||
threshold: Threshold,
|
||||
config: TSSConfig,
|
||||
messageSender: (msg: TSSMessage) => Promise<void>,
|
||||
messageReceiver: AsyncIterable<TSSMessage>,
|
||||
): Promise<KeygenResult> {
|
||||
this.logger.log(`Starting keygen for party: ${partyId}, threshold: ${threshold.t}/${threshold.n}`);
|
||||
|
||||
try {
|
||||
// Step 1: Create keygen session via account-service
|
||||
// This creates the session and notifies server-party-1/2/3 to participate
|
||||
const session = await this.createKeygenSession(participants, threshold);
|
||||
this.logger.log(`Created keygen session: ${session.session_id}`);
|
||||
|
||||
// Step 2: Get the join token for the user's party
|
||||
const userPartyJoinToken = session.join_tokens[partyId];
|
||||
if (!userPartyJoinToken) {
|
||||
throw new Error(`No join token found for party ${partyId}`);
|
||||
}
|
||||
|
||||
// Step 3: Call server-party-api to generate user's share
|
||||
// This is a synchronous call that participates in TSS and returns the share directly
|
||||
this.logger.log(`Calling server-party-api to generate user share...`);
|
||||
const userShareResult = await this.generateUserShare(
|
||||
session.session_id,
|
||||
partyId,
|
||||
userPartyJoinToken,
|
||||
);
|
||||
|
||||
this.logger.log(`Keygen completed successfully, party_index: ${userShareResult.party_index}`);
|
||||
|
||||
// The share_data is hex encoded, convert to Buffer
|
||||
const shareBuffer = Buffer.from(userShareResult.share_data, 'hex');
|
||||
|
||||
return {
|
||||
shareData: shareBuffer,
|
||||
publicKey: userShareResult.public_key,
|
||||
partyIndex: userShareResult.party_index,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Keygen failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async runSigning(
|
||||
partyId: string,
|
||||
participants: TSSParticipant[],
|
||||
shareData: Buffer,
|
||||
messageHash: MessageHash,
|
||||
threshold: Threshold,
|
||||
config: TSSConfig,
|
||||
messageSender: (msg: TSSMessage) => Promise<void>,
|
||||
messageReceiver: AsyncIterable<TSSMessage>,
|
||||
): Promise<SigningResult> {
|
||||
this.logger.log(`Starting signing for party: ${partyId}`);
|
||||
|
||||
try {
|
||||
// Step 1: Create signing session via account-service
|
||||
const sessionResponse = await this.axiosClient.post<{
|
||||
session_id: string;
|
||||
join_tokens: Record<string, string>;
|
||||
status: string;
|
||||
}>(`${this.accountServiceUrl}/api/v1/mpc/sign`, {
|
||||
message_hash: messageHash.toHex().replace('0x', ''),
|
||||
participants: participants.map(p => ({
|
||||
party_id: p.partyId,
|
||||
device_type: 'server',
|
||||
})),
|
||||
});
|
||||
|
||||
const session = sessionResponse.data;
|
||||
this.logger.log(`Created signing session: ${session.session_id}`);
|
||||
|
||||
// Step 2: Get the join token for the user's party
|
||||
const joinToken = session.join_tokens[partyId];
|
||||
if (!joinToken) {
|
||||
throw new Error(`No join token found for party ${partyId}`);
|
||||
}
|
||||
|
||||
// Step 3: Call server-party-api to sign with user's share
|
||||
// This is a synchronous call that participates in TSS signing and returns the signature
|
||||
this.logger.log(`Calling server-party-api to sign with user share...`);
|
||||
const signingResult = await this.signWithUserShare(
|
||||
session.session_id,
|
||||
partyId,
|
||||
joinToken,
|
||||
shareData,
|
||||
messageHash.toHex().replace('0x', ''),
|
||||
);
|
||||
|
||||
this.logger.log('Signing completed successfully');
|
||||
|
||||
return {
|
||||
signature: signingResult.signature,
|
||||
r: signingResult.r,
|
||||
s: signingResult.s,
|
||||
v: signingResult.v,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Signing failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async runKeyRefresh(
|
||||
partyId: string,
|
||||
participants: TSSParticipant[],
|
||||
oldShareData: Buffer,
|
||||
threshold: Threshold,
|
||||
config: TSSConfig,
|
||||
messageSender: (msg: TSSMessage) => Promise<void>,
|
||||
messageReceiver: AsyncIterable<TSSMessage>,
|
||||
): Promise<{ newShareData: Buffer }> {
|
||||
this.logger.log(`Starting key refresh for party: ${partyId}`);
|
||||
|
||||
// Key refresh follows similar pattern to keygen
|
||||
// For now, throw not implemented
|
||||
throw new Error('Key refresh not yet implemented via MPC system API');
|
||||
}
|
||||
|
||||
verifySignature(
|
||||
publicKey: PublicKey,
|
||||
messageHash: MessageHash,
|
||||
signature: Signature,
|
||||
curve: KeyCurve,
|
||||
): boolean {
|
||||
// Verification can be done locally using crypto libraries
|
||||
// For now, return true - implement proper ECDSA verification
|
||||
this.logger.debug('Signature verification requested');
|
||||
return true;
|
||||
}
|
||||
|
||||
async deriveChildKey(shareData: Buffer, derivationPath: string): Promise<Buffer> {
|
||||
this.logger.log(`Deriving child key with path: ${derivationPath}`);
|
||||
|
||||
// Key derivation would need to be done via the MPC system
|
||||
// For now, throw not implemented
|
||||
throw new Error('Child key derivation not yet implemented via MPC system API');
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
/**
|
||||
* Create a keygen session via account-service.
|
||||
* This will also notify server-party-1/2/3 to participate.
|
||||
*
|
||||
* Note: account-service requires exactly threshold_n participants.
|
||||
* For a 2-of-3 setup, we need 3 participants:
|
||||
* - user-party (the user's share, returned to client)
|
||||
* - server-party-1 (stored on server)
|
||||
* - server-party-2 (backup, stored on server)
|
||||
*/
|
||||
private async createKeygenSession(
|
||||
participants: TSSParticipant[],
|
||||
threshold: Threshold,
|
||||
): Promise<CreateKeygenSessionResponse> {
|
||||
// Build the full participant list for threshold_n parties
|
||||
// If we have fewer participants, add the default server parties
|
||||
const allParticipants: Array<{ party_id: string; device_type: string }> = [];
|
||||
|
||||
// Add provided participants
|
||||
for (const p of participants) {
|
||||
allParticipants.push({
|
||||
party_id: p.partyId,
|
||||
device_type: 'server',
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure we have exactly threshold_n participants
|
||||
// Default server party IDs for 2-of-3 setup
|
||||
const defaultPartyIds = ['user-party', 'server-party-1', 'server-party-2'];
|
||||
const existingPartyIds = new Set(allParticipants.map(p => p.party_id));
|
||||
|
||||
for (const partyId of defaultPartyIds) {
|
||||
if (!existingPartyIds.has(partyId) && allParticipants.length < threshold.n) {
|
||||
allParticipants.push({
|
||||
party_id: partyId,
|
||||
device_type: partyId === 'user-party' ? 'client' : 'server',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Creating keygen session with ${allParticipants.length} participants: ${allParticipants.map(p => p.party_id).join(', ')}`);
|
||||
|
||||
const response = await this.axiosClient.post<CreateKeygenSessionResponse>(
|
||||
`${this.accountServiceUrl}/api/v1/mpc/keygen`,
|
||||
{
|
||||
threshold_n: threshold.n,
|
||||
threshold_t: threshold.t,
|
||||
participants: allParticipants,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate user's share via server-party-api.
|
||||
* This is a synchronous call that:
|
||||
* 1. Joins the TSS session
|
||||
* 2. Participates in keygen protocol
|
||||
* 3. Returns the generated share directly (not stored on server)
|
||||
*/
|
||||
private async generateUserShare(
|
||||
sessionId: string,
|
||||
partyId: string,
|
||||
joinToken: string,
|
||||
): Promise<GenerateUserShareResponse> {
|
||||
const response = await this.axiosClient.post<GenerateUserShareResponse>(
|
||||
`${this.serverPartyApiUrl}/api/v1/keygen/generate-user-share`,
|
||||
{
|
||||
session_id: sessionId,
|
||||
party_id: partyId,
|
||||
join_token: joinToken,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(`Failed to generate user share: ${JSON.stringify(response.data)}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign with user's share via server-party-api.
|
||||
* This is a synchronous call that:
|
||||
* 1. Joins the signing session
|
||||
* 2. Participates in signing protocol with user's share
|
||||
* 3. Returns the signature directly
|
||||
*/
|
||||
private async signWithUserShare(
|
||||
sessionId: string,
|
||||
partyId: string,
|
||||
joinToken: string,
|
||||
shareData: Buffer,
|
||||
messageHash: string,
|
||||
): Promise<SignWithUserShareResponse> {
|
||||
const response = await this.axiosClient.post<SignWithUserShareResponse>(
|
||||
`${this.serverPartyApiUrl}/api/v1/sign/with-user-share`,
|
||||
{
|
||||
session_id: sessionId,
|
||||
party_id: partyId,
|
||||
join_token: joinToken,
|
||||
share_data: shareData.toString('hex'),
|
||||
message_hash: messageHash,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(`Failed to sign with user share: ${JSON.stringify(response.data)}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll session status until complete or failed.
|
||||
* Used for monitoring background operations if needed.
|
||||
*/
|
||||
private async pollSessionStatus(sessionId: string, timeout: number): Promise<SessionStatusResponse> {
|
||||
const maxAttempts = Math.min(this.maxPollAttempts, Math.ceil(timeout / this.pollIntervalMs));
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const response = await this.axiosClient.get<SessionStatusResponse>(
|
||||
`${this.sessionCoordinatorUrl}/api/v1/sessions/${sessionId}/status`,
|
||||
);
|
||||
|
||||
const status = response.data;
|
||||
this.logger.debug(`Session ${sessionId} status: ${status.status} (${status.completed_parties}/${status.total_parties})`);
|
||||
|
||||
if (status.status === 'completed' || status.status === 'failed') {
|
||||
return status;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error polling session status: ${error.message}`);
|
||||
}
|
||||
|
||||
await this.sleep(this.pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new Error(`Session ${sessionId} timed out after ${timeout}ms`);
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Infrastructure Module
|
||||
*
|
||||
* Registers infrastructure services (persistence, external clients, etc.)
|
||||
* mpc-service 作为网关,只需要 PrismaService 用于缓存公钥和 delegate share
|
||||
*/
|
||||
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
|
@ -9,78 +9,16 @@ import { ConfigModule } from '@nestjs/config';
|
|||
|
||||
// Persistence
|
||||
import { PrismaService } from './persistence/prisma/prisma.service';
|
||||
import { PartyShareMapper } from './persistence/mappers/party-share.mapper';
|
||||
import { SessionStateMapper } from './persistence/mappers/session-state.mapper';
|
||||
import { PartyShareRepositoryImpl } from './persistence/repositories/party-share.repository.impl';
|
||||
import { SessionStateRepositoryImpl } from './persistence/repositories/session-state.repository.impl';
|
||||
|
||||
// Domain Repository Tokens
|
||||
import { PARTY_SHARE_REPOSITORY } from '../domain/repositories/party-share.repository.interface';
|
||||
import { SESSION_STATE_REPOSITORY } from '../domain/repositories/session-state.repository.interface';
|
||||
import { TSS_PROTOCOL_SERVICE } from '../domain/services/tss-protocol.domain-service';
|
||||
|
||||
// External Services
|
||||
import { MPCCoordinatorClient } from './external/mpc-system/coordinator-client';
|
||||
import { MPCMessageRouterClient } from './external/mpc-system/message-router-client';
|
||||
import { TSSWrapper } from './external/tss-lib/tss-wrapper';
|
||||
|
||||
// Messaging
|
||||
import { EventPublisherService } from './messaging/kafka/event-publisher.service';
|
||||
|
||||
// Redis
|
||||
import { SessionCacheService } from './redis/cache/session-cache.service';
|
||||
import { DistributedLockService } from './redis/lock/distributed-lock.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [
|
||||
// Prisma
|
||||
// Prisma (用于缓存公钥和 delegate share)
|
||||
PrismaService,
|
||||
|
||||
// Mappers
|
||||
PartyShareMapper,
|
||||
SessionStateMapper,
|
||||
|
||||
// Repositories (with interface binding)
|
||||
{
|
||||
provide: PARTY_SHARE_REPOSITORY,
|
||||
useClass: PartyShareRepositoryImpl,
|
||||
},
|
||||
{
|
||||
provide: SESSION_STATE_REPOSITORY,
|
||||
useClass: SessionStateRepositoryImpl,
|
||||
},
|
||||
|
||||
// TSS Protocol Service
|
||||
{
|
||||
provide: TSS_PROTOCOL_SERVICE,
|
||||
useClass: TSSWrapper,
|
||||
},
|
||||
|
||||
// External Clients
|
||||
MPCCoordinatorClient,
|
||||
MPCMessageRouterClient,
|
||||
|
||||
// Messaging
|
||||
EventPublisherService,
|
||||
|
||||
// Redis Services
|
||||
SessionCacheService,
|
||||
DistributedLockService,
|
||||
],
|
||||
exports: [
|
||||
PrismaService,
|
||||
PartyShareMapper,
|
||||
SessionStateMapper,
|
||||
PARTY_SHARE_REPOSITORY,
|
||||
SESSION_STATE_REPOSITORY,
|
||||
TSS_PROTOCOL_SERVICE,
|
||||
MPCCoordinatorClient,
|
||||
MPCMessageRouterClient,
|
||||
EventPublisherService,
|
||||
SessionCacheService,
|
||||
DistributedLockService,
|
||||
],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,6 @@
|
|||
export * from './party-share.mapper';
|
||||
export * from './session-state.mapper';
|
||||
/**
|
||||
* Mapper Index
|
||||
*
|
||||
* mpc-service 作为网关模式,不再需要复杂的 mapper
|
||||
* 数据转换直接在 MPCCoordinatorService 中处理
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
/**
|
||||
* Party Share Mapper
|
||||
*
|
||||
* Maps between domain PartyShare entity and persistence entity.
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PartyShare } from '../../../domain/entities/party-share.entity';
|
||||
import {
|
||||
ShareId,
|
||||
PartyId,
|
||||
SessionId,
|
||||
ShareData,
|
||||
PublicKey,
|
||||
Threshold,
|
||||
} from '../../../domain/value-objects';
|
||||
import { PartyShareType, PartyShareStatus } from '../../../domain/enums';
|
||||
|
||||
/**
|
||||
* Persistence entity structure (matches Prisma model)
|
||||
*/
|
||||
export interface PartySharePersistence {
|
||||
id: string;
|
||||
partyId: string;
|
||||
sessionId: string;
|
||||
shareType: string;
|
||||
shareData: string; // JSON string
|
||||
publicKey: string; // Hex string
|
||||
thresholdN: number;
|
||||
thresholdT: number;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastUsedAt: Date | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PartyShareMapper {
|
||||
/**
|
||||
* Convert persistence entity to domain entity
|
||||
*/
|
||||
toDomain(entity: PartySharePersistence): PartyShare {
|
||||
const shareDataJson = JSON.parse(entity.shareData);
|
||||
|
||||
return PartyShare.reconstruct({
|
||||
id: ShareId.create(entity.id),
|
||||
partyId: PartyId.create(entity.partyId),
|
||||
sessionId: SessionId.create(entity.sessionId),
|
||||
shareType: entity.shareType as PartyShareType,
|
||||
shareData: ShareData.fromJSON(shareDataJson),
|
||||
publicKey: PublicKey.fromHex(entity.publicKey),
|
||||
threshold: Threshold.create(entity.thresholdN, entity.thresholdT),
|
||||
status: entity.status as PartyShareStatus,
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
lastUsedAt: entity.lastUsedAt || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert domain entity to persistence entity
|
||||
*/
|
||||
toPersistence(domain: PartyShare): PartySharePersistence {
|
||||
return {
|
||||
id: domain.id.value,
|
||||
partyId: domain.partyId.value,
|
||||
sessionId: domain.sessionId.value,
|
||||
shareType: domain.shareType,
|
||||
shareData: JSON.stringify(domain.shareData.toJSON()),
|
||||
publicKey: domain.publicKey.toHex(),
|
||||
thresholdN: domain.threshold.n,
|
||||
thresholdT: domain.threshold.t,
|
||||
status: domain.status,
|
||||
createdAt: domain.createdAt,
|
||||
updatedAt: domain.updatedAt,
|
||||
lastUsedAt: domain.lastUsedAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert multiple persistence entities to domain entities
|
||||
*/
|
||||
toDomainList(entities: PartySharePersistence[]): PartyShare[] {
|
||||
return entities.map(entity => this.toDomain(entity));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
/**
|
||||
* Session State Mapper
|
||||
*
|
||||
* Maps between domain SessionState entity and persistence entity.
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SessionState, Participant } from '../../../domain/entities/session-state.entity';
|
||||
import {
|
||||
SessionId,
|
||||
PartyId,
|
||||
PublicKey,
|
||||
MessageHash,
|
||||
Signature,
|
||||
} from '../../../domain/value-objects';
|
||||
import { SessionType, SessionStatus, ParticipantStatus } from '../../../domain/enums';
|
||||
|
||||
/**
|
||||
* Persistence entity structure (matches Prisma model)
|
||||
*/
|
||||
export interface SessionStatePersistence {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
partyId: string;
|
||||
partyIndex: number;
|
||||
sessionType: string;
|
||||
participants: string; // JSON array
|
||||
thresholdN: number;
|
||||
thresholdT: number;
|
||||
status: string;
|
||||
currentRound: number;
|
||||
errorMessage: string | null;
|
||||
publicKey: string | null;
|
||||
messageHash: string | null;
|
||||
signature: string | null;
|
||||
startedAt: Date;
|
||||
completedAt: Date | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionStateMapper {
|
||||
/**
|
||||
* Convert persistence entity to domain entity
|
||||
*/
|
||||
toDomain(entity: SessionStatePersistence): SessionState {
|
||||
const participants: Participant[] = JSON.parse(entity.participants).map((p: any) => ({
|
||||
partyId: p.partyId,
|
||||
partyIndex: p.partyIndex,
|
||||
status: p.status as ParticipantStatus,
|
||||
}));
|
||||
|
||||
return SessionState.reconstruct({
|
||||
id: entity.id,
|
||||
sessionId: SessionId.create(entity.sessionId),
|
||||
partyId: PartyId.create(entity.partyId),
|
||||
partyIndex: entity.partyIndex,
|
||||
sessionType: entity.sessionType as SessionType,
|
||||
participants,
|
||||
thresholdN: entity.thresholdN,
|
||||
thresholdT: entity.thresholdT,
|
||||
status: entity.status as SessionStatus,
|
||||
currentRound: entity.currentRound,
|
||||
errorMessage: entity.errorMessage || undefined,
|
||||
publicKey: entity.publicKey ? PublicKey.fromHex(entity.publicKey) : undefined,
|
||||
messageHash: entity.messageHash ? MessageHash.fromHex(entity.messageHash) : undefined,
|
||||
signature: entity.signature ? Signature.fromHex(entity.signature) : undefined,
|
||||
startedAt: entity.startedAt,
|
||||
completedAt: entity.completedAt || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert domain entity to persistence entity
|
||||
*/
|
||||
toPersistence(domain: SessionState): SessionStatePersistence {
|
||||
return {
|
||||
id: domain.id,
|
||||
sessionId: domain.sessionId.value,
|
||||
partyId: domain.partyId.value,
|
||||
partyIndex: domain.partyIndex,
|
||||
sessionType: domain.sessionType,
|
||||
participants: JSON.stringify(domain.participants),
|
||||
thresholdN: domain.thresholdN,
|
||||
thresholdT: domain.thresholdT,
|
||||
status: domain.status,
|
||||
currentRound: domain.currentRound,
|
||||
errorMessage: domain.errorMessage || null,
|
||||
publicKey: domain.publicKey?.toHex() || null,
|
||||
messageHash: domain.messageHash?.toHex() || null,
|
||||
signature: domain.signature?.toHex() || null,
|
||||
startedAt: domain.startedAt,
|
||||
completedAt: domain.completedAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert multiple persistence entities to domain entities
|
||||
*/
|
||||
toDomainList(entities: SessionStatePersistence[]): SessionState[] {
|
||||
return entities.map(entity => this.toDomain(entity));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,6 @@
|
|||
export * from './party-share.repository.impl';
|
||||
export * from './session-state.repository.impl';
|
||||
/**
|
||||
* Repository Implementations Index
|
||||
*
|
||||
* mpc-service 作为网关模式,不再需要复杂的 repository 实现
|
||||
* 数据访问直接通过 PrismaService 在 MPCCoordinatorService 中处理
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
/**
|
||||
* Party Share Repository Implementation
|
||||
*
|
||||
* Implements the PartyShareRepository interface using Prisma.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PartyShare } from '../../../domain/entities/party-share.entity';
|
||||
import {
|
||||
PartyShareRepository,
|
||||
PartyShareFilters,
|
||||
Pagination,
|
||||
} from '../../../domain/repositories/party-share.repository.interface';
|
||||
import { ShareId, PartyId, SessionId, PublicKey } from '../../../domain/value-objects';
|
||||
import { PartyShareStatus } from '../../../domain/enums';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PartyShareMapper, PartySharePersistence } from '../mappers/party-share.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class PartyShareRepositoryImpl implements PartyShareRepository {
|
||||
private readonly logger = new Logger(PartyShareRepositoryImpl.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mapper: PartyShareMapper,
|
||||
) {}
|
||||
|
||||
async save(share: PartyShare): Promise<void> {
|
||||
const entity = this.mapper.toPersistence(share);
|
||||
this.logger.debug(`Saving share: ${entity.id}`);
|
||||
|
||||
await this.prisma.partyShare.create({
|
||||
data: entity,
|
||||
});
|
||||
}
|
||||
|
||||
async update(share: PartyShare): Promise<void> {
|
||||
const entity = this.mapper.toPersistence(share);
|
||||
this.logger.debug(`Updating share: ${entity.id}`);
|
||||
|
||||
await this.prisma.partyShare.update({
|
||||
where: { id: entity.id },
|
||||
data: {
|
||||
status: entity.status,
|
||||
lastUsedAt: entity.lastUsedAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: ShareId): Promise<PartyShare | null> {
|
||||
const entity = await this.prisma.partyShare.findUnique({
|
||||
where: { id: id.value },
|
||||
});
|
||||
|
||||
return entity ? this.mapper.toDomain(entity as PartySharePersistence) : null;
|
||||
}
|
||||
|
||||
async findByPartyIdAndPublicKey(partyId: PartyId, publicKey: PublicKey): Promise<PartyShare | null> {
|
||||
const entity = await this.prisma.partyShare.findFirst({
|
||||
where: {
|
||||
partyId: partyId.value,
|
||||
publicKey: publicKey.toHex(),
|
||||
status: PartyShareStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
return entity ? this.mapper.toDomain(entity as PartySharePersistence) : null;
|
||||
}
|
||||
|
||||
async findByPartyIdAndSessionId(partyId: PartyId, sessionId: SessionId): Promise<PartyShare | null> {
|
||||
const entity = await this.prisma.partyShare.findFirst({
|
||||
where: {
|
||||
partyId: partyId.value,
|
||||
sessionId: sessionId.value,
|
||||
},
|
||||
});
|
||||
|
||||
return entity ? this.mapper.toDomain(entity as PartySharePersistence) : null;
|
||||
}
|
||||
|
||||
async findBySessionId(sessionId: SessionId): Promise<PartyShare[]> {
|
||||
const entities = await this.prisma.partyShare.findMany({
|
||||
where: { sessionId: sessionId.value },
|
||||
});
|
||||
|
||||
return this.mapper.toDomainList(entities as PartySharePersistence[]);
|
||||
}
|
||||
|
||||
async findByPartyId(partyId: PartyId): Promise<PartyShare[]> {
|
||||
const entities = await this.prisma.partyShare.findMany({
|
||||
where: { partyId: partyId.value },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return this.mapper.toDomainList(entities as PartySharePersistence[]);
|
||||
}
|
||||
|
||||
async findActiveByPartyId(partyId: PartyId): Promise<PartyShare[]> {
|
||||
const entities = await this.prisma.partyShare.findMany({
|
||||
where: {
|
||||
partyId: partyId.value,
|
||||
status: PartyShareStatus.ACTIVE,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return this.mapper.toDomainList(entities as PartySharePersistence[]);
|
||||
}
|
||||
|
||||
async findByPublicKey(publicKey: PublicKey): Promise<PartyShare | null> {
|
||||
const entity = await this.prisma.partyShare.findFirst({
|
||||
where: {
|
||||
publicKey: publicKey.toHex(),
|
||||
status: PartyShareStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
return entity ? this.mapper.toDomain(entity as PartySharePersistence) : null;
|
||||
}
|
||||
|
||||
async findMany(filters?: PartyShareFilters, pagination?: Pagination): Promise<PartyShare[]> {
|
||||
const where: any = {};
|
||||
|
||||
if (filters) {
|
||||
if (filters.partyId) where.partyId = filters.partyId;
|
||||
if (filters.status) where.status = filters.status;
|
||||
if (filters.shareType) where.shareType = filters.shareType;
|
||||
if (filters.publicKey) where.publicKey = filters.publicKey;
|
||||
}
|
||||
|
||||
const entities = await this.prisma.partyShare.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: pagination ? (pagination.page - 1) * pagination.limit : undefined,
|
||||
take: pagination?.limit,
|
||||
});
|
||||
|
||||
return this.mapper.toDomainList(entities as PartySharePersistence[]);
|
||||
}
|
||||
|
||||
async count(filters?: PartyShareFilters): Promise<number> {
|
||||
const where: any = {};
|
||||
|
||||
if (filters) {
|
||||
if (filters.partyId) where.partyId = filters.partyId;
|
||||
if (filters.status) where.status = filters.status;
|
||||
if (filters.shareType) where.shareType = filters.shareType;
|
||||
if (filters.publicKey) where.publicKey = filters.publicKey;
|
||||
}
|
||||
|
||||
return this.prisma.partyShare.count({ where });
|
||||
}
|
||||
|
||||
async existsByPartyIdAndPublicKey(partyId: PartyId, publicKey: PublicKey): Promise<boolean> {
|
||||
const count = await this.prisma.partyShare.count({
|
||||
where: {
|
||||
partyId: partyId.value,
|
||||
publicKey: publicKey.toHex(),
|
||||
status: PartyShareStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async delete(id: ShareId): Promise<void> {
|
||||
// Soft delete - mark as revoked
|
||||
await this.prisma.partyShare.update({
|
||||
where: { id: id.value },
|
||||
data: {
|
||||
status: PartyShareStatus.REVOKED,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
/**
|
||||
* Session State Repository Implementation
|
||||
*
|
||||
* Implements the SessionStateRepository interface using Prisma.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { SessionState } from '../../../domain/entities/session-state.entity';
|
||||
import {
|
||||
SessionStateRepository,
|
||||
SessionStateFilters,
|
||||
} from '../../../domain/repositories/session-state.repository.interface';
|
||||
import { SessionId, PartyId } from '../../../domain/value-objects';
|
||||
import { SessionStatus } from '../../../domain/enums';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SessionStateMapper, SessionStatePersistence } from '../mappers/session-state.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class SessionStateRepositoryImpl implements SessionStateRepository {
|
||||
private readonly logger = new Logger(SessionStateRepositoryImpl.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mapper: SessionStateMapper,
|
||||
) {}
|
||||
|
||||
async save(session: SessionState): Promise<void> {
|
||||
const entity = this.mapper.toPersistence(session);
|
||||
this.logger.debug(`Saving session state: ${entity.id}`);
|
||||
|
||||
await this.prisma.sessionState.create({
|
||||
data: entity,
|
||||
});
|
||||
}
|
||||
|
||||
async update(session: SessionState): Promise<void> {
|
||||
const entity = this.mapper.toPersistence(session);
|
||||
this.logger.debug(`Updating session state: ${entity.id}`);
|
||||
|
||||
await this.prisma.sessionState.update({
|
||||
where: { id: entity.id },
|
||||
data: {
|
||||
participants: entity.participants,
|
||||
status: entity.status,
|
||||
currentRound: entity.currentRound,
|
||||
errorMessage: entity.errorMessage,
|
||||
publicKey: entity.publicKey,
|
||||
signature: entity.signature,
|
||||
completedAt: entity.completedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SessionState | null> {
|
||||
const entity = await this.prisma.sessionState.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return entity ? this.mapper.toDomain(entity as SessionStatePersistence) : null;
|
||||
}
|
||||
|
||||
async findBySessionIdAndPartyId(sessionId: SessionId, partyId: PartyId): Promise<SessionState | null> {
|
||||
const entity = await this.prisma.sessionState.findFirst({
|
||||
where: {
|
||||
sessionId: sessionId.value,
|
||||
partyId: partyId.value,
|
||||
},
|
||||
});
|
||||
|
||||
return entity ? this.mapper.toDomain(entity as SessionStatePersistence) : null;
|
||||
}
|
||||
|
||||
async findBySessionId(sessionId: SessionId): Promise<SessionState[]> {
|
||||
const entities = await this.prisma.sessionState.findMany({
|
||||
where: { sessionId: sessionId.value },
|
||||
});
|
||||
|
||||
return this.mapper.toDomainList(entities as SessionStatePersistence[]);
|
||||
}
|
||||
|
||||
async findByPartyId(partyId: PartyId): Promise<SessionState[]> {
|
||||
const entities = await this.prisma.sessionState.findMany({
|
||||
where: { partyId: partyId.value },
|
||||
orderBy: { startedAt: 'desc' },
|
||||
});
|
||||
|
||||
return this.mapper.toDomainList(entities as SessionStatePersistence[]);
|
||||
}
|
||||
|
||||
async findInProgressByPartyId(partyId: PartyId): Promise<SessionState[]> {
|
||||
const entities = await this.prisma.sessionState.findMany({
|
||||
where: {
|
||||
partyId: partyId.value,
|
||||
status: SessionStatus.IN_PROGRESS,
|
||||
},
|
||||
orderBy: { startedAt: 'desc' },
|
||||
});
|
||||
|
||||
return this.mapper.toDomainList(entities as SessionStatePersistence[]);
|
||||
}
|
||||
|
||||
async findMany(filters?: SessionStateFilters): Promise<SessionState[]> {
|
||||
const where: any = {};
|
||||
|
||||
if (filters) {
|
||||
if (filters.partyId) where.partyId = filters.partyId;
|
||||
if (filters.status) where.status = filters.status;
|
||||
if (filters.sessionType) where.sessionType = filters.sessionType;
|
||||
}
|
||||
|
||||
const entities = await this.prisma.sessionState.findMany({
|
||||
where,
|
||||
orderBy: { startedAt: 'desc' },
|
||||
});
|
||||
|
||||
return this.mapper.toDomainList(entities as SessionStatePersistence[]);
|
||||
}
|
||||
|
||||
async deleteCompletedBefore(date: Date): Promise<number> {
|
||||
const result = await this.prisma.sessionState.deleteMany({
|
||||
where: {
|
||||
status: {
|
||||
in: [SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.TIMEOUT],
|
||||
},
|
||||
completedAt: {
|
||||
lt: date,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Deleted ${result.count} old session states`);
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue