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:
hailin 2025-12-06 17:16:14 -08:00
parent d652f1d7a4
commit 9c36e6772b
88 changed files with 2507 additions and 4897 deletions

View File

@ -29,7 +29,10 @@
"Bash(go run:*)", "Bash(go run:*)",
"Bash(timeout /t 5 /nobreak)", "Bash(timeout /t 5 /nobreak)",
"Bash(bash:*)", "Bash(bash:*)",
"Bash(docker compose:*)" "Bash(docker compose:*)",
"Bash(timeout /t 15 /nobreak)",
"Bash(timeout /t 30 /nobreak)",
"Bash(docker ps:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^10.3.0", "@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1", "@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0", "@nestjs/core": "^10.3.0",
@ -1605,6 +1606,17 @@
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==",
"license": "MIT" "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": { "node_modules/@nestjs/cli": {
"version": "10.4.9", "version": "10.4.9",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", "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", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.4", "form-data": "^4.0.4",

View File

@ -28,6 +28,7 @@
"db:seed": "ts-node prisma/seed.ts" "db:seed": "ts-node prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^10.3.0", "@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1", "@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0", "@nestjs/core": "^10.3.0",

View File

@ -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 { generator client {
@ -12,69 +20,32 @@ datasource db {
} }
// ============================================================================= // =============================================================================
// Party Shares Table // MPC Wallets Table (缓存用户公钥,用于本地快速查询)
// ============================================================================= // =============================================================================
model PartyShare { model MpcWallet {
id String @id @db.VarChar(255) id String @id @default(uuid())
partyId String @map("party_id") @db.VarChar(255) username String @unique @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 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") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
lastUsedAt DateTime? @map("last_used_at")
@@unique([partyId, sessionId], name: "uk_party_session") @@index([username], name: "idx_mw_username")
@@index([partyId], name: "idx_ps_party_id") @@map("mpc_wallets")
@@index([sessionId], name: "idx_ps_session_id")
@@index([status], name: "idx_ps_status")
@@map("party_shares")
} }
// ============================================================================= // =============================================================================
// Session States Table // MPC Shares Table (存储 delegate share由 mpc-system 返回给用户)
// ============================================================================= // =============================================================================
model SessionState { model MpcShare {
id String @id @db.VarChar(255) id String @id @default(uuid())
sessionId String @map("session_id") @db.VarChar(255) username String @db.VarChar(255)
partyId String @map("party_id") @db.VarChar(255) partyId String @map("party_id") @db.VarChar(255)
partyIndex Int @map("party_index") partyIndex Int @map("party_index")
sessionType String @map("session_type") @db.VarChar(20) encryptedShare String @map("encrypted_share") @db.Text
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")
@@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") createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(255) updatedAt DateTime @updatedAt @map("updated_at")
@@index([shareId], name: "idx_share_id") @@unique([username], name: "uq_ms_username")
@@index([createdAt], name: "idx_created_at") @@index([username], name: "idx_ms_username")
@@map("share_backups") @@map("mpc_shares")
} }

View File

@ -1,19 +1,20 @@
/** /**
* API Module * API Module
* *
* Registers API controllers. * mpc-service API
*/ */
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ApplicationModule } from '../application/application.module'; import { ApplicationModule } from '../application/application.module';
import { MPCPartyController } from './controllers/mpc-party.controller';
import { HealthController } from './controllers/health.controller'; import { HealthController } from './controllers/health.controller';
import { MPCController } from './controllers/mpc.controller'; import { MPCController } from './controllers/mpc.controller';
// Re-export API components for easier imports
export * from './controllers';
@Module({ @Module({
imports: [ApplicationModule], imports: [ApplicationModule],
controllers: [ controllers: [
MPCPartyController,
HealthController, HealthController,
MPCController, MPCController,
], ],

View File

@ -1,3 +1,6 @@
export * from './mpc-party.controller'; /**
* API Controllers Index
*/
export * from './health.controller'; export * from './health.controller';
export * from './mpc.controller'; export * from './mpc.controller';

View File

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

View File

@ -66,7 +66,6 @@ export class KeygenStatusResponseDto {
partyIndex: number; partyIndex: number;
encryptedShare: string; encryptedShare: string;
}; };
serverParties?: string[];
} }
export class SigningSessionResponseDto { export class SigningSessionResponseDto {
@ -146,7 +145,6 @@ export class MPCController {
status: result.status, status: result.status,
publicKey: result.publicKey, publicKey: result.publicKey,
delegateShare: result.delegateShare, delegateShare: result.delegateShare,
serverParties: result.serverParties,
}; };
} }
@ -226,12 +224,11 @@ export class MPCController {
async getPublicKey(@Param('username') username: string) { async getPublicKey(@Param('username') username: string) {
this.logger.debug(`Getting public key: username=${username}`); this.logger.debug(`Getting public key: username=${username}`);
const result = await this.mpcCoordinatorService.getWalletByUsername(username); const result = await this.mpcCoordinatorService.getPublicKeyByUsername(username);
return { return {
username: result.username, username: result.username,
publicKey: result.publicKey, publicKey: result.publicKey,
keygenSessionId: result.keygenSessionId,
}; };
} }
} }

View File

@ -1,2 +1,6 @@
/**
* API DTOs Index
*/
export * from './request'; export * from './request';
export * from './response'; export * from './response';

View File

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

View File

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

View File

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

View File

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

View File

@ -1,54 +1,26 @@
/** /**
* Application Module * Application Module
* *
* Registers application layer services (handlers, services). * mpc-service MPCCoordinatorService mpc-system
*/ */
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DomainModule } from '../domain/domain.module';
import { InfrastructureModule } from '../infrastructure/infrastructure.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 // Services
import { MPCPartyApplicationService } from './services/mpc-party-application.service';
import { MPCCoordinatorService } from './services/mpc-coordinator.service'; import { MPCCoordinatorService } from './services/mpc-coordinator.service';
// Entities
import { MpcWallet, MpcShare, MpcSession } from '../domain/entities';
@Module({ @Module({
imports: [ imports: [
DomainModule,
InfrastructureModule, InfrastructureModule,
HttpModule, HttpModule,
TypeOrmModule.forFeature([MpcWallet, MpcShare, MpcSession]),
], ],
providers: [ providers: [
// Command Handlers
ParticipateInKeygenHandler,
ParticipateInSigningHandler,
RotateShareHandler,
// Query Handlers
GetShareInfoHandler,
ListSharesHandler,
// Application Services // Application Services
MPCPartyApplicationService,
MPCCoordinatorService, MPCCoordinatorService,
], ],
exports: [ exports: [
MPCPartyApplicationService,
MPCCoordinatorService, MPCCoordinatorService,
], ],
}) })

View File

@ -1,7 +0,0 @@
/**
* Commands Index
*/
export * from './participate-keygen';
export * from './participate-signing';
export * from './rotate-share';

View File

@ -1,2 +0,0 @@
export * from './participate-keygen.command';
export * from './participate-keygen.handler';

View File

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

View File

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

View File

@ -1,2 +0,0 @@
export * from './participate-signing.command';
export * from './participate-signing.handler';

View File

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

View File

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

View File

@ -1,2 +0,0 @@
export * from './rotate-share.command';
export * from './rotate-share.handler';

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +0,0 @@
export * from './get-share-info.query';
export * from './get-share-info.handler';

View File

@ -1,6 +0,0 @@
/**
* Queries Index
*/
export * from './get-share-info';
export * from './list-shares';

View File

@ -1,2 +0,0 @@
export * from './list-shares.query';
export * from './list-shares.handler';

View File

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

View File

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

View File

@ -1 +1 @@
export * from './mpc-party-application.service'; export * from './mpc-coordinator.service';

View File

@ -1,22 +1,22 @@
/** /**
* MPC Coordinator Service * MPC Coordinator Service
* *
* mpc-system (Go) MPC * MPC mpc-system (Go)
* share * 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 { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { InjectRepository } from '@nestjs/typeorm'; import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { Repository } from 'typeorm';
import { MpcWallet } from '../../domain/entities/mpc-wallet.entity'; // ============================================================================
import { MpcShare } from '../../domain/entities/mpc-share.entity'; // Input/Output Types
import { MpcSession } from '../../domain/entities/mpc-session.entity'; // ============================================================================
export interface CreateKeygenInput { export interface CreateKeygenInput {
username: string; username: string;
@ -27,19 +27,27 @@ export interface CreateKeygenInput {
export interface CreateKeygenOutput { export interface CreateKeygenOutput {
sessionId: string; sessionId: string;
username: string;
thresholdN: number;
thresholdT: number;
selectedParties: string[];
delegateParty?: string;
status: string; status: string;
} }
export interface KeygenStatusOutput { export interface KeygenStatusOutput {
sessionId: string; sessionId: string;
status: string; status: string;
sessionType: string;
completedParties: number;
totalParties: number;
publicKey?: string; publicKey?: string;
hasDelegate?: boolean;
delegateShare?: { delegateShare?: {
partyId: string; partyId: string;
partyIndex: number; partyIndex: number;
encryptedShare: string; encryptedShare: string;
}; };
serverParties?: string[];
} }
export interface CreateSigningInput { export interface CreateSigningInput {
@ -50,52 +58,85 @@ export interface CreateSigningInput {
export interface CreateSigningOutput { export interface CreateSigningOutput {
sessionId: string; sessionId: string;
username: string;
messageHash: string;
thresholdT: number;
selectedParties: string[];
hasDelegate: boolean;
delegatePartyId?: string;
status: string; status: string;
} }
export interface SigningStatusOutput { export interface SigningStatusOutput {
sessionId: string; sessionId: string;
status: string; status: string;
sessionType: string;
completedParties: number;
totalParties: number;
signature?: string; signature?: string;
} }
export interface WalletOutput { export interface WalletOutput {
username: string; username: string;
publicKey: 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() @Injectable()
export class MPCCoordinatorService { export class MPCCoordinatorService {
private readonly logger = new Logger(MPCCoordinatorService.name); private readonly logger = new Logger(MPCCoordinatorService.name);
private readonly mpcSystemUrl: string; private readonly mpcSystemUrl: string;
private readonly mpcApiKey: string; private readonly mpcApiKey: string;
private readonly pollIntervalMs = 2000;
private readonly maxPollAttempts = 150;
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly httpService: HttpService, private readonly httpService: HttpService,
@InjectRepository(MpcWallet) private readonly prisma: PrismaService,
private readonly walletRepository: Repository<MpcWallet>,
@InjectRepository(MpcShare)
private readonly shareRepository: Repository<MpcShare>,
@InjectRepository(MpcSession)
private readonly sessionRepository: Repository<MpcSession>,
) { ) {
this.mpcSystemUrl = this.configService.get<string>('MPC_SYSTEM_URL', 'http://localhost:4000'); this.mpcSystemUrl = this.configService.get<string>('MPC_SYSTEM_URL', 'http://localhost:4000');
this.mpcApiKey = this.configService.get<string>('MPC_API_KEY', 'test-api-key'); this.mpcApiKey = this.configService.get<string>('MPC_API_KEY', 'test-api-key');
} }
// ==========================================================================
// Keygen APIs
// ==========================================================================
/** /**
* keygen * keygen - mpc-system
* mpc-system /api/v1/mpc/keygen API
*/ */
async createKeygenSession(input: CreateKeygenInput): Promise<CreateKeygenOutput> { async createKeygenSession(input: CreateKeygenInput): Promise<CreateKeygenOutput> {
this.logger.log(`Creating keygen session: username=${input.username}`); this.logger.log(`Creating keygen session: username=${input.username}`);
try {
// 调用 mpc-system 创建 keygen session
const response = await firstValueFrom( const response = await firstValueFrom(
this.httpService.post<{ this.httpService.post<{
session_id: string; session_id: string;
@ -124,44 +165,25 @@ export class MPCCoordinatorService {
), ),
); );
const sessionId = response.data.session_id; const data = response.data;
this.logger.log(`Keygen session created: ${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 { return {
sessionId, sessionId: data.session_id,
status: 'created', username: input.username,
thresholdN: data.threshold_n,
thresholdT: data.threshold_t,
selectedParties: data.selected_parties,
delegateParty: data.delegate_party,
status: data.status,
}; };
} catch (error) {
this.logger.error(`Failed to create keygen session: ${error.message}`);
throw error;
}
} }
/** /**
* keygen * keygen - mpc-system
*
*/ */
private async pollKeygenCompletion(sessionId: string, username: string): Promise<void> { async getKeygenStatus(sessionId: string): Promise<KeygenStatusOutput> {
for (let i = 0; i < this.maxPollAttempts; i++) {
try {
const response = await firstValueFrom( const response = await firstValueFrom(
this.httpService.get<{ this.httpService.get<{
session_id: string; session_id: string;
@ -189,114 +211,42 @@ export class MPCCoordinatorService {
const data = response.data; const data = response.data;
if (data.status === 'completed') { // 如果完成且有公钥,缓存到本地
// 更新 session 状态 if (data.status === 'completed' && data.public_key) {
await this.sessionRepository.update( await this.cachePublicKey(sessionId, data.public_key);
{ 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' },
);
this.logger.error(`Keygen timed out: sessionId=${sessionId}`);
}
/**
* keygen
*/
async getKeygenStatus(sessionId: string): Promise<KeygenStatusOutput> {
const session = await this.sessionRepository.findOne({
where: { sessionId },
});
if (!session) {
return {
sessionId,
status: 'not_found',
};
} }
const result: KeygenStatusOutput = { const result: KeygenStatusOutput = {
sessionId, sessionId: data.session_id,
status: session.status, status: data.status,
serverParties: session.selectedParties?.filter(p => p !== session.delegateParty), sessionType: data.session_type,
completedParties: data.completed_parties,
totalParties: data.total_parties,
publicKey: data.public_key,
hasDelegate: data.has_delegate,
}; };
if (session.status === 'completed') { if (data.delegate_share) {
result.publicKey = session.publicKey;
// 获取 delegate share
const share = await this.shareRepository.findOne({
where: { sessionId, shareType: 'delegate' },
});
if (share) {
result.delegateShare = { result.delegateShare = {
partyId: share.partyId, partyId: data.delegate_share.party_id,
partyIndex: share.partyIndex, partyIndex: data.delegate_share.party_index,
encryptedShare: share.encryptedShare, encryptedShare: data.delegate_share.encrypted_share,
}; };
} }
}
return result; return result;
} }
// ==========================================================================
// Signing APIs
// ==========================================================================
/** /**
* * - mpc-system
*/ */
async createSigningSession(input: CreateSigningInput): Promise<CreateSigningOutput> { async createSigningSession(input: CreateSigningInput): Promise<CreateSigningOutput> {
this.logger.log(`Creating signing session: username=${input.username}`); this.logger.log(`Creating signing session: username=${input.username}`);
try {
// 调用 mpc-system 创建签名 session
const response = await firstValueFrom( const response = await firstValueFrom(
this.httpService.post<{ this.httpService.post<{
session_id: string; session_id: string;
@ -306,6 +256,7 @@ export class MPCCoordinatorService {
threshold_t: number; threshold_t: number;
selected_parties: string[]; selected_parties: string[];
has_delegate: boolean; has_delegate: boolean;
delegate_party_id?: string;
status: string; status: string;
}>( }>(
`${this.mpcSystemUrl}/api/v1/mpc/sign`, `${this.mpcSystemUrl}/api/v1/mpc/sign`,
@ -324,42 +275,25 @@ export class MPCCoordinatorService {
), ),
); );
const sessionId = response.data.session_id; const data = response.data;
this.logger.log(`Signing session created: ${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 { return {
sessionId, sessionId: data.session_id,
status: 'created', 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,
}; };
} catch (error) {
this.logger.error(`Failed to create signing session: ${error.message}`);
throw error;
}
} }
/** /**
* * - mpc-system
*/ */
private async pollSigningCompletion(sessionId: string): Promise<void> { async getSigningStatus(sessionId: string): Promise<SigningStatusOutput> {
for (let i = 0; i < this.maxPollAttempts; i++) {
try {
const response = await firstValueFrom( const response = await firstValueFrom(
this.httpService.get<{ this.httpService.get<{
session_id: string; session_id: string;
@ -381,83 +315,295 @@ export class MPCCoordinatorService {
const data = response.data; const data = response.data;
if (data.status === 'completed') { return {
await this.sessionRepository.update( sessionId: data.session_id,
{ sessionId }, status: data.status,
{ sessionType: data.session_type,
status: 'completed', completedParties: data.completed_parties,
totalParties: data.total_parties,
signature: data.signature, 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' },
);
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',
}; };
} }
return { // ==========================================================================
sessionId, // Wallet APIs
status: session.status, // ==========================================================================
signature: session.signature,
};
}
/** /**
* * - mpc-system
*/ */
async getWalletByUsername(username: string): Promise<WalletOutput> { async getPublicKeyByUsername(username: string): Promise<WalletOutput> {
const wallet = await this.walletRepository.findOne({ // 先查本地缓存
const cached = await this.prisma.mpcWallet.findUnique({
where: { username }, where: { username },
}); });
if (!wallet) { if (cached) {
throw new Error(`Wallet not found for username: ${username}`);
}
return { return {
username: wallet.username, username: cached.username,
publicKey: wallet.publicKey, publicKey: cached.publicKey,
keygenSessionId: wallet.keygenSessionId,
}; };
} }
private sleep(ms: number): Promise<void> { // 本地没有,转发到 mpc-system
return new Promise(resolve => setTimeout(resolve, ms)); 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,
publicKey,
};
}
// ==========================================================================
// 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 sharekeygen 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}`);
} }
} }

View File

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

View File

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

View File

@ -0,0 +1,11 @@
/**
* Database Configuration
*/
export interface DatabaseConfig {
url: string;
}
export const databaseConfig = (): DatabaseConfig => ({
url: process.env.DATABASE_URL || '',
});

View File

@ -2,53 +2,26 @@
* Configuration Index * Configuration Index
* *
* Central configuration management using NestJS ConfigModule. * Central configuration management using NestJS ConfigModule.
* Individual config files can be found in config/*.json for environment-specific defaults.
*/ */
export const appConfig = () => ({ export { AppConfig, appConfig } from './app.config';
port: parseInt(process.env.APP_PORT || '3006', 10), export { DatabaseConfig, databaseConfig } from './database.config';
env: process.env.NODE_ENV || 'development', export { JwtConfig, jwtConfig } from './jwt.config';
apiPrefix: process.env.API_PREFIX || 'api/v1', 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 = () => ({ import { appConfig } from './app.config';
url: process.env.DATABASE_URL, 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 = () => ({ // Combined configuration loader for NestJS ConfigModule
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
export const configurations = [ export const configurations = [
appConfig, appConfig,
databaseConfig, databaseConfig,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
/**
* Domain Aggregates Index
*/
export * from './party-share';

View File

@ -0,0 +1,7 @@
/**
* PartyShare Aggregate Index
*/
export * from './party-share.aggregate';
export * from './party-share.factory';
export * from './party-share.spec';

View File

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

View File

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

View File

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

View File

@ -8,6 +8,15 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ShareEncryptionDomainService } from './services/share-encryption.domain-service'; 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({ @Module({
providers: [ providers: [
ShareEncryptionDomainService, ShareEncryptionDomainService,

View File

@ -4,6 +4,3 @@
export * from './party-share.entity'; export * from './party-share.entity';
export * from './session-state.entity'; export * from './session-state.entity';
export * from './mpc-wallet.entity';
export * from './mpc-share.entity';
export * from './mpc-session.entity';

View File

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

View File

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

View File

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

View File

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

View File

@ -5,396 +5,37 @@
* They are used for audit logging, event sourcing, and async communication. * They are used for audit logging, event sourcing, and async communication.
*/ */
import { v4 as uuidv4 } from 'uuid'; // Base
import { PartyShareType, SessionType } from '../enums'; 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 // 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 // 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 // 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 // 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 = export type MPCDomainEvent =
| ShareCreatedEvent | ShareCreatedEvent
| ShareRotatedEvent | ShareRotatedEvent
@ -407,9 +48,7 @@ export type MPCDomainEvent =
| SessionTimeoutEvent | SessionTimeoutEvent
| ShareDecryptionAttemptedEvent; | ShareDecryptionAttemptedEvent;
// ============================================================================
// Event Topic Names (for Kafka) // Event Topic Names (for Kafka)
// ============================================================================
export const MPC_TOPICS = { export const MPC_TOPICS = {
SHARE_CREATED: 'mpc.ShareCreated', SHARE_CREATED: 'mpc.ShareCreated',
SHARE_ROTATED: 'mpc.ShareRotated', SHARE_ROTATED: 'mpc.ShareRotated',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,455 +5,11 @@
* They have no identity - equality is based on their values. * They have no identity - equality is based on their values.
*/ */
import { v4 as uuidv4 } from 'uuid'; export { SessionId } from './session-id.vo';
export { PartyId } from './party-id.vo';
// ============================================================================ export { ShareId } from './share-id.vo';
// SessionId - Unique identifier for MPC sessions export { Threshold } from './threshold.vo';
// ============================================================================ export { ShareData } from './share-data.vo';
export class SessionId { export { PublicKey } from './public-key.vo';
private readonly _value: string; export { Signature } from './signature.vo';
export { MessageHash } from './message-hash.vo';
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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +0,0 @@
export * from './coordinator-client';
export * from './message-router-client';

View File

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

View File

@ -1 +0,0 @@
export * from './tss-wrapper';

View File

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

View File

@ -1,7 +1,7 @@
/** /**
* Infrastructure Module * Infrastructure Module
* *
* Registers infrastructure services (persistence, external clients, etc.) * mpc-service PrismaService delegate share
*/ */
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
@ -9,78 +9,16 @@ import { ConfigModule } from '@nestjs/config';
// Persistence // Persistence
import { PrismaService } from './persistence/prisma/prisma.service'; 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() @Global()
@Module({ @Module({
imports: [ConfigModule], imports: [ConfigModule],
providers: [ providers: [
// Prisma // Prisma (用于缓存公钥和 delegate share)
PrismaService, 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: [ exports: [
PrismaService, PrismaService,
PartyShareMapper,
SessionStateMapper,
PARTY_SHARE_REPOSITORY,
SESSION_STATE_REPOSITORY,
TSS_PROTOCOL_SERVICE,
MPCCoordinatorClient,
MPCMessageRouterClient,
EventPublisherService,
SessionCacheService,
DistributedLockService,
], ],
}) })
export class InfrastructureModule {} export class InfrastructureModule {}

View File

@ -1,2 +1,6 @@
export * from './party-share.mapper'; /**
export * from './session-state.mapper'; * Mapper Index
*
* mpc-service mapper
* MPCCoordinatorService
*/

View File

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

View File

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

View File

@ -1,2 +1,6 @@
export * from './party-share.repository.impl'; /**
export * from './session-state.repository.impl'; * Repository Implementations Index
*
* mpc-service repository
* 访 PrismaService MPCCoordinatorService
*/

View File

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

View File

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