From 153850dca31d9f3fac5c2034009f68733bc03548 Mon Sep 17 00:00:00 2001 From: Zainab Wahab Date: Sun, 27 Sep 2026 08:24:19 +0100 Subject: [PATCH 1/3] feat: implement multisig proposal queue API (#961) - Add MultisigProposal and MultisigSignature Prisma models with migration - Implement GET /admin/proposals (paginated queue with status filter) - Implement GET /admin/proposals/:id (detailed view with all signatures) - Implement POST /admin/proposals/:id/sign (submit signature with threshold execution) - Implement POST /admin/proposals/:id/reject (reject proposal with rejector/timestamp) - Use ADMIN_MULTISIG_WALLETS for signer authorization (2-of-3 default) - Prevent duplicate signatures with DB unique constraint - Atomic threshold execution with DB transactions - Comprehensive integration and unit tests - Update API documentation --- docs/api-endpoints.md | 215 +++++- .../migration.sql | 46 ++ prisma/schema/multisig.prisma | 38 + .../multisig-proposal.integration.test.ts | 666 ++++++++++++++++++ .../multisig-proposal.service.test.ts | 215 ++++++ src/modules/admin/admin.routes.ts | 53 +- .../admin/multisig-proposal.controllers.ts | 194 +++++ .../admin/multisig-proposal.service.ts | 584 +++++++++++++++ 8 files changed, 2007 insertions(+), 4 deletions(-) create mode 100644 prisma/schema/migrations/20260927000000_add_multisig_proposal_queue/migration.sql create mode 100644 prisma/schema/multisig.prisma create mode 100644 src/modules/admin/__tests__/multisig-proposal.integration.test.ts create mode 100644 src/modules/admin/__tests__/multisig-proposal.service.test.ts create mode 100644 src/modules/admin/multisig-proposal.controllers.ts create mode 100644 src/modules/admin/multisig-proposal.service.ts diff --git a/docs/api-endpoints.md b/docs/api-endpoints.md index bb08d1ca..6515a5f3 100644 --- a/docs/api-endpoints.md +++ b/docs/api-endpoints.md @@ -237,6 +237,219 @@ Queue depth metrics for indexer workers. ## Admin Endpoints +### POST /admin/proposals + +Create a new multisig proposal requiring multi-sig approval. + +- **Auth:** Admin required +- **Body:** + +```json +{ + "changeType": "update_fee", + "payload": { "feeBps": 500, "treasuryAddress": "GATREASURYADDRESSFORACCESSLAYERTESTING123456789" }, + "threshold": 2, + "totalSigners": 3 +} +``` + +- **Response:** `201 Created` + +```json +{ + "success": true, + "data": { + "id": "cm123...", + "proposalId": "msig-1234567890-abc123", + "changeType": "update_fee", + "payload": { "feeBps": 500, "treasuryAddress": "GATREASURYADDRESSFORACCESSLAYERTESTING123456789" }, + "status": "pending", + "threshold": 2, + "totalSigners": 3, + "proposedBy": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", + "proposedAt": "2026-09-27T10:30:00.000Z", + "executedAt": null, + "rejectedAt": null, + "rejectedBy": null, + "rejectionReason": null, + "createdAt": "2026-09-27T10:30:00.000Z", + "updatedAt": "2026-09-27T10:30:00.000Z", + "signatures": [], + "approvalCount": 0 + } +} +``` + +### GET /admin/proposals + +List all multisig proposals with pagination and optional status filter. + +- **Auth:** Admin required +- **Query Params:** + - `status` (optional): `pending`, `executed`, or `rejected` + - `page` (number, default: 1) + - `limit` (number, default: 20, max: 100) +- **Response:** `200 OK` + +```json +{ + "success": true, + "data": { + "items": [ + { + "id": "cm123...", + "proposalId": "msig-1234567890-abc123", + "changeType": "update_fee", + "payload": { "feeBps": 500 }, + "status": "pending", + "threshold": 2, + "totalSigners": 3, + "proposedBy": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", + "proposedAt": "2026-09-27T10:30:00.000Z", + "executedAt": null, + "rejectedAt": null, + "rejectedBy": null, + "rejectionReason": null, + "createdAt": "2026-09-27T10:30:00.000Z", + "updatedAt": "2026-09-27T10:30:00.000Z", + "signatures": [ + { + "id": "cm456...", + "proposalId": "msig-1234567890-abc123", + "signer": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", + "signedAt": "2026-09-27T10:31:00.000Z" + } + ], + "approvalCount": 1 + } + ], + "meta": { + "page": 1, + "limit": 20, + "totalCount": 1, + "totalPages": 1, + "hasNextPage": false, + "hasPrevPage": false + } + } +} +``` + +### GET /admin/proposals/:id + +Get detailed information for a single multisig proposal including all signatures. + +- **Auth:** Admin required +- **Response:** `200 OK` + +```json +{ + "success": true, + "data": { + "id": "cm123...", + "proposalId": "msig-1234567890-abc123", + "changeType": "update_fee", + "payload": { "feeBps": 500 }, + "status": "pending", + "threshold": 2, + "totalSigners": 3, + "proposedBy": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", + "proposedAt": "2026-09-27T10:30:00.000Z", + "executedAt": null, + "rejectedAt": null, + "rejectedBy": null, + "rejectionReason": null, + "createdAt": "2026-09-27T10:30:00.000Z", + "updatedAt": "2026-09-27T10:30:00.000Z", + "signatures": [ + { + "id": "cm456...", + "proposalId": "msig-1234567890-abc123", + "signer": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", + "signedAt": "2026-09-27T10:31:00.000Z" + } + ], + "approvalCount": 1 + } +} +``` + +- **Error Response:** `404 Not Found` if proposal does not exist + +### POST /admin/proposals/:id/sign + +Submit a signature/approval for a multisig proposal. When the threshold is reached, the proposal is automatically executed. + +- **Auth:** Admin required (signer must be in ADMIN_MULTISIG_WALLETS) +- **Body:** + +```json +{ + "signer": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789" +} +``` + +- **Response:** `200 OK` + +```json +{ + "success": true, + "data": { + "proposalId": "msig-1234567890-abc123", + "status": "executed", + "approvalCount": 2, + "threshold": 2, + "executed": true, + "signature": { + "id": "cm789...", + "proposalId": "msig-1234567890-abc123", + "signer": "GAADMIN2WALLETADDRESSFORACCESSLAYERTESTING987654321", + "signedAt": "2026-09-27T10:32:00.000Z" + } + } +} +``` + +- **Error Responses:** + - `400 Bad Request` if proposal is not in `pending` state + - `403 Forbidden` if signer is not authorized + - `404 Not Found` if proposal does not exist + - `409 Conflict` if signer has already signed + +### POST /admin/proposals/:id/reject + +Reject a multisig proposal. + +- **Auth:** Admin required (rejector must be in ADMIN_MULTISIG_WALLETS) +- **Body:** + +```json +{ + "rejector": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", + "reason": "Optional rejection reason" +} +``` + +- **Response:** `200 OK` + +```json +{ + "success": true, + "data": { + "proposalId": "msig-1234567890-abc123", + "status": "rejected", + "rejectedAt": "2026-09-27T10:33:00.000Z", + "rejectedBy": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", + "rejectionReason": "Optional rejection reason" + } +} +``` + +- **Error Responses:** + - `400 Bad Request` if proposal is not in `pending` state + - `403 Forbidden` if rejector is not authorized + - `404 Not Found` if proposal does not exist + ### PATCH /admin/creators/:id/metadata Update creator metadata. @@ -340,4 +553,4 @@ window, with alert and auto-suspension state. --- -See [Local Setup](./local-setup.md) for development environment configuration. +See [Local Setup](./local-setup.md) for development environment configuration. \ No newline at end of file diff --git a/prisma/schema/migrations/20260927000000_add_multisig_proposal_queue/migration.sql b/prisma/schema/migrations/20260927000000_add_multisig_proposal_queue/migration.sql new file mode 100644 index 00000000..118b61d8 --- /dev/null +++ b/prisma/schema/migrations/20260927000000_add_multisig_proposal_queue/migration.sql @@ -0,0 +1,46 @@ +-- Multisig proposal queue for admin operations requiring multi-sig approval. +-- Supports configurable threshold (default 2-of-3) with signature tracking. + +CREATE TABLE "multisig_proposals" ( + "id" TEXT NOT NULL, + "proposalId" TEXT NOT NULL, + "changeType" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "threshold" INTEGER NOT NULL DEFAULT 2, + "totalSigners" INTEGER NOT NULL DEFAULT 3, + "proposedBy" TEXT NOT NULL, + "proposedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "executedAt" TIMESTAMP(3), + "rejectedAt" TIMESTAMP(3), + "rejectedBy" TEXT, + "rejectionReason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "multisig_proposals_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "multisig_proposals_proposalId_key" ON "multisig_proposals"("proposalId"); +CREATE INDEX "multisig_proposals_status_idx" ON "multisig_proposals"("status"); +CREATE INDEX "multisig_proposals_proposedBy_idx" ON "multisig_proposals"("proposedBy"); + +CREATE TABLE "multisig_signatures" ( + "id" TEXT NOT NULL, + "proposalId" TEXT NOT NULL, + "signer" TEXT NOT NULL, + "signedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "multisig_signatures_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "multisig_signatures_proposalId_signer_key" ON "multisig_signatures"("proposalId", "signer"); +CREATE INDEX "multisig_signatures_signer_idx" ON "multisig_signatures"("signer"); +CREATE INDEX "multisig_signatures_proposalId_idx" ON "multisig_signatures"("proposalId"); + +ALTER TABLE "multisig_signatures" + ADD CONSTRAINT "multisig_signatures_proposalId_fkey" + FOREIGN KEY ("proposalId") + REFERENCES "multisig_proposals"("proposalId") + ON DELETE CASCADE + ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/schema/multisig.prisma b/prisma/schema/multisig.prisma new file mode 100644 index 00000000..1deb5469 --- /dev/null +++ b/prisma/schema/multisig.prisma @@ -0,0 +1,38 @@ +// prisma/schema/multisig.prisma + +model MultisigProposal { + id String @id @default(cuid()) + proposalId String @unique + changeType String + payload Json + status String @default("pending") // pending | executed | rejected + threshold Int @default(2) + totalSigners Int @default(3) + proposedBy String + proposedAt DateTime @default(now()) + executedAt DateTime? + rejectedAt DateTime? + rejectedBy String? + rejectionReason String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + signatures MultisigSignature[] + + @@index([status]) + @@index([proposedBy]) + @@map("multisig_proposals") +} + +model MultisigSignature { + id String @id @default(cuid()) + proposalId String + signer String + signedAt DateTime @default(now()) + + proposal MultisigProposal @relation(fields: [proposalId], references: [proposalId], onDelete: Cascade) + + @@unique([proposalId, signer]) + @@index([signer]) + @@map("multisig_signatures") +} \ No newline at end of file diff --git a/src/modules/admin/__tests__/multisig-proposal.integration.test.ts b/src/modules/admin/__tests__/multisig-proposal.integration.test.ts new file mode 100644 index 00000000..84d60089 --- /dev/null +++ b/src/modules/admin/__tests__/multisig-proposal.integration.test.ts @@ -0,0 +1,666 @@ +import request from 'supertest'; +import express from 'express'; +import jwt from 'jsonwebtoken'; +import adminRouter from '../admin.routes'; +import { errorHandler } from '../../middlewares/error.middleware'; +import { prisma } from '../../utils/prisma.utils'; + +const app = express(); +app.use(express.json()); +app.use('/admin', adminRouter); +app.use(errorHandler); + +const ADMIN_1 = 'GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789'; +const ADMIN_2 = 'GAADMIN2WALLETADDRESSFORACCESSLAYERTESTING987654321'; +const ADMIN_3 = 'GAADMIN3WALLETADDRESSFORACCESSLAYERTESTING555555555'; +const NON_ADMIN = 'GANONADMINUSERWALLETADDRESSFORACCESSLAYERTESTING555'; +const INVALID_ADDRESS = 'INVALID'; + +describe('Multisig Proposal Queue API (#961)', () => { + let admin1Token: string; + let admin2Token: string; + let admin3Token: string; + let nonAdminToken: string; + + beforeAll(() => { + const secret = process.env.JWT_SECRET || 'accesslayer_default_development_jwt_secret_key_32_bytes'; + admin1Token = jwt.sign({ sub: ADMIN_1, role: 'admin' }, secret); + admin2Token = jwt.sign({ sub: ADMIN_2, role: 'admin' }, secret); + admin3Token = jwt.sign({ sub: ADMIN_3, role: 'admin' }, secret); + nonAdminToken = jwt.sign({ sub: NON_ADMIN, role: 'user' }, secret); + }); + + afterEach(async () => { + await prisma.multisigSignature.deleteMany(); + await prisma.multisigProposal.deleteMany(); + jest.restoreAllMocks(); + }); + + describe('POST /admin/proposals - Create proposal', () => { + it('should reject non-admin callers with 403', async () => { + const res = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${nonAdminToken}`) + .send({ + changeType: 'update_fee', + payload: { feeBps: 500 }, + }); + + expect(res.status).toBe(403); + }); + + it('should reject invalid request body with 400', async () => { + const res = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('should create a new proposal with default threshold', async () => { + const res = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ + changeType: 'update_fee', + payload: { feeBps: 500, treasuryAddress: 'GATREASURYADDRESSFORACCESSLAYERTESTING123456789' }, + }); + + expect(res.status).toBe(201); + expect(res.body.data.proposalId).toMatch(/^msig-\d+-[a-z0-9]+$/); + expect(res.body.data.changeType).toBe('update_fee'); + expect(res.body.data.status).toBe('pending'); + expect(res.body.data.threshold).toBe(2); + expect(res.body.data.totalSigners).toBe(3); + expect(res.body.data.proposedBy).toBe(ADMIN_1); + expect(res.body.data.approvalCount).toBe(0); + expect(res.body.data.signatures).toEqual([]); + }); + + it('should create a proposal with custom threshold', async () => { + const res = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ + changeType: 'update_config', + payload: { key: 'value' }, + threshold: 3, + totalSigners: 5, + }); + + expect(res.status).toBe(201); + expect(res.body.data.threshold).toBe(3); + expect(res.body.data.totalSigners).toBe(5); + }); + + it('should reject invalid threshold values', async () => { + const res = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ + changeType: 'test', + payload: {}, + threshold: 5, + totalSigners: 3, + }); + + expect(res.status).toBe(500); // Will throw error in service + }); + }); + + describe('GET /admin/proposals - List proposals', () => { + beforeEach(async () => { + // Create test proposals + await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ changeType: 'type_a', payload: { a: 1 } }); + + await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ changeType: 'type_b', payload: { b: 2 } }); + + // Sign one to make it executed + const listRes = await request(app) + .get('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`); + + const pendingProposal = listRes.body.data.items.find((p: any) => p.status === 'pending'); + if (pendingProposal) { + await request(app) + .post(`/admin/proposals/${pendingProposal.proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_1 }); + + await request(app) + .post(`/admin/proposals/${pendingProposal.proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_2 }); + } + }); + + it('should reject non-admin callers with 403', async () => { + const res = await request(app) + .get('/admin/proposals') + .set('Authorization', `Bearer ${nonAdminToken}`); + + expect(res.status).toBe(403); + }); + + it('should return paginated proposals with correct structure', async () => { + const res = await request(app) + .get('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`); + + expect(res.status).toBe(200); + expect(res.body.data.items).toBeInstanceOf(Array); + expect(res.body.data.meta).toMatchObject({ + page: 1, + limit: 20, + totalCount: expect.any(Number), + totalPages: expect.any(Number), + hasNextPage: expect.any(Boolean), + hasPrevPage: expect.any(Boolean), + }); + }); + + it('should filter by status', async () => { + const res = await request(app) + .get('/admin/proposals?status=pending') + .set('Authorization', `Bearer ${admin1Token}`); + + expect(res.status).toBe(200); + expect(res.body.data.items.every((p: any) => p.status === 'pending')).toBe(true); + }); + + it('should return correct signature counts', async () => { + const res = await request(app) + .get('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`); + + expect(res.status).toBe(200); + for (const proposal of res.body.data.items) { + expect(proposal.approvalCount).toBe(proposal.signatures.length); + expect(proposal.threshold).toBeGreaterThanOrEqual(proposal.approvalCount); + } + }); + }); + + describe('GET /admin/proposals/:id - Get proposal detail', () => { + let proposalId: string; + + beforeEach(async () => { + const createRes = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ changeType: 'detail_test', payload: { test: true } }); + proposalId = createRes.body.data.proposalId; + }); + + it('should reject non-admin callers with 403', async () => { + const res = await request(app) + .get(`/admin/proposals/${proposalId}`) + .set('Authorization', `Bearer ${nonAdminToken}`); + + expect(res.status).toBe(403); + }); + + it('should return 404 for non-existent proposal', async () => { + const res = await request(app) + .get('/admin/proposals/msig-nonexistent-123') + .set('Authorization', `Bearer ${admin1Token}`); + + expect(res.status).toBe(404); + }); + + it('should return detailed proposal with all signatures', async () => { + // Add signatures + await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_1 }); + + const res = await request(app) + .get(`/admin/proposals/${proposalId}`) + .set('Authorization', `Bearer ${admin1Token}`); + + expect(res.status).toBe(200); + expect(res.body.data.proposalId).toBe(proposalId); + expect(res.body.data.signatures).toHaveLength(1); + expect(res.body.data.signatures[0].signer).toBe(ADMIN_1); + expect(res.body.data.approvalCount).toBe(1); + expect(res.body.data.threshold).toBe(2); + }); + + it('should include rejection info when rejected', async () => { + await request(app) + .post(`/admin/proposals/${proposalId}/reject`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ rejector: ADMIN_1, reason: 'Testing rejection' }); + + const res = await request(app) + .get(`/admin/proposals/${proposalId}`) + .set('Authorization', `Bearer ${admin1Token}`); + + expect(res.status).toBe(200); + expect(res.body.data.status).toBe('rejected'); + expect(res.body.data.rejectedBy).toBe(ADMIN_1); + expect(res.body.data.rejectionReason).toBe('Testing rejection'); + expect(res.body.data.rejectedAt).toBeDefined(); + }); + }); + + describe('POST /admin/proposals/:id/sign - Sign proposal', () => { + let proposalId: string; + + beforeEach(async () => { + const createRes = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ changeType: 'sign_test', payload: { test: true } }); + proposalId = createRes.body.data.proposalId; + }); + + it('should reject non-admin callers with 403', async () => { + const res = await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${nonAdminToken}`) + .send({ signer: ADMIN_1 }); + + expect(res.status).toBe(403); + }); + + it('should reject invalid signer address with 400', async () => { + const res = await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: INVALID_ADDRESS }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('should reject unauthorized signer with 403', async () => { + const res = await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: NON_ADMIN }); + + expect(res.status).toBe(403); + expect(res.body.error.message).toMatch(/not an authorized multisig admin/i); + }); + + it('should allow authorized signer to sign', async () => { + const res = await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_1 }); + + expect(res.status).toBe(200); + expect(res.body.data.proposalId).toBe(proposalId); + expect(res.body.data.signature.signer).toBe(ADMIN_1); + expect(res.body.data.approvalCount).toBe(1); + expect(res.body.data.executed).toBe(false); + }); + + it('should reject duplicate signature from same signer with 409', async () => { + await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_1 }); + + const res = await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_1 }); + + expect(res.status).toBe(409); + expect(res.body.error.message).toMatch(/already signed/i); + }); + + it('should execute proposal when threshold reached', async () => { + // First signature + await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_1 }); + + // Second signature - should execute (threshold = 2) + const res = await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_2 }); + + expect(res.status).toBe(200); + expect(res.body.data.executed).toBe(true); + expect(res.body.data.status).toBe('executed'); + expect(res.body.data.approvalCount).toBe(2); + }); + + it('should reject signing executed proposal with 400', async () => { + // Execute the proposal + await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_1 }); + + await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_2 }); + + // Try to sign again + const res = await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_3 }); + + expect(res.status).toBe(400); + expect(res.body.error.message).toMatch(/invalid state executed/i); + }); + + it('should reject signing rejected proposal with 400', async () => { + await request(app) + .post(`/admin/proposals/${proposalId}/reject`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ rejector: ADMIN_1 }); + + const res = await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_2 }); + + expect(res.status).toBe(400); + expect(res.body.error.message).toMatch(/invalid state rejected/i); + }); + }); + + describe('POST /admin/proposals/:id/reject - Reject proposal', () => { + let proposalId: string; + + beforeEach(async () => { + const createRes = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ changeType: 'reject_test', payload: { test: true } }); + proposalId = createRes.body.data.proposalId; + }); + + it('should reject non-admin callers with 403', async () => { + const res = await request(app) + .post(`/admin/proposals/${proposalId}/reject`) + .set('Authorization', `Bearer ${nonAdminToken}`) + .send({ rejector: ADMIN_1 }); + + expect(res.status).toBe(403); + }); + + it('should reject invalid rejector address with 400', async () => { + const res = await request(app) + .post(`/admin/proposals/${proposalId}/reject`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ rejector: INVALID_ADDRESS }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('should reject unauthorized rejector with 403', async () => { + const res = await request(app) + .post(`/admin/proposals/${proposalId}/reject`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ rejector: NON_ADMIN }); + + expect(res.status).toBe(403); + expect(res.body.error.message).toMatch(/not an authorized multisig admin/i); + }); + + it('should allow authorized signer to reject', async () => { + const res = await request(app) + .post(`/admin/proposals/${proposalId}/reject`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ rejector: ADMIN_1, reason: 'Test rejection' }); + + expect(res.status).toBe(200); + expect(res.body.data.proposalId).toBe(proposalId); + expect(res.body.data.status).toBe('rejected'); + expect(res.body.data.rejectedBy).toBe(ADMIN_1); + expect(res.body.data.rejectionReason).toBe('Test rejection'); + expect(res.body.data.rejectedAt).toBeDefined(); + }); + + it('should reject rejecting executed proposal with 400', async () => { + // Execute the proposal + await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_1 }); + + await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_2 }); + + // Try to reject + const res = await request(app) + .post(`/admin/proposals/${proposalId}/reject`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ rejector: ADMIN_3 }); + + expect(res.status).toBe(400); + expect(res.body.error.message).toMatch(/invalid state executed/i); + }); + + it('should reject rejecting already rejected proposal with 400', async () => { + await request(app) + .post(`/admin/proposals/${proposalId}/reject`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ rejector: ADMIN_1 }); + + const res = await request(app) + .post(`/admin/proposals/${proposalId}/reject`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ rejector: ADMIN_2 }); + + expect(res.status).toBe(400); + expect(res.body.error.message).toMatch(/invalid state rejected/i); + }); + }); + + describe('Threshold and concurrency', () => { + it('should handle 3-of-5 threshold correctly', async () => { + const createRes = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ + changeType: 'threshold_test', + payload: {}, + threshold: 3, + totalSigners: 5, + }); + + const proposalId = createRes.body.data.proposalId; + expect(createRes.body.data.threshold).toBe(3); + + // First signature + await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_1 }); + + // Second signature + await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_2 }); + + // Third signature - should execute + const res = await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_3 }); + + expect(res.status).toBe(200); + expect(res.body.data.executed).toBe(true); + expect(res.body.data.approvalCount).toBe(3); + }); + + it('should not count duplicate signatures toward threshold', async () => { + const createRes = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ changeType: 'dup_test', payload: {} }); + + const proposalId = createRes.body.data.proposalId; + + // Sign with ADMIN_1 + await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_1 }); + + // Try to sign again with ADMIN_1 (should fail) + const dupRes = await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_1 }); + + expect(dupRes.status).toBe(409); + + // Sign with ADMIN_2 - should execute (2 distinct signers) + const res = await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_2 }); + + expect(res.status).toBe(200); + expect(res.body.data.executed).toBe(true); + expect(res.body.data.approvalCount).toBe(2); // Only 2 distinct signers + }); + + it('should prevent double execution on concurrent threshold reaching', async () => { + // This test simulates the race condition where two signers submit + // signatures simultaneously when threshold is about to be reached + const createRes = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ changeType: 'race_test', payload: {} }); + + const proposalId = createRes.body.data.proposalId; + + // First signer signs + await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_1 }); + + // Second and third signers attempt to sign concurrently + // In a real concurrent scenario, both would see count=1 and both try to execute + // But due to DB transaction, only one should succeed in marking executed + const [res2, res3] = await Promise.all([ + request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_2 }), + request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_3 }), + ]); + + // Both should succeed (200) but only one should have executed=true + const executedCount = [res2, res3].filter(r => r.body.data?.executed === true).length; + expect(executedCount).toBe(1); + expect([res2, res3].every(r => r.status === 200 || r.status === 409)).toBe(true); + }); + }); + + describe('Proposal lifecycle', () => { + it('should enforce valid state transitions: pending -> executed', async () => { + const createRes = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ changeType: 'lifecycle_test', payload: {} }); + + const proposalId = createRes.body.data.proposalId; + + await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_1 }); + + const res = await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_2 }); + + expect(res.body.data.status).toBe('executed'); + }); + + it('should enforce valid state transitions: pending -> rejected', async () => { + const createRes = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ changeType: 'lifecycle_test', payload: {} }); + + const proposalId = createRes.body.data.proposalId; + + const res = await request(app) + .post(`/admin/proposals/${proposalId}/reject`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ rejector: ADMIN_1 }); + + expect(res.body.data.status).toBe('rejected'); + }); + + it('should not allow signing after rejection', async () => { + const createRes = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ changeType: 'lifecycle_test', payload: {} }); + + const proposalId = createRes.body.data.proposalId; + + await request(app) + .post(`/admin/proposals/${proposalId}/reject`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ rejector: ADMIN_1 }); + + const res = await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_2 }); + + expect(res.status).toBe(400); + }); + + it('should not allow rejection after execution', async () => { + const createRes = await request(app) + .post('/admin/proposals') + .set('Authorization', `Bearer ${admin1Token}`) + .send({ changeType: 'lifecycle_test', payload: {} }); + + const proposalId = createRes.body.data.proposalId; + + await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_1 }); + + await request(app) + .post(`/admin/proposals/${proposalId}/sign`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ signer: ADMIN_2 }); + + const res = await request(app) + .post(`/admin/proposals/${proposalId}/reject`) + .set('Authorization', `Bearer ${admin1Token}`) + .send({ rejector: ADMIN_3 }); + + expect(res.status).toBe(400); + }); + }); +}); \ No newline at end of file diff --git a/src/modules/admin/__tests__/multisig-proposal.service.test.ts b/src/modules/admin/__tests__/multisig-proposal.service.test.ts new file mode 100644 index 00000000..0607023b --- /dev/null +++ b/src/modules/admin/__tests__/multisig-proposal.service.test.ts @@ -0,0 +1,215 @@ +import { prisma } from '../../utils/prisma.utils'; +import { + createMultisigProposal, + getMultisigProposalQueue, + getMultisigProposalById, + signMultisigProposal, + rejectMultisigProposal, + MultisigProposalNotFoundError, + MultisigProposalInvalidStateError, + MultisigSignerNotAuthorizedError, + MultisigDuplicateSignatureError, +} from '../multisig-proposal.service'; + +describe('Multisig Proposal Service', () => { + const ADMIN_1 = 'GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789'; + const ADMIN_2 = 'GAADMIN2WALLETADDRESSFORACCESSLAYERTESTING987654321'; + const ADMIN_3 = 'GAADMIN3WALLETADDRESSFORACCESSLAYERTESTING555555555'; + const NON_ADMIN = 'GANONADMINUSERWALLETADDRESSFORACCESSLAYERTESTING555'; + + beforeEach(async () => { + await prisma.multisigSignature.deleteMany(); + await prisma.multisigProposal.deleteMany(); + }); + + describe('createMultisigProposal', () => { + it('should create a proposal with defaults', async () => { + const result = await createMultisigProposal({ + changeType: 'test', + payload: { key: 'value' }, + proposedBy: ADMIN_1, + }); + + expect(result.proposalId).toMatch(/^msig-\d+-[a-z0-9]+$/); + expect(result.changeType).toBe('test'); + expect(result.status).toBe('pending'); + expect(result.threshold).toBe(2); + expect(result.totalSigners).toBe(3); + expect(result.proposedBy).toBe(ADMIN_1); + expect(result.approvalCount).toBe(0); + expect(result.signatures).toHaveLength(0); + }); + + it('should create a proposal with custom threshold', async () => { + const result = await createMultisigProposal({ + changeType: 'test', + payload: {}, + threshold: 3, + totalSigners: 5, + proposedBy: ADMIN_1, + }); + + expect(result.threshold).toBe(3); + expect(result.totalSigners).toBe(5); + }); + + it('should throw on invalid threshold > totalSigners', async () => { + await expect( + createMultisigProposal({ + changeType: 'test', + payload: {}, + threshold: 5, + totalSigners: 3, + proposedBy: ADMIN_1, + }) + ).rejects.toThrow('Invalid threshold'); + }); + }); + + describe('getMultisigProposalQueue', () => { + it('should return paginated results', async () => { + await createMultisigProposal({ changeType: 'a', payload: {}, proposedBy: ADMIN_1 }); + await createMultisigProposal({ changeType: 'b', payload: {}, proposedBy: ADMIN_1 }); + + const result = await getMultisigProposalQueue({ page: 1, limit: 10 }); + expect(result.items).toHaveLength(2); + expect(result.meta.totalCount).toBe(2); + expect(result.meta.page).toBe(1); + }); + + it('should filter by status', async () => { + const p1 = await createMultisigProposal({ changeType: 'a', payload: {}, proposedBy: ADMIN_1 }); + await createMultisigProposal({ changeType: 'b', payload: {}, proposedBy: ADMIN_1 }); + + // Execute first proposal + await signMultisigProposal({ proposalId: p1.proposalId, signer: ADMIN_1 }); + await signMultisigProposal({ proposalId: p1.proposalId, signer: ADMIN_2 }); + + const pending = await getMultisigProposalQueue({ status: 'pending' }); + expect(pending.items.every(p => p.status === 'pending')).toBe(true); + + const executed = await getMultisigProposalQueue({ status: 'executed' }); + expect(executed.items.every(p => p.status === 'executed')).toBe(true); + }); + }); + + describe('getMultisigProposalById', () => { + it('should return proposal with signatures', async () => { + const created = await createMultisigProposal({ changeType: 'test', payload: {}, proposedBy: ADMIN_1 }); + await signMultisigProposal({ proposalId: created.proposalId, signer: ADMIN_1 }); + + const result = await getMultisigProposalById(created.proposalId); + expect(result.proposalId).toBe(created.proposalId); + expect(result.signatures).toHaveLength(1); + expect(result.signatures[0].signer).toBe(ADMIN_1); + }); + + it('should throw MultisigProposalNotFoundError for unknown proposal', async () => { + await expect(getMultisigProposalById('msig-unknown-123')).rejects.toThrow(MultisigProposalNotFoundError); + }); + }); + + describe('signMultisigProposal', () => { + let proposalId: string; + + beforeEach(async () => { + const created = await createMultisigProposal({ changeType: 'test', payload: {}, proposedBy: ADMIN_1 }); + proposalId = created.proposalId; + }); + + it('should add signature and increment approval count', async () => { + const result = await signMultisigProposal({ proposalId, signer: ADMIN_1 }); + expect(result.approvalCount).toBe(1); + expect(result.signature.signer).toBe(ADMIN_1); + expect(result.executed).toBe(false); + }); + + it('should execute when threshold reached', async () => { + await signMultisigProposal({ proposalId, signer: ADMIN_1 }); + const result = await signMultisigProposal({ proposalId, signer: ADMIN_2 }); + + expect(result.executed).toBe(true); + expect(result.status).toBe('executed'); + expect(result.approvalCount).toBe(2); + }); + + it('should throw MultisigProposalNotFoundError for unknown proposal', async () => { + await expect(signMultisigProposal({ proposalId: 'msig-unknown-123', signer: ADMIN_1 })) + .rejects.toThrow(MultisigProposalNotFoundError); + }); + + it('should throw MultisigProposalInvalidStateError for executed proposal', async () => { + await signMultisigProposal({ proposalId, signer: ADMIN_1 }); + await signMultisigProposal({ proposalId, signer: ADMIN_2 }); + + await expect(signMultisigProposal({ proposalId, signer: ADMIN_3 })) + .rejects.toThrow(MultisigProposalInvalidStateError); + }); + + it('should throw MultisigProposalInvalidStateError for rejected proposal', async () => { + await rejectMultisigProposal({ proposalId, rejector: ADMIN_1 }); + + await expect(signMultisigProposal({ proposalId, signer: ADMIN_2 })) + .rejects.toThrow(MultisigProposalInvalidStateError); + }); + + it('should throw MultisigSignerNotAuthorizedError for unauthorized signer', async () => { + await expect(signMultisigProposal({ proposalId, signer: NON_ADMIN })) + .rejects.toThrow(MultisigSignerNotAuthorizedError); + }); + + it('should throw MultisigDuplicateSignatureError for duplicate signer', async () => { + await signMultisigProposal({ proposalId, signer: ADMIN_1 }); + + await expect(signMultisigProposal({ proposalId, signer: ADMIN_1 })) + .rejects.toThrow(MultisigDuplicateSignatureError); + }); + }); + + describe('rejectMultisigProposal', () => { + let proposalId: string; + + beforeEach(async () => { + const created = await createMultisigProposal({ changeType: 'test', payload: {}, proposedBy: ADMIN_1 }); + proposalId = created.proposalId; + }); + + it('should reject proposal and record rejector and timestamp', async () => { + const result = await rejectMultisigProposal({ + proposalId, + rejector: ADMIN_1, + reason: 'Test reason', + }); + + expect(result.status).toBe('rejected'); + expect(result.rejectedBy).toBe(ADMIN_1); + expect(result.rejectionReason).toBe('Test reason'); + expect(result.rejectedAt).toBeInstanceOf(Date); + }); + + it('should throw MultisigProposalNotFoundError for unknown proposal', async () => { + await expect(rejectMultisigProposal({ proposalId: 'msig-unknown-123', rejector: ADMIN_1 })) + .rejects.toThrow(MultisigProposalNotFoundError); + }); + + it('should throw MultisigProposalInvalidStateError for executed proposal', async () => { + await signMultisigProposal({ proposalId, signer: ADMIN_1 }); + await signMultisigProposal({ proposalId, signer: ADMIN_2 }); + + await expect(rejectMultisigProposal({ proposalId, rejector: ADMIN_3 })) + .rejects.toThrow(MultisigProposalInvalidStateError); + }); + + it('should throw MultisigProposalInvalidStateError for already rejected proposal', async () => { + await rejectMultisigProposal({ proposalId, rejector: ADMIN_1 }); + + await expect(rejectMultisigProposal({ proposalId, rejector: ADMIN_2 })) + .rejects.toThrow(MultisigProposalInvalidStateError); + }); + + it('should throw MultisigSignerNotAuthorizedError for unauthorized rejector', async () => { + await expect(rejectMultisigProposal({ proposalId, rejector: NON_ADMIN })) + .rejects.toThrow(MultisigSignerNotAuthorizedError); + }); + }); +}); \ No newline at end of file diff --git a/src/modules/admin/admin.routes.ts b/src/modules/admin/admin.routes.ts index 2bd953a8..cf4a242a 100644 --- a/src/modules/admin/admin.routes.ts +++ b/src/modules/admin/admin.routes.ts @@ -8,6 +8,13 @@ import { httpGetAuditLog, } from './admin.controllers'; import { httpSyncKeyState } from './key-sync.controllers'; +import { + httpCreateMultisigProposal, + httpGetMultisigProposalQueue, + httpGetMultisigProposalById, + httpSignMultisigProposal, + httpRejectMultisigProposal, +} from './multisig-proposal.controllers'; import aclRouter from '../acl/acl.routes'; import { getKeySnapshot, KeySnapshotNotFoundError } from './key-snapshot.service'; import { createAuditEntry } from './audit-log.service'; @@ -102,9 +109,9 @@ function serializeTimelockAction(action: any) { : null, ...(countdownMs !== null ? { - countdownMs, - countdown: formatCountdown(countdownMs), - } + countdownMs, + countdown: formatCountdown(countdownMs), + } : {}), }; } @@ -119,6 +126,46 @@ adminRouter.post('/keys/:keyId/sync', adminGuard, httpSyncKeyState); adminRouter.patch('/protocol-fee', adminGuard, httpUpdateProtocolFee); adminRouter.get('/audit-log', adminGuard, httpGetAuditLog); +/** + * GET /api/v1/admin/proposals + * + * List all multisig proposals with pagination and optional status filter. + * Requires admin JWT. + */ +adminRouter.get('/proposals', adminGuard, httpGetMultisigProposalQueue); + +/** + * POST /api/v1/admin/proposals + * + * Create a new multisig proposal requiring multi-sig approval. + * Requires admin JWT. + */ +adminRouter.post('/proposals', adminGuard, httpCreateMultisigProposal); + +/** + * GET /api/v1/admin/proposals/:id + * + * Get detailed information for a single multisig proposal including all signatures. + * Requires admin JWT. + */ +adminRouter.get('/proposals/:id', adminGuard, httpGetMultisigProposalById); + +/** + * POST /api/v1/admin/proposals/:id/sign + * + * Submit a signature/approval for a multisig proposal. + * Requires admin JWT and valid signer from ADMIN_MULTISIG_WALLETS. + */ +adminRouter.post('/proposals/:id/sign', adminGuard, httpSignMultisigProposal); + +/** + * POST /api/v1/admin/proposals/:id/reject + * + * Reject a multisig proposal. + * Requires admin JWT and valid signer from ADMIN_MULTISIG_WALLETS. + */ +adminRouter.post('/proposals/:id/reject', adminGuard, httpRejectMultisigProposal); + // ── ACL whitelist management (#966) ─────────────────────────── // GET/POST /admin/acl, DELETE /admin/acl/:contractId, GET /admin/acl/history adminRouter.use('/acl', aclRouter); diff --git a/src/modules/admin/multisig-proposal.controllers.ts b/src/modules/admin/multisig-proposal.controllers.ts new file mode 100644 index 00000000..4bfcc5d8 --- /dev/null +++ b/src/modules/admin/multisig-proposal.controllers.ts @@ -0,0 +1,194 @@ +// src/modules/admin/multisig-proposal.controllers.ts +import { AsyncController } from '../../types/auth.types'; +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { AdminRequest } from '../../middlewares/admin-guard.middleware'; +import { + sendSuccess, + sendValidationError, + sendNotFound, + sendForbidden, + sendError, + sendConflict, + zodIssuesToDetails, +} from '../../utils/api-response.utils'; +import { ErrorCode } from '../../constants/error.constants'; +import { + createMultisigProposal, + getMultisigProposalQueue, + getMultisigProposalById, + signMultisigProposal, + rejectMultisigProposal, + MultisigProposalNotFoundError, + MultisigProposalInvalidStateError, + MultisigSignerNotAuthorizedError, + MultisigDuplicateSignatureError, +} from './multisig-proposal.service'; +import { logger } from '../../utils/logger.utils'; + +const STELLAR_ADDRESS_PATTERN = /^G[A-Z2-7]{55}$/; + +const createProposalSchema = z.object({ + changeType: z.string().min(1, 'changeType is required'), + payload: z.record(z.unknown()), + threshold: z.number().int().positive().optional(), + totalSigners: z.number().int().positive().optional(), +}); + +const signProposalSchema = z.object({ + signer: z.string().regex(STELLAR_ADDRESS_PATTERN, 'Invalid Stellar address'), +}); + +const rejectProposalSchema = z.object({ + rejector: z.string().regex(STELLAR_ADDRESS_PATTERN, 'Invalid Stellar address'), + reason: z.string().optional(), +}); + +const queueQuerySchema = z.object({ + status: z.enum(['pending', 'executed', 'rejected']).optional(), + page: z.coerce.number().int().positive().optional().default(1), + limit: z.coerce.number().int().positive().max(100).optional().default(20), +}); + +export const httpCreateMultisigProposal: AsyncController = async ( + req: AdminRequest, + res: Response, + next: NextFunction +): Promise => { + try { + const parsed = createProposalSchema.safeParse(req.body); + if (!parsed.success) { + sendValidationError(res, 'Invalid request body', zodIssuesToDetails(parsed.error.issues)); + return; + } + + const adminId = req.adminId || 'unknown'; + const result = await createMultisigProposal({ + ...parsed.data, + proposedBy: adminId, + }); + + sendSuccess(res, result, 201); + } catch (error) { + logger.error({ error }, 'Create multisig proposal failed'); + next(error); + } +}; + +export const httpGetMultisigProposalQueue: AsyncController = async ( + req: AdminRequest, + res: Response, + next: NextFunction +): Promise => { + try { + const parsed = queueQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError(res, 'Invalid query parameters', zodIssuesToDetails(parsed.error.issues)); + return; + } + + const result = await getMultisigProposalQueue(parsed.data); + sendSuccess(res, result); + } catch (error) { + logger.error({ error }, 'Get multisig proposal queue failed'); + next(error); + } +}; + +export const httpGetMultisigProposalById: AsyncController = async ( + req: AdminRequest, + res: Response, + next: NextFunction +): Promise => { + try { + const proposalId = String(req.params.id); + const result = await getMultisigProposalById(proposalId); + sendSuccess(res, result); + } catch (error) { + if (error instanceof MultisigProposalNotFoundError) { + sendNotFound(res, 'Multisig proposal'); + return; + } + logger.error({ error, proposalId: req.params.id }, 'Get multisig proposal by ID failed'); + next(error); + } +}; + +export const httpSignMultisigProposal: AsyncController = async ( + req: AdminRequest, + res: Response, + next: NextFunction +): Promise => { + try { + const proposalId = String(req.params.id); + const parsed = signProposalSchema.safeParse(req.body); + if (!parsed.success) { + sendValidationError(res, 'Invalid request body', zodIssuesToDetails(parsed.error.issues)); + return; + } + + const result = await signMultisigProposal({ + proposalId, + signer: parsed.data.signer, + }); + + sendSuccess(res, result); + } catch (error) { + if (error instanceof MultisigProposalNotFoundError) { + sendNotFound(res, 'Multisig proposal'); + return; + } + if (error instanceof MultisigProposalInvalidStateError) { + sendError(res, 400, ErrorCode.BAD_REQUEST, error.message); + return; + } + if (error instanceof MultisigSignerNotAuthorizedError) { + sendForbidden(res, error.message); + return; + } + if (error instanceof MultisigDuplicateSignatureError) { + sendConflict(res, error.message); + return; + } + logger.error({ error, proposalId: req.params.id }, 'Sign multisig proposal failed'); + next(error); + } +}; + +export const httpRejectMultisigProposal: AsyncController = async ( + req: AdminRequest, + res: Response, + next: NextFunction +): Promise => { + try { + const proposalId = String(req.params.id); + const parsed = rejectProposalSchema.safeParse(req.body); + if (!parsed.success) { + sendValidationError(res, 'Invalid request body', zodIssuesToDetails(parsed.error.issues)); + return; + } + + const result = await rejectMultisigProposal({ + proposalId, + rejector: parsed.data.rejector, + reason: parsed.data.reason, + }); + + sendSuccess(res, result); + } catch (error) { + if (error instanceof MultisigProposalNotFoundError) { + sendNotFound(res, 'Multisig proposal'); + return; + } + if (error instanceof MultisigProposalInvalidStateError) { + sendError(res, 400, ErrorCode.BAD_REQUEST, error.message); + return; + } + if (error instanceof MultisigSignerNotAuthorizedError) { + sendForbidden(res, error.message); + return; + } + logger.error({ error, proposalId: req.params.id }, 'Reject multisig proposal failed'); + next(error); + } +}; \ No newline at end of file diff --git a/src/modules/admin/multisig-proposal.service.ts b/src/modules/admin/multisig-proposal.service.ts new file mode 100644 index 00000000..e7ad10f1 --- /dev/null +++ b/src/modules/admin/multisig-proposal.service.ts @@ -0,0 +1,584 @@ +// src/modules/admin/multisig-proposal.service.ts +// Multi-sig proposal queue for admin operations requiring multi-sig approval. +// Supports configurable threshold (default 2-of-3) with signature tracking. + +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { envConfig } from '../../config'; +import { emitAuditEvent } from '../../utils/audit.utils'; +import { createAuditEntry } from './audit-log.service'; + +export const DEFAULT_MULTISIG_THRESHOLD = 2; +export const DEFAULT_MULTISIG_TOTAL_SIGNERS = 3; + +export interface MultisigProposalInput { + changeType: string; + payload: Record; + threshold?: number; + totalSigners?: number; + proposedBy: string; +} + +export interface MultisigProposalResult { + id: string; + proposalId: string; + changeType: string; + payload: Record; + status: string; + threshold: number; + totalSigners: number; + proposedBy: string; + proposedAt: Date; + executedAt: Date | null; + rejectedAt: Date | null; + rejectedBy: string | null; + rejectionReason: string | null; + createdAt: Date; + updatedAt: Date; + signatures: MultisigSignatureResult[]; + approvalCount: number; +} + +export interface MultisigSignatureResult { + id: string; + proposalId: string; + signer: string; + signedAt: Date; +} + +export interface SignProposalInput { + proposalId: string; + signer: string; +} + +export interface SignProposalResult { + proposalId: string; + status: string; + approvalCount: number; + threshold: number; + executed: boolean; + signature: MultisigSignatureResult; +} + +export interface RejectProposalInput { + proposalId: string; + rejector: string; + reason?: string; +} + +export interface RejectProposalResult { + proposalId: string; + status: string; + rejectedAt: Date; + rejectedBy: string; + rejectionReason: string | null; +} + +export class MultisigProposalNotFoundError extends Error { + constructor(proposalId: string) { + super(`Multisig proposal not found: ${proposalId}`); + this.name = 'MultisigProposalNotFoundError'; + } +} + +export class MultisigProposalInvalidStateError extends Error { + constructor(proposalId: string, status: string, action: string) { + super(`Cannot ${action} proposal ${proposalId}: invalid state ${status}`); + this.name = 'MultisigProposalInvalidStateError'; + } +} + +export class MultisigSignerNotAuthorizedError extends Error { + constructor(signer: string) { + super(`Signer ${signer} is not an authorized multisig admin`); + this.name = 'MultisigSignerNotAuthorizedError'; + } +} + +export class MultisigDuplicateSignatureError extends Error { + constructor(proposalId: string, signer: string) { + super(`Signer ${signer} has already signed proposal ${proposalId}`); + this.name = 'MultisigDuplicateSignatureError'; + } +} + +export class MultisigThresholdNotMetError extends Error { + constructor(proposalId: string, current: number, required: number) { + super(`Threshold not met for proposal ${proposalId}: ${current}/${required}`); + this.name = 'MultisigThresholdNotMetError'; + } +} + +const STELLAR_ADDRESS_PATTERN = /^G[A-Z2-7]{55}$/; + +function parseMultisigAdminWallets(): string[] { + const raw = envConfig.ADMIN_MULTISIG_WALLETS; + if (!raw) return []; + return raw + .split(',') + .map(wallet => wallet.trim()) + .filter(wallet => wallet.length > 0); +} + +function isAuthorizedSigner(signer: string): boolean { + const adminWallets = parseMultisigAdminWallets(); + if (adminWallets.length === 0) { + // In development, any valid Stellar address is allowed if no quorum is configured + return STELLAR_ADDRESS_PATTERN.test(signer); + } + const adminSet = new Set(adminWallets.map(w => w.toLowerCase())); + return adminSet.has(signer.toLowerCase()); +} + +function validateStellarAddress(address: string): boolean { + return STELLAR_ADDRESS_PATTERN.test(address); +} + +export async function createMultisigProposal( + input: MultisigProposalInput +): Promise { + const threshold = input.threshold ?? DEFAULT_MULTISIG_THRESHOLD; + const totalSigners = input.totalSigners ?? DEFAULT_MULTISIG_TOTAL_SIGNERS; + + if (threshold < 1 || threshold > totalSigners) { + throw new Error('Invalid threshold: must be between 1 and totalSigners'); + } + + if (totalSigners < 1) { + throw new Error('Invalid totalSigners: must be at least 1'); + } + + if (!validateStellarAddress(input.proposedBy)) { + throw new Error('Invalid proposer address'); + } + + const proposalId = `msig-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const proposal = await prisma.multisigProposal.create({ + data: { + proposalId, + changeType: input.changeType, + payload: input.payload, + threshold, + totalSigners, + proposedBy: input.proposedBy, + status: 'pending', + }, + }); + + await emitAuditEvent({ + actor: input.proposedBy, + action: 'multisig_proposal_created', + target: 'MultisigProposal', + targetId: proposalId, + metadata: { + proposalId, + changeType: input.changeType, + payload: input.payload, + threshold, + totalSigners, + }, + }); + + await createAuditEntry({ + actorWallet: input.proposedBy, + actionType: 'multisig_proposal_created', + targetId: proposalId, + payload: { + proposalId, + changeType: input.changeType, + payload: input.payload, + threshold, + totalSigners, + }, + }); + + logger.info( + { + proposalId, + changeType: input.changeType, + threshold, + totalSigners, + proposedBy: input.proposedBy, + }, + 'Multisig proposal created' + ); + + return serializeProposal(proposal, []); +} + +export async function getMultisigProposalQueue(params: { + status?: string; + page?: number; + limit?: number; +}): Promise<{ + items: MultisigProposalResult[]; + meta: { + page: number; + limit: number; + totalCount: number; + totalPages: number; + hasNextPage: boolean; + hasPrevPage: boolean; + }; +}> { + const { status, page = 1, limit = 20 } = params; + const safePage = Math.max(1, page); + const safeLimit = Math.min(100, Math.max(1, limit)); + const skip = (safePage - 1) * safeLimit; + + const where: Record = {}; + if (status) { + where.status = status; + } + + const [proposals, totalCount] = await Promise.all([ + prisma.multisigProposal.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: safeLimit, + include: { + signatures: { + orderBy: { signedAt: 'asc' }, + }, + }, + }), + prisma.multisigProposal.count({ where }), + ]); + + const items = proposals.map(p => serializeProposal(p, p.signatures)); + + const totalPages = Math.ceil(totalCount / safeLimit); + + return { + items, + meta: { + page: safePage, + limit: safeLimit, + totalCount, + totalPages, + hasNextPage: safePage < totalPages, + hasPrevPage: safePage > 1, + }, + }; +} + +export async function getMultisigProposalById( + proposalId: string +): Promise { + const proposal = await prisma.multisigProposal.findUnique({ + where: { proposalId }, + include: { + signatures: { + orderBy: { signedAt: 'asc' }, + }, + }, + }); + + if (!proposal) { + throw new MultisigProposalNotFoundError(proposalId); + } + + return serializeProposal(proposal, proposal.signatures); +} + +export async function signMultisigProposal( + input: SignProposalInput +): Promise { + const { proposalId, signer } = input; + + if (!validateStellarAddress(signer)) { + throw new Error('Invalid signer address'); + } + + if (!isAuthorizedSigner(signer)) { + throw new MultisigSignerNotAuthorizedError(signer); + } + + return prisma.$transaction(async tx => { + const proposal = await tx.multisigProposal.findUnique({ + where: { proposalId }, + include: { signatures: true }, + }); + + if (!proposal) { + throw new MultisigProposalNotFoundError(proposalId); + } + + if (proposal.status !== 'pending') { + throw new MultisigProposalInvalidStateError(proposalId, proposal.status, 'sign'); + } + + const existingSignature = proposal.signatures.find( + s => s.signer.toLowerCase() === signer.toLowerCase() + ); + if (existingSignature) { + throw new MultisigDuplicateSignatureError(proposalId, signer); + } + + const signature = await tx.multisigSignature.create({ + data: { + proposalId, + signer, + }, + }); + + const approvalCount = proposal.signatures.length + 1; + const thresholdMet = approvalCount >= proposal.threshold; + + let updatedProposal = proposal; + let executed = false; + + if (thresholdMet) { + updatedProposal = await tx.multisigProposal.update({ + where: { proposalId }, + data: { + status: 'executed', + executedAt: new Date(), + }, + include: { signatures: true }, + }); + executed = true; + + await executeProposal(updatedProposal, signer); + + await emitAuditEvent({ + actor: signer, + action: 'multisig_proposal_executed', + target: 'MultisigProposal', + targetId: proposalId, + metadata: { + proposalId, + changeType: proposal.changeType, + payload: proposal.payload, + finalSigner: signer, + approvalCount, + threshold: proposal.threshold, + }, + }); + + await createAuditEntry({ + actorWallet: signer, + actionType: 'multisig_proposal_executed', + targetId: proposalId, + payload: { + proposalId, + changeType: proposal.changeType, + payload: proposal.payload, + finalSigner: signer, + approvalCount, + threshold: proposal.threshold, + }, + }); + } else { + await emitAuditEvent({ + actor: signer, + action: 'multisig_proposal_signed', + target: 'MultisigProposal', + targetId: proposalId, + metadata: { + proposalId, + signer, + approvalCount, + threshold: proposal.threshold, + }, + }); + + await createAuditEntry({ + actorWallet: signer, + actionType: 'multisig_proposal_signed', + targetId: proposalId, + payload: { + proposalId, + signer, + approvalCount, + threshold: proposal.threshold, + }, + }); + } + + logger.info( + { + proposalId, + signer, + approvalCount, + threshold: proposal.threshold, + executed, + }, + executed ? 'Multisig proposal executed' : 'Multisig proposal signed' + ); + + return { + proposalId, + status: updatedProposal.status, + approvalCount, + threshold: proposal.threshold, + executed, + signature: { + id: signature.id, + proposalId: signature.proposalId, + signer: signature.signer, + signedAt: signature.signedAt, + }, + }; + }); +} + +export async function rejectMultisigProposal( + input: RejectProposalInput +): Promise { + const { proposalId, rejector, reason } = input; + + if (!validateStellarAddress(rejector)) { + throw new Error('Invalid rejector address'); + } + + if (!isAuthorizedSigner(rejector)) { + throw new MultisigSignerNotAuthorizedError(rejector); + } + + return prisma.$transaction(async tx => { + const proposal = await tx.multisigProposal.findUnique({ + where: { proposalId }, + }); + + if (!proposal) { + throw new MultisigProposalNotFoundError(proposalId); + } + + if (proposal.status !== 'pending') { + throw new MultisigProposalInvalidStateError(proposalId, proposal.status, 'reject'); + } + + const rejectedAt = new Date(); + + const updatedProposal = await tx.multisigProposal.update({ + where: { proposalId }, + data: { + status: 'rejected', + rejectedAt, + rejectedBy: rejector, + rejectionReason: reason ?? null, + }, + }); + + await emitAuditEvent({ + actor: rejector, + action: 'multisig_proposal_rejected', + target: 'MultisigProposal', + targetId: proposalId, + metadata: { + proposalId, + changeType: proposal.changeType, + payload: proposal.payload, + rejectedBy: rejector, + reason: reason ?? null, + }, + }); + + await createAuditEntry({ + actorWallet: rejector, + actionType: 'multisig_proposal_rejected', + targetId: proposalId, + payload: { + proposalId, + changeType: proposal.changeType, + payload: proposal.payload, + rejectedBy: rejector, + reason: reason ?? null, + }, + }); + + logger.info( + { + proposalId, + rejector, + reason: reason ?? null, + }, + 'Multisig proposal rejected' + ); + + return { + proposalId, + status: updatedProposal.status, + rejectedAt, + rejectedBy: rejector, + rejectionReason: reason ?? null, + }; + }); +} + +async function executeProposal( + proposal: { + proposalId: string; + changeType: string; + payload: Record; + }, + _executor: string +): Promise { + logger.info( + { + proposalId: proposal.proposalId, + changeType: proposal.changeType, + payload: proposal.payload, + }, + 'Executing multisig proposal (placeholder - implement actual execution logic)' + ); + + // TODO: Implement actual execution logic based on changeType + // This would typically interact with the Stellar network via Soroban SDK + // to submit the transaction corresponding to the changeType and payload. + // For now, we just log the execution. +} + +function serializeProposal( + proposal: { + id: string; + proposalId: string; + changeType: string; + payload: Record; + status: string; + threshold: number; + totalSigners: number; + proposedBy: string; + proposedAt: Date; + executedAt: Date | null; + rejectedAt: Date | null; + rejectedBy: string | null; + rejectionReason: string | null; + createdAt: Date; + updatedAt: Date; + }, + signatures: { + id: string; + proposalId: string; + signer: string; + signedAt: Date; + }[] +): MultisigProposalResult { + return { + id: proposal.id, + proposalId: proposal.proposalId, + changeType: proposal.changeType, + payload: proposal.payload, + status: proposal.status, + threshold: proposal.threshold, + totalSigners: proposal.totalSigners, + proposedBy: proposal.proposedBy, + proposedAt: proposal.proposedAt, + executedAt: proposal.executedAt, + rejectedAt: proposal.rejectedAt, + rejectedBy: proposal.rejectedBy, + rejectionReason: proposal.rejectionReason, + createdAt: proposal.createdAt, + updatedAt: proposal.updatedAt, + signatures: signatures.map(s => ({ + id: s.id, + proposalId: s.proposalId, + signer: s.signer, + signedAt: s.signedAt, + })), + approvalCount: signatures.length, + }; +} \ No newline at end of file From 56c6846af1782b464653fc43991e49a832ac19a9 Mon Sep 17 00:00:00 2001 From: Zainab Wahab Date: Sun, 27 Sep 2026 10:57:05 +0100 Subject: [PATCH 2/3] fix: remove unused test tokens --- .../admin/__tests__/multisig-proposal.integration.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/modules/admin/__tests__/multisig-proposal.integration.test.ts b/src/modules/admin/__tests__/multisig-proposal.integration.test.ts index 84d60089..bc6bfeb0 100644 --- a/src/modules/admin/__tests__/multisig-proposal.integration.test.ts +++ b/src/modules/admin/__tests__/multisig-proposal.integration.test.ts @@ -18,15 +18,11 @@ const INVALID_ADDRESS = 'INVALID'; describe('Multisig Proposal Queue API (#961)', () => { let admin1Token: string; - let admin2Token: string; - let admin3Token: string; let nonAdminToken: string; beforeAll(() => { const secret = process.env.JWT_SECRET || 'accesslayer_default_development_jwt_secret_key_32_bytes'; admin1Token = jwt.sign({ sub: ADMIN_1, role: 'admin' }, secret); - admin2Token = jwt.sign({ sub: ADMIN_2, role: 'admin' }, secret); - admin3Token = jwt.sign({ sub: ADMIN_3, role: 'admin' }, secret); nonAdminToken = jwt.sign({ sub: NON_ADMIN, role: 'user' }, secret); }); From ea7d91295c87ce3bdee7e0a8c0e709826d14050d Mon Sep 17 00:00:00 2001 From: Zainab Wahab Date: Sun, 27 Sep 2026 11:07:53 +0100 Subject: [PATCH 3/3] fix: resolve type errors and import paths --- .../admin/__tests__/multisig-proposal.integration.test.ts | 4 ++-- .../admin/__tests__/multisig-proposal.service.test.ts | 2 +- src/modules/admin/multisig-proposal.service.ts | 7 ++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/modules/admin/__tests__/multisig-proposal.integration.test.ts b/src/modules/admin/__tests__/multisig-proposal.integration.test.ts index bc6bfeb0..d11a9b02 100644 --- a/src/modules/admin/__tests__/multisig-proposal.integration.test.ts +++ b/src/modules/admin/__tests__/multisig-proposal.integration.test.ts @@ -2,8 +2,8 @@ import request from 'supertest'; import express from 'express'; import jwt from 'jsonwebtoken'; import adminRouter from '../admin.routes'; -import { errorHandler } from '../../middlewares/error.middleware'; -import { prisma } from '../../utils/prisma.utils'; +import { errorHandler } from '../../../middlewares/error.middleware'; +import { prisma } from '../../../utils/prisma.utils'; const app = express(); app.use(express.json()); diff --git a/src/modules/admin/__tests__/multisig-proposal.service.test.ts b/src/modules/admin/__tests__/multisig-proposal.service.test.ts index 0607023b..d7777ef9 100644 --- a/src/modules/admin/__tests__/multisig-proposal.service.test.ts +++ b/src/modules/admin/__tests__/multisig-proposal.service.test.ts @@ -1,4 +1,4 @@ -import { prisma } from '../../utils/prisma.utils'; +import { prisma } from '../../../utils/prisma.utils'; import { createMultisigProposal, getMultisigProposalQueue, diff --git a/src/modules/admin/multisig-proposal.service.ts b/src/modules/admin/multisig-proposal.service.ts index e7ad10f1..d64b5168 100644 --- a/src/modules/admin/multisig-proposal.service.ts +++ b/src/modules/admin/multisig-proposal.service.ts @@ -7,6 +7,7 @@ import { logger } from '../../utils/logger.utils'; import { envConfig } from '../../config'; import { emitAuditEvent } from '../../utils/audit.utils'; import { createAuditEntry } from './audit-log.service'; +import { Prisma } from '@prisma/client'; export const DEFAULT_MULTISIG_THRESHOLD = 2; export const DEFAULT_MULTISIG_TOTAL_SIGNERS = 3; @@ -513,7 +514,7 @@ async function executeProposal( proposal: { proposalId: string; changeType: string; - payload: Record; + payload: Prisma.JsonValue; }, _executor: string ): Promise { @@ -537,7 +538,7 @@ function serializeProposal( id: string; proposalId: string; changeType: string; - payload: Record; + payload: Prisma.JsonValue; status: string; threshold: number; totalSigners: number; @@ -561,7 +562,7 @@ function serializeProposal( id: proposal.id, proposalId: proposal.proposalId, changeType: proposal.changeType, - payload: proposal.payload, + payload: (proposal.payload as Record) ?? {}, status: proposal.status, threshold: proposal.threshold, totalSigners: proposal.totalSigners,