From 54061b4c160ffed4702652e82bb649e49694ce9e Mon Sep 17 00:00:00 2001 From: hailin Date: Fri, 5 Dec 2025 23:31:04 -0800 Subject: [PATCH] feat(mpc-system): add event sourcing for session tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SessionEventRepository interface for append-only event storage - Implement PostgreSQL session_event_repo with immutable event log - Add database migration for session_events table with indexes - Record events for keygen and sign session creation - Record events for signing-config APIs (set, update, clear) - Wire up sessionEventRepo in main.go and account handler - Update API documentation with event sourcing design ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- backend/mpc-system/docs/api-flows.md | 464 ++++++++++++++++++ .../003_add_session_events.down.sql | 13 + .../migrations/003_add_session_events.up.sql | 77 +++ .../adapters/input/http/account_handler.go | 226 ++++++--- .../output/postgres/session_event_repo.go | 231 +++++++++ .../services/account/cmd/server/main.go | 8 + .../repositories/session_event_repository.go | 137 ++++++ 7 files changed, 1093 insertions(+), 63 deletions(-) create mode 100644 backend/mpc-system/docs/api-flows.md create mode 100644 backend/mpc-system/migrations/003_add_session_events.down.sql create mode 100644 backend/mpc-system/migrations/003_add_session_events.up.sql create mode 100644 backend/mpc-system/services/account/adapters/output/postgres/session_event_repo.go create mode 100644 backend/mpc-system/services/account/domain/repositories/session_event_repository.go diff --git a/backend/mpc-system/docs/api-flows.md b/backend/mpc-system/docs/api-flows.md new file mode 100644 index 00000000..2cc5d1e8 --- /dev/null +++ b/backend/mpc-system/docs/api-flows.md @@ -0,0 +1,464 @@ +# MPC System API ๆต็จ‹ๆ–‡ๆกฃ + +ๆœฌๆ–‡ๆกฃๆ่ฟฐ MPC ็ณป็ปŸไธญ็š„ Keygenใ€Sign ไปฅๅŠ็ญพๅๆ–น้…็ฝฎ API ็š„่ฐƒ็”จๆต็จ‹ใ€‚ + +**้‡่ฆ่ฎพ่ฎกๅŽŸๅˆ™๏ผš** +- `username` ๆ˜ฏๆ•ดไธช MPC ็ณป็ปŸไธญๆ‰€ๆœ‰้€ป่พ‘ๅ…ณ็ณป็š„ๅ”ฏไธ€ๆ ‡่ฏ† +- ไบ‹ไปถๅž‹ๆ•ฐๆฎๅบ“่ฎพ่ฎก๏ผšๅชๆ’ๅ…ฅไธไฟฎๆ”น๏ผŒไฟ่ฏๆ•ฐๆฎๅฎ‰ๅ…จๆ€งๅ’Œๅฏ่ฟฝๆบฏๆ€ง + +## ็›ฎๅฝ• + +1. [Keygen API](#1-keygen-api) +2. [Sign API](#2-sign-api) +3. [็ญพๅๆ–น้…็ฝฎ API](#3-็ญพๅๆ–น้…็ฝฎ-api) +4. [API ๅ‚ๆ•ฐๆฑ‡ๆ€ป](#4-api-ๅ‚ๆ•ฐๆฑ‡ๆ€ป) +5. [็ญพๅๆ–น้€‰ๆ‹ฉ้€ป่พ‘](#5-็ญพๅๆ–น้€‰ๆ‹ฉ้€ป่พ‘) +6. [ไบ‹ไปถๅž‹ๆ•ฐๆฎๅบ“่ฎพ่ฎก](#6-ไบ‹ไปถๅž‹ๆ•ฐๆฎๅบ“่ฎพ่ฎก) + +--- + +## 1. Keygen API + +### ็ซฏ็‚น +``` +POST /mpc/keygen +``` + +### ๆต็จ‹ๅ›พ +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Client โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ Account API โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ Session Coordinator โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ Server Party โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ POST /keygen โ”‚ โ”‚ โ”‚ + โ”‚ {username, โ”‚ โ”‚ โ”‚ + โ”‚ threshold_n, โ”‚ ๆฃ€ๆŸฅ username โ”‚ โ”‚ + โ”‚ threshold_t} โ”‚ ๆ˜ฏๅฆๅทฒๅญ˜ๅœจ โ”‚ โ”‚ + โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚โ—€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ CreateSession โ”‚ โ”‚ + โ”‚ โ”‚ (keygen, n, t) โ”‚ โ”‚ + โ”‚ โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ NotifyParties โ”‚ + โ”‚ โ”‚ โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ session_id โ”‚ โ”‚ + โ”‚ โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ {session_id, โ”‚ โ”‚ โ”‚ + โ”‚ username, โ”‚ โ”‚ โ”‚ + โ”‚ status} โ”‚ โ”‚ โ”‚ + โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ [MPC Keygen Protocol Execution] โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ WebSocket โ”‚ โ”‚ โ”‚ + โ”‚ ๅฎŒๆˆ้€š็Ÿฅ โ”‚ โ”‚ โ”‚ + โ”‚โ—€โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ”‚ โ”‚ โ”‚ + โ”‚ {public_key} โ”‚ โ”‚ โ”‚ +``` + +### ่ฏทๆฑ‚ๅ‚ๆ•ฐ +```json +{ + "username": "string", // ็”จๆˆทๅ๏ผˆๅฟ…ๅกซ๏ผŒๅ”ฏไธ€ๆ ‡่ฏ†๏ผ‰ + "threshold_n": 3, // ๆ€ปๅ‚ไธŽๆ–นๆ•ฐ้‡๏ผˆๅฟ…ๅกซ๏ผ‰ + "threshold_t": 2, // ็ญพๅ้˜ˆๅ€ผ๏ผˆๅฟ…ๅกซ๏ผ‰ + "require_delegate": true // ๆ˜ฏๅฆ้œ€่ฆๅง”ๆ‰˜ๆ–น๏ผˆๅฏ้€‰๏ผ‰ +} +``` + +### ๅ“ๅบ” +```json +{ + "session_id": "uuid", + "session_type": "keygen", + "username": "string", + "threshold_n": 3, + "threshold_t": 2, + "selected_parties": ["party1", "party2", "party3"], + "delegate_party": "party3", + "status": "created" +} +``` + +### ้”™่ฏฏๅ“ๅบ” +- `409 Conflict`: username ๅทฒๅญ˜ๅœจ๏ผˆๅบ”ไฝฟ็”จ Sign API๏ผ‰ +- `400 Bad Request`: threshold_t > threshold_n + +--- + +## 2. Sign API + +### ็ซฏ็‚น +``` +POST /mpc/sign +``` + +### ๆต็จ‹ๅ›พ +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Client โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ Account API โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ Session Coordinator โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ Server Party โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ POST /sign โ”‚ โ”‚ โ”‚ + โ”‚ {username, โ”‚ โ”‚ โ”‚ + โ”‚ message_hash,โ”‚ ้€š่ฟ‡ username โ”‚ โ”‚ + โ”‚ user_share} โ”‚ ๆŸฅ่ฏข Account โ”‚ โ”‚ + โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ ๆฃ€ๆŸฅ SigningParties โ”‚ โ”‚ + โ”‚ โ”‚โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚โ—€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ CreateSession โ”‚ โ”‚ + โ”‚ โ”‚ (sign, parties) โ”‚ โ”‚ + โ”‚ โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ NotifyParties โ”‚ + โ”‚ โ”‚ โ”‚ (configured or all) โ”‚ + โ”‚ โ”‚ โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ session_id โ”‚ โ”‚ + โ”‚ โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ {session_id, โ”‚ โ”‚ โ”‚ + โ”‚ username, โ”‚ โ”‚ โ”‚ + โ”‚ status} โ”‚ โ”‚ โ”‚ + โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ [MPC Sign Protocol Execution] โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ WebSocket โ”‚ โ”‚ โ”‚ + โ”‚ ๅฎŒๆˆ้€š็Ÿฅ โ”‚ โ”‚ โ”‚ + โ”‚โ—€โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ”‚ โ”‚ โ”‚ + โ”‚ {signature} โ”‚ โ”‚ โ”‚ +``` + +### ่ฏทๆฑ‚ๅ‚ๆ•ฐ +```json +{ + "username": "string", // ็”จๆˆทๅ๏ผˆๅฟ…ๅกซ๏ผŒๅ”ฏไธ€ๆ ‡่ฏ†๏ผ‰ + "message_hash": "hex", // ๅพ…็ญพๅๆถˆๆฏๅ“ˆๅธŒ๏ผˆๅฟ…ๅกซ๏ผŒ32ๅญ—่Š‚SHA-256๏ผ‰ + "user_share": "hex" // ็”จๆˆทShare๏ผˆๅฏ้€‰๏ผŒ่ดฆๆˆทๆœ‰delegateๆ—ถๅฟ…ๅกซ๏ผ‰ +} +``` + +### ๅ“ๅบ” +```json +{ + "session_id": "uuid", + "session_type": "sign", + "username": "string", + "message_hash": "hex", + "threshold_t": 2, + "selected_parties": ["party1", "party2"], + "has_delegate": true, + "delegate_party_id": "party2", + "status": "created" +} +``` + +### ้”™่ฏฏๅ“ๅบ” +- `404 Not Found`: username ไธๅญ˜ๅœจ +- `400 Bad Request`: ่ดฆๆˆทๆœ‰ delegate ไฝ†ๆœชๆไพ› user_share + +### ็ญพๅๆ–น้€‰ๆ‹ฉ้€ป่พ‘ + +1. ๅฆ‚ๆžœ Account ้…็ฝฎไบ† `signing_parties`๏ผˆ้ž็ฉบ๏ผ‰โ†’ ไฝฟ็”จ้…็ฝฎ็š„ๅ‚ไธŽๆ–น +2. ๅฆ‚ๆžœ Account ๆœช้…็ฝฎ `signing_parties`๏ผˆ็ฉบๆˆ–NULL๏ผ‰โ†’ ไฝฟ็”จๆ‰€ๆœ‰ๆดป่ทƒ็š„ๅ‚ไธŽๆ–น + +--- + +## 3. ็ญพๅๆ–น้…็ฝฎ API + +**ๆ‰€ๆœ‰็ญพๅๆ–น้…็ฝฎ API ไฝฟ็”จ username ไฝœไธบ่ทฏๅพ„ๅ‚ๆ•ฐ** + +### 3.1 ่ฎพ็ฝฎ็ญพๅๆ–น้…็ฝฎ๏ผˆ้ฆ–ๆฌก๏ผ‰ + +#### ็ซฏ็‚น +``` +POST /accounts/by-username/:username/signing-config +``` + +#### ๆต็จ‹ๅ›พ +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Client โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ Account API โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ Account Repo โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ PostgreSQL โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ POST โ”‚ โ”‚ โ”‚ + โ”‚ /signing-config โ”‚ โ”‚ + โ”‚ {party_ids} โ”‚ โ”‚ โ”‚ + โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ GetByUsername โ”‚ โ”‚ + โ”‚ โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ SELECT โ”‚ + โ”‚ โ”‚ โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ + โ”‚ โ”‚ โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ + โ”‚ โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ ๆฃ€ๆŸฅ๏ผš โ”‚ โ”‚ + โ”‚ โ”‚ - ๆ˜ฏๅฆๅทฒ้…็ฝฎ? โ”‚ โ”‚ + โ”‚ โ”‚ - partyๆ•ฐ้‡=t? โ”‚ โ”‚ + โ”‚ โ”‚โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚โ—€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ Update โ”‚ โ”‚ + โ”‚ โ”‚ (signing_parties) โ”‚ โ”‚ + โ”‚ โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ UPDATE โ”‚ + โ”‚ โ”‚ โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ + โ”‚ โ”‚ โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ + โ”‚ โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ {success, โ”‚ โ”‚ โ”‚ + โ”‚ username, โ”‚ โ”‚ โ”‚ + โ”‚ party_ids} โ”‚ โ”‚ โ”‚ + โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ โ”‚ +``` + +#### ่ฏทๆฑ‚ๅ‚ๆ•ฐ +```json +{ + "party_ids": ["party1", "party2"] // ็ญพๅๆ–นIDๅˆ—่กจ๏ผˆๅฟ…ๅกซ๏ผŒๆ•ฐ้‡ๅฟ…้กป็ญ‰ไบŽthreshold_t๏ผ‰ +} +``` + +#### ๅ“ๅบ” +```json +{ + "message": "signing parties configured successfully", + "username": "string", + "signing_parties": ["party1", "party2"], + "threshold_t": 2 +} +``` + +#### ้”™่ฏฏๅ“ๅบ” +- `400 Bad Request`: ๅ‚ไธŽๆ–นๆ•ฐ้‡ไธ็ญ‰ไบŽ threshold_t +- `409 Conflict`: ๅทฒๅญ˜ๅœจ้…็ฝฎ๏ผˆๅบ”ไฝฟ็”จ PUT ๆ›ดๆ–ฐ๏ผ‰ +- `404 Not Found`: username ไธๅญ˜ๅœจ + +--- + +### 3.2 ๆ›ดๆ–ฐ็ญพๅๆ–น้…็ฝฎ + +#### ็ซฏ็‚น +``` +PUT /accounts/by-username/:username/signing-config +``` + +#### ่ฏทๆฑ‚ๅ‚ๆ•ฐ +```json +{ + "party_ids": ["party1", "party3"] // ๆ–ฐ็š„็ญพๅๆ–นIDๅˆ—่กจ +} +``` + +#### ๅ“ๅบ” +```json +{ + "message": "signing parties updated successfully", + "username": "string", + "signing_parties": ["party1", "party3"], + "threshold_t": 2 +} +``` + +#### ้”™่ฏฏๅ“ๅบ” +- `400 Bad Request`: ๅ‚ไธŽๆ–นๆ•ฐ้‡ไธ็ญ‰ไบŽ threshold_t +- `404 Not Found`: username ไธๅญ˜ๅœจๆˆ–ๆœช้…็ฝฎ็ญพๅๆ–น + +--- + +### 3.3 ๆธ…้™ค็ญพๅๆ–น้…็ฝฎ + +#### ็ซฏ็‚น +``` +DELETE /accounts/by-username/:username/signing-config +``` + +#### ๅ“ๅบ” +```json +{ + "message": "signing parties cleared - all active parties will be used for signing", + "username": "string" +} +``` + +--- + +### 3.4 ๆŸฅ่ฏข็ญพๅๆ–น้…็ฝฎ + +#### ็ซฏ็‚น +``` +GET /accounts/by-username/:username/signing-config +``` + +#### ๅ“ๅบ”๏ผˆๅทฒ้…็ฝฎ๏ผ‰ +```json +{ + "configured": true, + "username": "string", + "signing_parties": ["party1", "party2"], + "threshold_t": 2 +} +``` + +#### ๅ“ๅบ”๏ผˆๆœช้…็ฝฎ๏ผ‰ +```json +{ + "configured": false, + "username": "string", + "message": "no signing parties configured - all active parties will be used", + "active_parties": ["party1", "party2", "party3"], + "threshold_t": 2 +} +``` + +--- + +## 4. API ๅ‚ๆ•ฐๆฑ‡ๆ€ป + +| API | Method | ่ทฏๅพ„ | ไธป่ฆๅ‚ๆ•ฐ | ่ฟ”ๅ›žๅ€ผ | +|-----|--------|------|----------|--------| +| Keygen | POST | `/mpc/keygen` | username, threshold_n, threshold_t | session_id, username, status | +| Sign | POST | `/mpc/sign` | username, message_hash, user_share | session_id, username, parties | +| ่ฎพ็ฝฎ็ญพๅๆ–น | POST | `/accounts/by-username/:username/signing-config` | party_ids[] | username, signing_parties | +| ๆ›ดๆ–ฐ็ญพๅๆ–น | PUT | `/accounts/by-username/:username/signing-config` | party_ids[] | username, signing_parties | +| ๆธ…้™ค็ญพๅๆ–น | DELETE | `/accounts/by-username/:username/signing-config` | - | username, message | +| ๆŸฅ่ฏข็ญพๅๆ–น | GET | `/accounts/by-username/:username/signing-config` | - | username, signing_parties, configured | + +--- + +## 5. ็ญพๅๆ–น้€‰ๆ‹ฉ้€ป่พ‘ + +``` + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Sign Request โ”‚ + โ”‚ (username) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ GetByUsername โ”‚ + โ”‚ ๆŸฅ่ฏข Account โ”‚ + โ”‚ SigningParties โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ HasSigningPartiesโ”‚ + โ”‚ Config? โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ YES โ”‚ NO + โ–ผ โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ ไฝฟ็”จ้…็ฝฎ็š„ โ”‚ โ”‚ ๆŸฅ่ฏขๆ‰€ๆœ‰ๆดป่ทƒ็š„ โ”‚ + โ”‚ SigningParties โ”‚ โ”‚ Server Parties โ”‚ + โ”‚ (ๅ›บๅฎš t ไธช) โ”‚ โ”‚ (ๅฏ่ƒฝ > t ไธช) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ ๅˆ›ๅปบ็ญพๅไผš่ฏ โ”‚ + โ”‚ (ๆŒ‡ๅฎšๅ‚ไธŽๆ–น) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### ไฝฟ็”จๅœบๆ™ฏ + +1. **้ป˜่ฎคๆจกๅผ๏ผˆๆœช้…็ฝฎ๏ผ‰** + - ๆ‰€ๆœ‰ๆดป่ทƒ็š„ Server Party ้ƒฝไผšๅ‚ไธŽ็ญพๅ + - ้€‚็”จไบŽ้œ€่ฆๆœ€ๅคง็ตๆดปๆ€ง็š„ๅœบๆ™ฏ + +2. **้…็ฝฎๆจกๅผ๏ผˆๅทฒ้…็ฝฎ๏ผ‰** + - ๅชๆœ‰ๆŒ‡ๅฎš็š„ Party ๅ‚ไธŽ็ญพๅ + - ้€‚็”จไบŽๅ›บๅฎš็ญพๅๆ–น็ป„ๅˆ็š„ๅœบๆ™ฏ + - ไพ‹ๅฆ‚๏ผšๆŒ‡ๅฎš "server-party-1" ๅ’Œ "delegate-party" ไฝœไธบ็ญพๅๆ–น + +### ้ชŒ่ฏ่ง„ๅˆ™ + +- `party_ids` ๆ•ฐ้‡ๅฟ…้กป็ญ‰ไบŽ่ดฆๆˆท็š„ `threshold_t` +- `party_ids` ไธญไธ่ƒฝๆœ‰้‡ๅค้กน +- `party_ids` ไธญไธ่ƒฝๆœ‰็ฉบๅญ—็ฌฆไธฒ +- `party_ids` ๅฟ…้กปๆ˜ฏ่ดฆๆˆท็š„ๆดป่ทƒ share + +--- + +## 6. ไบ‹ไปถๅž‹ๆ•ฐๆฎๅบ“่ฎพ่ฎก + +### ่ฎพ่ฎกๅŽŸๅˆ™ + +ไธบไฟ่ฏๆ•ฐๆฎๅฎ‰ๅ…จๆ€งๅ’Œๅฏ่ฟฝๆบฏๆ€ง๏ผŒ้‡‡็”จไบ‹ไปถๆบฏๆบ๏ผˆEvent Sourcing๏ผ‰ๆจกๅผ๏ผš + +- **ๅชๆ’ๅ…ฅ๏ผŒไธไฟฎๆ”น** - ๆ‰€ๆœ‰็Šถๆ€ๅ˜ๆ›ด้ƒฝไฝœไธบๆ–ฐไบ‹ไปถๆ’ๅ…ฅ +- **ไธๅฏๅ˜ๆ—ฅๅฟ—** - ไบ‹ไปถ่ฎฐๅฝ•ไธ€ๆ—ฆๅ†™ๅ…ฅไธๅฏๆ›ดๆ”น +- **็Šถๆ€ๅฏ้‡ๅปบ** - ๅฝ“ๅ‰็Šถๆ€ๅฏ้€š่ฟ‡้‡ๆ”พไบ‹ไปถๅพ—ๅˆฐ + +### session_events ่กจ็ป“ๆž„ + +```sql +CREATE TABLE session_events ( + id UUID PRIMARY KEY, + session_id UUID NOT NULL, + username VARCHAR(255) NOT NULL, -- ็”จๆˆทๅ”ฏไธ€ๆ ‡่ฏ† + event_type VARCHAR(50) NOT NULL, -- ไบ‹ไปถ็ฑปๅž‹ + session_type VARCHAR(20) NOT NULL, -- keygen ๆˆ– sign + + -- ไบ‹ไปถๅฟซ็…งๆ•ฐๆฎ + threshold_n INTEGER, + threshold_t INTEGER, + party_id VARCHAR(255), + party_index INTEGER, + message_hash BYTEA, + public_key BYTEA, + signature BYTEA, + error_message TEXT, + + metadata JSONB, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +### ไบ‹ไปถ็ฑปๅž‹ + +| ไบ‹ไปถ็ฑปๅž‹ | ๆ่ฟฐ | +|---------|------| +| `session_created` | ไผš่ฏๅˆ›ๅปบ | +| `party_joined` | ๅ‚ไธŽๆ–นๅŠ ๅ…ฅ | +| `party_ready` | ๅ‚ไธŽๆ–นๅฐฑ็ปช | +| `round_started` | MPC ่ฝฎๆฌกๅผ€ๅง‹ | +| `round_completed` | MPC ่ฝฎๆฌกๅฎŒๆˆ | +| `session_completed` | ไผš่ฏๆˆๅŠŸๅฎŒๆˆ | +| `session_failed` | ไผš่ฏๅคฑ่ดฅ | +| `session_expired` | ไผš่ฏ่ฟ‡ๆœŸ | +| `delegate_share_sent` | ๅง”ๆ‰˜ share ๅทฒๅ‘้€็ป™็”จๆˆท | +| `signing_config_set` | ็ญพๅๆ–น้…็ฝฎๅทฒ่ฎพ็ฝฎ | +| `signing_config_cleared` | ็ญพๅๆ–น้…็ฝฎๅทฒๆธ…้™ค | + +### ๆŸฅ่ฏขๅฝ“ๅ‰็Šถๆ€ + +้€š่ฟ‡่ง†ๅ›พ `session_current_state` ่Žทๅ–ๆœ€ๆ–ฐ็Šถๆ€๏ผš + +```sql +SELECT * FROM session_current_state WHERE username = 'alice'; +``` + +--- + +## ๆ›ดๆ–ฐๆ—ฅๅฟ— + +| ๆ—ฅๆœŸ | ็‰ˆๆœฌ | ๆ่ฟฐ | +|------|------|------| +| 2024-XX-XX | 1.0 | ๅˆๅง‹็‰ˆๆœฌ | +| 2024-XX-XX | 2.0 | ๅ…จ้ข้‡‡็”จ username ไฝœไธบๅ”ฏไธ€ๆ ‡่ฏ†๏ผŒๆทปๅŠ ไบ‹ไปถๅž‹ๆ•ฐๆฎๅบ“่ฎพ่ฎก | diff --git a/backend/mpc-system/migrations/003_add_session_events.down.sql b/backend/mpc-system/migrations/003_add_session_events.down.sql new file mode 100644 index 00000000..3a60ba77 --- /dev/null +++ b/backend/mpc-system/migrations/003_add_session_events.down.sql @@ -0,0 +1,13 @@ +-- Rollback: Remove session events table and view + +DROP VIEW IF EXISTS session_current_state; + +DROP INDEX IF EXISTS idx_session_events_user_sessions; +DROP INDEX IF EXISTS idx_session_events_session_timeline; +DROP INDEX IF EXISTS idx_session_events_session_type; +DROP INDEX IF EXISTS idx_session_events_event_type; +DROP INDEX IF EXISTS idx_session_events_created_at; +DROP INDEX IF EXISTS idx_session_events_username; +DROP INDEX IF EXISTS idx_session_events_session_id; + +DROP TABLE IF EXISTS session_events; diff --git a/backend/mpc-system/migrations/003_add_session_events.up.sql b/backend/mpc-system/migrations/003_add_session_events.up.sql new file mode 100644 index 00000000..9f130dd1 --- /dev/null +++ b/backend/mpc-system/migrations/003_add_session_events.up.sql @@ -0,0 +1,77 @@ +-- Session Events table (Event Sourcing pattern - insert only, never update) +-- This provides an immutable audit trail of all session state changes +-- The current session state can be derived by replaying events + +CREATE TABLE session_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL, + username VARCHAR(255) NOT NULL, -- The user this session belongs to + event_type VARCHAR(50) NOT NULL, -- Event type: created, joined, ready, completed, failed, expired + session_type VARCHAR(20) NOT NULL, -- 'keygen' or 'sign' + + -- Event payload (immutable snapshot at event time) + threshold_n INTEGER, + threshold_t INTEGER, + party_id VARCHAR(255), -- For party-specific events (joined, ready, completed) + party_index INTEGER, + message_hash BYTEA, -- For sign sessions + public_key BYTEA, -- Result of keygen + signature BYTEA, -- Result of sign + error_message TEXT, -- For failed events + + -- Metadata + metadata JSONB, -- Additional event-specific data + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT chk_session_event_type CHECK (event_type IN ( + 'session_created', -- Session initiated + 'party_joined', -- A party joined the session + 'party_ready', -- A party is ready to start MPC + 'round_started', -- MPC round started + 'round_completed', -- MPC round completed + 'session_completed', -- Session completed successfully + 'session_failed', -- Session failed + 'session_expired', -- Session expired + 'delegate_share_sent', -- Delegate share sent to user + 'signing_config_set', -- Signing parties configured + 'signing_config_cleared' -- Signing parties cleared + )), + CONSTRAINT chk_session_event_session_type CHECK (session_type IN ('keygen', 'sign')) +); + +-- Indexes for efficient querying +CREATE INDEX idx_session_events_session_id ON session_events(session_id); +CREATE INDEX idx_session_events_username ON session_events(username); +CREATE INDEX idx_session_events_created_at ON session_events(created_at); +CREATE INDEX idx_session_events_event_type ON session_events(event_type); +CREATE INDEX idx_session_events_session_type ON session_events(session_type); + +-- Composite index for common queries +CREATE INDEX idx_session_events_session_timeline ON session_events(session_id, created_at); +CREATE INDEX idx_session_events_user_sessions ON session_events(username, session_type, created_at DESC); + +-- Comments +COMMENT ON TABLE session_events IS 'Immutable event log for MPC sessions - append only, never update or delete'; +COMMENT ON COLUMN session_events.username IS 'The unique user identifier that links all MPC operations'; +COMMENT ON COLUMN session_events.event_type IS 'Type of event that occurred'; +COMMENT ON COLUMN session_events.metadata IS 'Additional JSON data specific to the event type'; + +-- View to get current session state by replaying events +CREATE OR REPLACE VIEW session_current_state AS +SELECT DISTINCT ON (session_id) + session_id, + username, + session_type, + threshold_n, + threshold_t, + event_type as current_status, + public_key, + signature, + error_message, + created_at as last_event_at, + (SELECT MIN(created_at) FROM session_events e2 WHERE e2.session_id = session_events.session_id) as started_at +FROM session_events +ORDER BY session_id, created_at DESC; + +COMMENT ON VIEW session_current_state IS 'Derived view showing current state of each session from event log'; diff --git a/backend/mpc-system/services/account/adapters/input/http/account_handler.go b/backend/mpc-system/services/account/adapters/input/http/account_handler.go index cefd8465..3e577bd3 100644 --- a/backend/mpc-system/services/account/adapters/input/http/account_handler.go +++ b/backend/mpc-system/services/account/adapters/input/http/account_handler.go @@ -13,12 +13,15 @@ import ( "github.com/rwadurian/mpc-system/services/account/application/ports" "github.com/rwadurian/mpc-system/services/account/application/use_cases" "github.com/rwadurian/mpc-system/services/account/domain/entities" + "github.com/rwadurian/mpc-system/services/account/domain/repositories" "github.com/rwadurian/mpc-system/services/account/domain/value_objects" "go.uber.org/zap" ) // AccountHTTPHandler handles HTTP requests for accounts type AccountHTTPHandler struct { + accountRepo repositories.AccountRepository + sessionEventRepo repositories.SessionEventRepository createAccountUC *use_cases.CreateAccountUseCase getAccountUC *use_cases.GetAccountUseCase updateAccountUC *use_cases.UpdateAccountUseCase @@ -37,6 +40,8 @@ type AccountHTTPHandler struct { // NewAccountHTTPHandler creates a new AccountHTTPHandler func NewAccountHTTPHandler( + accountRepo repositories.AccountRepository, + sessionEventRepo repositories.SessionEventRepository, createAccountUC *use_cases.CreateAccountUseCase, getAccountUC *use_cases.GetAccountUseCase, updateAccountUC *use_cases.UpdateAccountUseCase, @@ -53,6 +58,8 @@ func NewAccountHTTPHandler( sessionCoordinatorClient *grpcclient.SessionCoordinatorClient, ) *AccountHTTPHandler { return &AccountHTTPHandler{ + accountRepo: accountRepo, + sessionEventRepo: sessionEventRepo, createAccountUC: createAccountUC, getAccountUC: getAccountUC, updateAccountUC: updateAccountUC, @@ -81,11 +88,11 @@ func (h *AccountHTTPHandler) RegisterRoutes(router *gin.RouterGroup) { accounts.PUT("/:id", h.UpdateAccount) accounts.GET("/:id/shares", h.GetAccountShares) accounts.DELETE("/:id/shares/:shareId", h.DeactivateShare) - // Signing parties configuration - accounts.POST("/:id/signing-config", h.SetSigningParties) - accounts.PUT("/:id/signing-config", h.UpdateSigningParties) - accounts.DELETE("/:id/signing-config", h.ClearSigningParties) - accounts.GET("/:id/signing-config", h.GetSigningParties) + // Signing parties configuration (use username as path parameter) + accounts.POST("/by-username/:username/signing-config", h.SetSigningParties) + accounts.PUT("/by-username/:username/signing-config", h.UpdateSigningParties) + accounts.DELETE("/by-username/:username/signing-config", h.ClearSigningParties) + accounts.GET("/by-username/:username/signing-config", h.GetSigningParties) } auth := router.Group("/auth") @@ -544,9 +551,10 @@ func (h *AccountHTTPHandler) CancelRecovery(c *gin.Context) { // CreateKeygenSessionRequest represents the request for creating a keygen session // Coordinator will automatically select parties from registered pool type CreateKeygenSessionRequest struct { - ThresholdN int `json:"threshold_n" binding:"required,min=2"` // Total number of parties (e.g., 3) - ThresholdT int `json:"threshold_t" binding:"required,min=1"` // Threshold for signing (e.g., 2) - RequireDelegate bool `json:"require_delegate"` // If true, one party will be delegate (returns share to user) + Username string `json:"username" binding:"required"` // Username - the unique identifier for all relationships + ThresholdN int `json:"threshold_n" binding:"required,min=2"` // Total number of parties (e.g., 3) + ThresholdT int `json:"threshold_t" binding:"required,min=1"` // Threshold for signing (e.g., 2) + RequireDelegate bool `json:"require_delegate"` // If true, one party will be delegate (returns share to user) } // CreateKeygenSession handles creating a new keygen session @@ -564,11 +572,23 @@ func (h *AccountHTTPHandler) CreateKeygenSession(c *gin.Context) { return } + // Check if username already exists (keygen should be for new users) + exists, err := h.accountRepo.ExistsByUsername(c.Request.Context(), req.Username) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check username"}) + return + } + if exists { + c.JSON(http.StatusConflict, gin.H{"error": "username already exists, use sign API instead"}) + return + } + // Call session coordinator via gRPC (no participants - coordinator selects automatically) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() logger.Info("Calling CreateKeygenSession via gRPC (auto party selection)", + zap.String("username", req.Username), zap.Int("threshold_n", req.ThresholdN), zap.Int("threshold_t", req.ThresholdT), zap.Bool("require_delegate", req.RequireDelegate)) @@ -604,10 +624,30 @@ func (h *AccountHTTPHandler) CreateKeygenSession(c *gin.Context) { zap.String("session_id", resp.SessionID), zap.Int("num_parties", len(resp.SelectedParties))) + // Record session_created event + sessionID, _ := uuid.Parse(resp.SessionID) + event := repositories.NewSessionEvent( + sessionID, + req.Username, + repositories.EventSessionCreated, + repositories.SessionTypeKeygen, + ).WithThreshold(req.ThresholdN, req.ThresholdT).WithMetadata(map[string]interface{}{ + "selected_parties": resp.SelectedParties, + "delegate_party": resp.DelegateParty, + "require_delegate": req.RequireDelegate, + "persistent_count": persistentCount, + "delegate_count": delegateCount, + }) + if err := h.sessionEventRepo.Create(c.Request.Context(), event); err != nil { + logger.Error("Failed to record session event", zap.Error(err)) + // Don't fail the request, just log the error + } + // Return response with selected parties info c.JSON(http.StatusCreated, gin.H{ "session_id": resp.SessionID, "session_type": "keygen", + "username": req.Username, // Include username in response for reference "threshold_n": req.ThresholdN, "threshold_t": req.ThresholdT, "selected_parties": resp.SelectedParties, @@ -619,7 +659,7 @@ func (h *AccountHTTPHandler) CreateKeygenSession(c *gin.Context) { // CreateSigningSessionRequest represents the request for creating a signing session // Coordinator will automatically select parties based on account's registered shares type CreateSigningSessionRequest struct { - AccountID string `json:"account_id" binding:"required"` // Account to sign for + Username string `json:"username" binding:"required"` // Username - the unique identifier for all relationships MessageHash string `json:"message_hash" binding:"required"` // SHA-256 hash to sign (hex encoded) UserShare string `json:"user_share"` // Required if account has delegate share: user's encrypted share (hex) } @@ -633,13 +673,6 @@ func (h *AccountHTTPHandler) CreateSigningSession(c *gin.Context) { return } - // Validate account ID - accountID, err := value_objects.AccountIDFromString(req.AccountID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) - return - } - // Decode message hash messageHash, err := hex.DecodeString(req.MessageHash) if err != nil { @@ -652,12 +685,12 @@ func (h *AccountHTTPHandler) CreateSigningSession(c *gin.Context) { return } - // Get account to verify it exists and get share info + // Get account by username to verify it exists and get share info accountOutput, err := h.getAccountUC.Execute(c.Request.Context(), ports.GetAccountInput{ - AccountID: &accountID, + Username: &req.Username, }) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "account not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "account not found for username: " + req.Username}) return } @@ -696,14 +729,14 @@ func (h *AccountHTTPHandler) CreateSigningSession(c *gin.Context) { partyIDs = configuredParties logger.Info("Using configured signing parties", - zap.String("account_id", req.AccountID), + zap.String("username", req.Username), zap.Strings("configured_parties", partyIDs)) } else { // Use all active parties (original behavior) partyIDs = allActivePartyIDs logger.Info("Using all active parties for signing", - zap.String("account_id", req.AccountID), + zap.String("username", req.Username), zap.Strings("active_parties", partyIDs)) } @@ -757,13 +790,13 @@ func (h *AccountHTTPHandler) CreateSigningSession(c *gin.Context) { PartyIndex: int32(delegateShare.PartyIndex), } logger.Info("Calling CreateSigningSession with delegate user share", - zap.String("account_id", req.AccountID), + zap.String("username", req.Username), zap.String("delegate_party_id", delegateShare.PartyID), zap.Int("threshold_t", accountOutput.Account.ThresholdT), zap.Int("available_parties", len(partyIDs))) } else { logger.Info("Calling CreateSigningSession via gRPC (auto party selection)", - zap.String("account_id", req.AccountID), + zap.String("username", req.Username), zap.Int("threshold_t", accountOutput.Account.ThresholdT), zap.Int("available_parties", len(partyIDs))) } @@ -787,10 +820,32 @@ func (h *AccountHTTPHandler) CreateSigningSession(c *gin.Context) { zap.String("session_id", resp.SessionID), zap.Int("num_parties", len(resp.SelectedParties))) + // Record session_created event for sign + signSessionID, _ := uuid.Parse(resp.SessionID) + signEvent := repositories.NewSessionEvent( + signSessionID, + req.Username, + repositories.EventSessionCreated, + repositories.SessionTypeSign, + ).WithThreshold(accountOutput.Account.ThresholdN, accountOutput.Account.ThresholdT). + WithMessageHash(messageHash). + WithMetadata(map[string]interface{}{ + "selected_parties": resp.SelectedParties, + "has_delegate": delegateShare != nil, + "signing_parties_config": accountOutput.Account.HasSigningPartiesConfig(), + }) + if delegateShare != nil { + signEvent = signEvent.WithParty(delegateShare.PartyID, delegateShare.PartyIndex) + } + if err := h.sessionEventRepo.Create(c.Request.Context(), signEvent); err != nil { + logger.Error("Failed to record session event", zap.Error(err)) + // Don't fail the request, just log the error + } + response := gin.H{ "session_id": resp.SessionID, "session_type": "sign", - "account_id": req.AccountID, + "username": req.Username, "message_hash": req.MessageHash, "threshold_t": accountOutput.Account.ThresholdT, "selected_parties": resp.SelectedParties, @@ -986,12 +1041,11 @@ type SigningPartiesRequest struct { } // SetSigningParties handles setting signing parties for the first time -// POST /accounts/:id/signing-config +// POST /accounts/by-username/:username/signing-config func (h *AccountHTTPHandler) SetSigningParties(c *gin.Context) { - idStr := c.Param("id") - accountID, err := value_objects.AccountIDFromString(idStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) + username := c.Param("username") + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"}) return } @@ -1001,12 +1055,12 @@ func (h *AccountHTTPHandler) SetSigningParties(c *gin.Context) { return } - // Get account + // Get account by username accountOutput, err := h.getAccountUC.Execute(c.Request.Context(), ports.GetAccountInput{ - AccountID: &accountID, + Username: &username, }) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "account not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "account not found for username: " + username}) return } @@ -1045,7 +1099,7 @@ func (h *AccountHTTPHandler) SetSigningParties(c *gin.Context) { // Update account in database _, err = h.updateAccountUC.Execute(c.Request.Context(), ports.UpdateAccountInput{ - AccountID: accountID, + AccountID: accountOutput.Account.ID, SigningParties: req.PartyIDs, }) if err != nil { @@ -1054,23 +1108,38 @@ func (h *AccountHTTPHandler) SetSigningParties(c *gin.Context) { } logger.Info("Signing parties configured", - zap.String("account_id", idStr), + zap.String("username", username), zap.Strings("signing_parties", req.PartyIDs)) + // Record signing_config_set event + event := repositories.NewSessionEvent( + uuid.New(), // Generate new event ID (no session for config changes) + username, + repositories.EventSigningConfigSet, + repositories.SessionTypeSign, // Configuration is for signing + ).WithThreshold(accountOutput.Account.ThresholdN, accountOutput.Account.ThresholdT). + WithMetadata(map[string]interface{}{ + "operation": "set", + "signing_parties": req.PartyIDs, + }) + if err := h.sessionEventRepo.Create(c.Request.Context(), event); err != nil { + logger.Error("Failed to record signing config event", zap.Error(err)) + } + c.JSON(http.StatusCreated, gin.H{ "message": "signing parties configured successfully", + "username": username, "signing_parties": req.PartyIDs, "threshold_t": accountOutput.Account.ThresholdT, }) } // UpdateSigningParties handles updating existing signing parties configuration -// PUT /accounts/:id/signing-config +// PUT /accounts/by-username/:username/signing-config func (h *AccountHTTPHandler) UpdateSigningParties(c *gin.Context) { - idStr := c.Param("id") - accountID, err := value_objects.AccountIDFromString(idStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) + username := c.Param("username") + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"}) return } @@ -1080,12 +1149,12 @@ func (h *AccountHTTPHandler) UpdateSigningParties(c *gin.Context) { return } - // Get account + // Get account by username accountOutput, err := h.getAccountUC.Execute(c.Request.Context(), ports.GetAccountInput{ - AccountID: &accountID, + Username: &username, }) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "account not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "account not found for username: " + username}) return } @@ -1123,7 +1192,7 @@ func (h *AccountHTTPHandler) UpdateSigningParties(c *gin.Context) { // Update account in database _, err = h.updateAccountUC.Execute(c.Request.Context(), ports.UpdateAccountInput{ - AccountID: accountID, + AccountID: accountOutput.Account.ID, SigningParties: req.PartyIDs, }) if err != nil { @@ -1132,32 +1201,47 @@ func (h *AccountHTTPHandler) UpdateSigningParties(c *gin.Context) { } logger.Info("Signing parties updated", - zap.String("account_id", idStr), + zap.String("username", username), zap.Strings("signing_parties", req.PartyIDs)) + // Record signing_config_set event for update + updateEvent := repositories.NewSessionEvent( + uuid.New(), + username, + repositories.EventSigningConfigSet, + repositories.SessionTypeSign, + ).WithThreshold(accountOutput.Account.ThresholdN, accountOutput.Account.ThresholdT). + WithMetadata(map[string]interface{}{ + "operation": "update", + "signing_parties": req.PartyIDs, + }) + if err := h.sessionEventRepo.Create(c.Request.Context(), updateEvent); err != nil { + logger.Error("Failed to record signing config event", zap.Error(err)) + } + c.JSON(http.StatusOK, gin.H{ "message": "signing parties updated successfully", + "username": username, "signing_parties": req.PartyIDs, "threshold_t": accountOutput.Account.ThresholdT, }) } // ClearSigningParties handles clearing signing parties configuration -// DELETE /accounts/:id/signing-config +// DELETE /accounts/by-username/:username/signing-config func (h *AccountHTTPHandler) ClearSigningParties(c *gin.Context) { - idStr := c.Param("id") - accountID, err := value_objects.AccountIDFromString(idStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) + username := c.Param("username") + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"}) return } - // Get account + // Get account by username accountOutput, err := h.getAccountUC.Execute(c.Request.Context(), ports.GetAccountInput{ - AccountID: &accountID, + Username: &username, }) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "account not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "account not found for username: " + username}) return } @@ -1171,7 +1255,7 @@ func (h *AccountHTTPHandler) ClearSigningParties(c *gin.Context) { // Clear signing parties - pass empty slice _, err = h.updateAccountUC.Execute(c.Request.Context(), ports.UpdateAccountInput{ - AccountID: accountID, + AccountID: accountOutput.Account.ID, ClearSigningParties: true, }) if err != nil { @@ -1180,35 +1264,50 @@ func (h *AccountHTTPHandler) ClearSigningParties(c *gin.Context) { } logger.Info("Signing parties cleared", - zap.String("account_id", idStr)) + zap.String("username", username)) + + // Record signing_config_cleared event + clearEvent := repositories.NewSessionEvent( + uuid.New(), + username, + repositories.EventSigningConfigCleared, + repositories.SessionTypeSign, + ).WithThreshold(accountOutput.Account.ThresholdN, accountOutput.Account.ThresholdT). + WithMetadata(map[string]interface{}{ + "operation": "clear", + }) + if err := h.sessionEventRepo.Create(c.Request.Context(), clearEvent); err != nil { + logger.Error("Failed to record signing config event", zap.Error(err)) + } c.JSON(http.StatusOK, gin.H{ - "message": "signing parties cleared - all active parties will be used for signing", + "message": "signing parties cleared - all active parties will be used for signing", + "username": username, }) } // GetSigningParties handles getting current signing parties configuration -// GET /accounts/:id/signing-config +// GET /accounts/by-username/:username/signing-config func (h *AccountHTTPHandler) GetSigningParties(c *gin.Context) { - idStr := c.Param("id") - accountID, err := value_objects.AccountIDFromString(idStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) + username := c.Param("username") + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"}) return } - // Get account + // Get account by username accountOutput, err := h.getAccountUC.Execute(c.Request.Context(), ports.GetAccountInput{ - AccountID: &accountID, + Username: &username, }) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "account not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "account not found for username: " + username}) return } if accountOutput.Account.HasSigningPartiesConfig() { c.JSON(http.StatusOK, gin.H{ "configured": true, + "username": username, "signing_parties": accountOutput.Account.GetSigningParties(), "threshold_t": accountOutput.Account.ThresholdT, }) @@ -1222,6 +1321,7 @@ func (h *AccountHTTPHandler) GetSigningParties(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ "configured": false, + "username": username, "message": "no signing parties configured - all active parties will be used", "active_parties": activeParties, "threshold_t": accountOutput.Account.ThresholdT, diff --git a/backend/mpc-system/services/account/adapters/output/postgres/session_event_repo.go b/backend/mpc-system/services/account/adapters/output/postgres/session_event_repo.go new file mode 100644 index 00000000..fc9605ed --- /dev/null +++ b/backend/mpc-system/services/account/adapters/output/postgres/session_event_repo.go @@ -0,0 +1,231 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + + "github.com/google/uuid" + "github.com/rwadurian/mpc-system/services/account/domain/repositories" +) + +// SessionEventPostgresRepo implements SessionEventRepository using PostgreSQL +// This is an append-only repository - events are never updated or deleted +type SessionEventPostgresRepo struct { + db *sql.DB +} + +// NewSessionEventPostgresRepo creates a new SessionEventPostgresRepo +func NewSessionEventPostgresRepo(db *sql.DB) repositories.SessionEventRepository { + return &SessionEventPostgresRepo{db: db} +} + +// Create inserts a new event (append-only, never update) +func (r *SessionEventPostgresRepo) Create(ctx context.Context, event *repositories.SessionEvent) error { + query := ` + INSERT INTO session_events ( + id, session_id, username, event_type, session_type, + threshold_n, threshold_t, party_id, party_index, + message_hash, public_key, signature, error_message, + metadata, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ` + + var metadataJSON []byte + var err error + if event.Metadata != nil { + metadataJSON, err = json.Marshal(event.Metadata) + if err != nil { + return err + } + } + + _, err = r.db.ExecContext(ctx, query, + event.ID, + event.SessionID, + event.Username, + string(event.EventType), + string(event.SessionType), + event.ThresholdN, + event.ThresholdT, + event.PartyID, + event.PartyIndex, + event.MessageHash, + event.PublicKey, + event.Signature, + event.ErrorMessage, + metadataJSON, + event.CreatedAt, + ) + + return err +} + +// GetBySessionID retrieves all events for a session, ordered by creation time +func (r *SessionEventPostgresRepo) GetBySessionID(ctx context.Context, sessionID uuid.UUID) ([]*repositories.SessionEvent, error) { + query := ` + SELECT id, session_id, username, event_type, session_type, + threshold_n, threshold_t, party_id, party_index, + message_hash, public_key, signature, error_message, + metadata, created_at + FROM session_events + WHERE session_id = $1 + ORDER BY created_at ASC + ` + + rows, err := r.db.QueryContext(ctx, query, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + + return r.scanEvents(rows) +} + +// GetByUsername retrieves all events for a user, ordered by creation time desc +func (r *SessionEventPostgresRepo) GetByUsername(ctx context.Context, username string, limit int) ([]*repositories.SessionEvent, error) { + query := ` + SELECT id, session_id, username, event_type, session_type, + threshold_n, threshold_t, party_id, party_index, + message_hash, public_key, signature, error_message, + metadata, created_at + FROM session_events + WHERE username = $1 + ORDER BY created_at DESC + LIMIT $2 + ` + + rows, err := r.db.QueryContext(ctx, query, username, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + return r.scanEvents(rows) +} + +// GetLatestBySessionID retrieves the most recent event for a session +func (r *SessionEventPostgresRepo) GetLatestBySessionID(ctx context.Context, sessionID uuid.UUID) (*repositories.SessionEvent, error) { + query := ` + SELECT id, session_id, username, event_type, session_type, + threshold_n, threshold_t, party_id, party_index, + message_hash, public_key, signature, error_message, + metadata, created_at + FROM session_events + WHERE session_id = $1 + ORDER BY created_at DESC + LIMIT 1 + ` + + row := r.db.QueryRowContext(ctx, query, sessionID) + return r.scanEvent(row) +} + +// GetByUsernameAndType retrieves events for a user filtered by session type +func (r *SessionEventPostgresRepo) GetByUsernameAndType(ctx context.Context, username string, sessionType repositories.SessionType, limit int) ([]*repositories.SessionEvent, error) { + query := ` + SELECT id, session_id, username, event_type, session_type, + threshold_n, threshold_t, party_id, party_index, + message_hash, public_key, signature, error_message, + metadata, created_at + FROM session_events + WHERE username = $1 AND session_type = $2 + ORDER BY created_at DESC + LIMIT $3 + ` + + rows, err := r.db.QueryContext(ctx, query, username, string(sessionType), limit) + if err != nil { + return nil, err + } + defer rows.Close() + + return r.scanEvents(rows) +} + +// scanEvent scans a single event from a row +func (r *SessionEventPostgresRepo) scanEvent(row *sql.Row) (*repositories.SessionEvent, error) { + var event repositories.SessionEvent + var eventType, sessionType string + var metadataJSON []byte + + err := row.Scan( + &event.ID, + &event.SessionID, + &event.Username, + &eventType, + &sessionType, + &event.ThresholdN, + &event.ThresholdT, + &event.PartyID, + &event.PartyIndex, + &event.MessageHash, + &event.PublicKey, + &event.Signature, + &event.ErrorMessage, + &metadataJSON, + &event.CreatedAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + + event.EventType = repositories.SessionEventType(eventType) + event.SessionType = repositories.SessionType(sessionType) + + if len(metadataJSON) > 0 { + if err := json.Unmarshal(metadataJSON, &event.Metadata); err != nil { + return nil, err + } + } + + return &event, nil +} + +// scanEvents scans multiple events from rows +func (r *SessionEventPostgresRepo) scanEvents(rows *sql.Rows) ([]*repositories.SessionEvent, error) { + var events []*repositories.SessionEvent + + for rows.Next() { + var event repositories.SessionEvent + var eventType, sessionType string + var metadataJSON []byte + + err := rows.Scan( + &event.ID, + &event.SessionID, + &event.Username, + &eventType, + &sessionType, + &event.ThresholdN, + &event.ThresholdT, + &event.PartyID, + &event.PartyIndex, + &event.MessageHash, + &event.PublicKey, + &event.Signature, + &event.ErrorMessage, + &metadataJSON, + &event.CreatedAt, + ) + if err != nil { + return nil, err + } + + event.EventType = repositories.SessionEventType(eventType) + event.SessionType = repositories.SessionType(sessionType) + + if len(metadataJSON) > 0 { + if err := json.Unmarshal(metadataJSON, &event.Metadata); err != nil { + return nil, err + } + } + + events = append(events, &event) + } + + return events, rows.Err() +} diff --git a/backend/mpc-system/services/account/cmd/server/main.go b/backend/mpc-system/services/account/cmd/server/main.go index 832b65ee..2320fee3 100644 --- a/backend/mpc-system/services/account/cmd/server/main.go +++ b/backend/mpc-system/services/account/cmd/server/main.go @@ -25,6 +25,7 @@ import ( "github.com/rwadurian/mpc-system/services/account/adapters/output/memory" "github.com/rwadurian/mpc-system/services/account/adapters/output/postgres" "github.com/rwadurian/mpc-system/services/account/application/use_cases" + "github.com/rwadurian/mpc-system/services/account/domain/repositories" "github.com/rwadurian/mpc-system/services/account/domain/services" "go.uber.org/zap" ) @@ -77,6 +78,7 @@ func main() { accountRepo := postgres.NewAccountPostgresRepo(db) shareRepo := postgres.NewAccountSharePostgresRepo(db) recoveryRepo := postgres.NewRecoverySessionPostgresRepo(db) + sessionEventRepo := postgres.NewSessionEventPostgresRepo(db) // Initialize adapters (using in-memory implementations) eventPublisher := memory.NewEventPublisherAdapter() @@ -119,6 +121,8 @@ func main() { if err := startHTTPServer( cfg, jwtService, + accountRepo, + sessionEventRepo, createAccountUC, getAccountUC, updateAccountUC, @@ -219,6 +223,8 @@ func initDatabase(cfg config.DatabaseConfig) (*sql.DB, error) { func startHTTPServer( cfg *config.Config, jwtService *jwt.JWTService, + accountRepo repositories.AccountRepository, + sessionEventRepo repositories.SessionEventRepository, createAccountUC *use_cases.CreateAccountUseCase, getAccountUC *use_cases.GetAccountUseCase, updateAccountUC *use_cases.UpdateAccountUseCase, @@ -268,6 +274,8 @@ func startHTTPServer( // Create HTTP handler with session coordinator client httpHandler := httphandler.NewAccountHTTPHandler( + accountRepo, + sessionEventRepo, createAccountUC, getAccountUC, updateAccountUC, diff --git a/backend/mpc-system/services/account/domain/repositories/session_event_repository.go b/backend/mpc-system/services/account/domain/repositories/session_event_repository.go new file mode 100644 index 00000000..d3539843 --- /dev/null +++ b/backend/mpc-system/services/account/domain/repositories/session_event_repository.go @@ -0,0 +1,137 @@ +package repositories + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// SessionEventType represents the type of session event +type SessionEventType string + +const ( + // Session lifecycle events + EventSessionCreated SessionEventType = "session_created" + EventPartyJoined SessionEventType = "party_joined" + EventPartyReady SessionEventType = "party_ready" + EventRoundStarted SessionEventType = "round_started" + EventRoundCompleted SessionEventType = "round_completed" + EventSessionCompleted SessionEventType = "session_completed" + EventSessionFailed SessionEventType = "session_failed" + EventSessionExpired SessionEventType = "session_expired" + + // Delegate events + EventDelegateShareSent SessionEventType = "delegate_share_sent" + + // Signing config events + EventSigningConfigSet SessionEventType = "signing_config_set" + EventSigningConfigCleared SessionEventType = "signing_config_cleared" +) + +// SessionType represents the type of MPC session +type SessionType string + +const ( + SessionTypeKeygen SessionType = "keygen" + SessionTypeSign SessionType = "sign" +) + +// SessionEvent represents an immutable event in the session lifecycle +type SessionEvent struct { + ID uuid.UUID + SessionID uuid.UUID + Username string + EventType SessionEventType + SessionType SessionType + ThresholdN *int + ThresholdT *int + PartyID *string + PartyIndex *int + MessageHash []byte + PublicKey []byte + Signature []byte + ErrorMessage *string + Metadata map[string]interface{} + CreatedAt time.Time +} + +// NewSessionEvent creates a new session event with required fields +func NewSessionEvent( + sessionID uuid.UUID, + username string, + eventType SessionEventType, + sessionType SessionType, +) *SessionEvent { + return &SessionEvent{ + ID: uuid.New(), + SessionID: sessionID, + Username: username, + EventType: eventType, + SessionType: sessionType, + CreatedAt: time.Now().UTC(), + } +} + +// WithThreshold adds threshold info to the event +func (e *SessionEvent) WithThreshold(n, t int) *SessionEvent { + e.ThresholdN = &n + e.ThresholdT = &t + return e +} + +// WithParty adds party info to the event +func (e *SessionEvent) WithParty(partyID string, partyIndex int) *SessionEvent { + e.PartyID = &partyID + e.PartyIndex = &partyIndex + return e +} + +// WithMessageHash adds message hash to the event +func (e *SessionEvent) WithMessageHash(hash []byte) *SessionEvent { + e.MessageHash = hash + return e +} + +// WithPublicKey adds public key to the event +func (e *SessionEvent) WithPublicKey(key []byte) *SessionEvent { + e.PublicKey = key + return e +} + +// WithSignature adds signature to the event +func (e *SessionEvent) WithSignature(sig []byte) *SessionEvent { + e.Signature = sig + return e +} + +// WithError adds error message to the event +func (e *SessionEvent) WithError(msg string) *SessionEvent { + e.ErrorMessage = &msg + return e +} + +// WithMetadata adds metadata to the event +func (e *SessionEvent) WithMetadata(metadata map[string]interface{}) *SessionEvent { + e.Metadata = metadata + return e +} + +// SessionEventRepository defines the interface for session event persistence +// This is an append-only repository - events are never updated or deleted +type SessionEventRepository interface { + // Create inserts a new event (append-only, never update) + Create(ctx context.Context, event *SessionEvent) error + + // GetBySessionID retrieves all events for a session, ordered by creation time + GetBySessionID(ctx context.Context, sessionID uuid.UUID) ([]*SessionEvent, error) + + // GetByUsername retrieves all events for a user, ordered by creation time desc + GetByUsername(ctx context.Context, username string, limit int) ([]*SessionEvent, error) + + // GetLatestBySessionID retrieves the most recent event for a session + GetLatestBySessionID(ctx context.Context, sessionID uuid.UUID) (*SessionEvent, error) + + // GetByUsernameAndType retrieves events for a user filtered by session type + GetByUsernameAndType(ctx context.Context, username string, sessionType SessionType, limit int) ([]*SessionEvent, error) +}