diff --git a/prisma/schema/multisig.prisma b/prisma/schema/multisig.prisma index 1deb5469..24d01f55 100644 --- a/prisma/schema/multisig.prisma +++ b/prisma/schema/multisig.prisma @@ -35,4 +35,44 @@ model MultisigSignature { @@unique([proposalId, signer]) @@index([signer]) @@map("multisig_signatures") -} \ No newline at end of file +} + +model MultisigAction { + id String @id @default(cuid()) + actionId String @unique + changeType String? + payload Json? + status String @default("pending") // pending | executed | expired | rejected + threshold Int @default(2) + totalSigners Int @default(3) + proposedBy String + proposedAt DateTime @default(now()) + executedAt DateTime? + expiredAt DateTime? + ledger Int? + txHash String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + signatures Signature[] + + @@index([status]) + @@index([proposedBy]) + @@map("multisig_actions") +} + +model Signature { + id String @id @default(cuid()) + actionId String + signer String + signedAt DateTime @default(now()) + ledger Int? + txHash String? + + action MultisigAction @relation(fields: [actionId], references: [actionId], onDelete: Cascade) + + @@unique([actionId, signer]) + @@index([signer]) + @@index([actionId]) + @@map("signatures") +} diff --git a/src/modules/index.ts b/src/modules/index.ts index a46ae93c..ccd47126 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -52,15 +52,7 @@ import factoryRouter from './factory/factory.routes'; import lpRouter from './lp/lp.routes'; import auctionRouter from './auctions/auction.routes'; import holdersRouter from './dividends/holders.routes'; -import discoveryRouter from './keys/discovery.routes'; -import tradesRouter from './trades/trades.routes'; -import tradersRouter from './traders/traders.routes'; -import nonceRouter from './nonce/nonce.routes'; -import swapsRouter from './swaps/swaps.routes'; -import eventsRouter from './events/events.routes'; -import milestonesRouter from './keys/milestones.routes'; -import autoInvestRouter from './auto-invest/auto-invest.routes'; - +import multisigRouter from './multisig/multisig.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -140,14 +132,6 @@ router.use('/factory', routeBodySizeLimit('default'), factoryRouter); router.use('/lp', routeBodySizeLimit('default'), lpRouter); router.use('/auctions', routeBodySizeLimit('default'), auctionRouter); router.use('/holders', routeBodySizeLimit('default'), holdersRouter); -router.use('/events', routeBodySizeLimit('default'), eventsRouter); - -router.use('/discovery', routeBodySizeLimit('default'), discoveryRouter); -router.use('/trades', routeBodySizeLimit('default'), tradesRouter); -router.use('/traders', routeBodySizeLimit('default'), tradersRouter); -router.use('/nonce', routeBodySizeLimit('default'), nonceRouter); -router.use('/swaps', routeBodySizeLimit('default'), swapsRouter); -router.use('/milestones', routeBodySizeLimit('default'), milestonesRouter); -router.use('/auto-invest', routeBodySizeLimit('default'), autoInvestRouter); +router.use('/multisig', routeBodySizeLimit('default'), multisigRouter); export default router; diff --git a/src/modules/indexer/multisig-action-indexer.service.test.ts b/src/modules/indexer/multisig-action-indexer.service.test.ts new file mode 100644 index 00000000..f1e9a26f --- /dev/null +++ b/src/modules/indexer/multisig-action-indexer.service.test.ts @@ -0,0 +1,138 @@ +// src/modules/indexer/multisig-action-indexer.service.test.ts + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + multisigAction: { + upsert: jest.fn(), + updateMany: jest.fn(), + }, + signature: { + create: jest.fn(), + }, + }, +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +import { prisma } from '../../utils/prisma.utils'; +import { processMultisigActionEvents } from './multisig-action-indexer.service'; + +const mockPrisma = prisma as unknown as { + multisigAction: { + upsert: jest.Mock; + updateMany: jest.Mock; + }; + signature: { + create: jest.Mock; + }; +}; + +const VALID_STELLAR_ADDR = 'GA37PRKUOWJN5DWJWBCBLAH4WWHWEQQKSKFQGSIEPKT3VJUE5DVQHSXT'; + +beforeEach(() => { + jest.clearAllMocks(); + mockPrisma.multisigAction.upsert.mockResolvedValue({ id: 'action-1' }); + mockPrisma.signature.create.mockResolvedValue({ id: 'sig-1' }); + mockPrisma.multisigAction.updateMany.mockResolvedValue({ count: 1 }); +}); + +describe('processMultisigActionEvents', () => { + it('indexes ActionProposed event correctly', async () => { + const event = { + eventType: 'ActionProposed', + actionId: 'act-101', + changeType: 'upgrade', + payload: { version: '2.0' }, + threshold: 2, + totalSigners: 3, + proposedBy: VALID_STELLAR_ADDR, + ledger: 1000, + txHash: 'tx-prop-1', + eventIndex: 0, + occurredAt: '2026-10-20T10:00:00.000Z', + }; + + await processMultisigActionEvents([event]); + + expect(mockPrisma.multisigAction.upsert).toHaveBeenCalledWith({ + where: { actionId: 'act-101' }, + create: expect.objectContaining({ + actionId: 'act-101', + changeType: 'upgrade', + threshold: 2, + totalSigners: 3, + status: 'pending', + proposedBy: VALID_STELLAR_ADDR, + ledger: 1000, + txHash: 'tx-prop-1', + }), + update: expect.any(Object), + }); + }); + + it('indexes ActionSigned event correctly', async () => { + const event = { + eventType: 'ActionSigned', + actionId: 'act-101', + signer: VALID_STELLAR_ADDR, + ledger: 1001, + txHash: 'tx-sig-1', + eventIndex: 1, + occurredAt: '2026-10-20T10:05:00.000Z', + }; + + await processMultisigActionEvents([event]); + + expect(mockPrisma.signature.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + actionId: 'act-101', + signer: VALID_STELLAR_ADDR, + ledger: 1001, + txHash: 'tx-sig-1', + }), + }); + }); + + it('indexes ActionExecuted event correctly', async () => { + const event = { + eventType: 'ActionExecuted', + actionId: 'act-101', + status: 'executed', + ledger: 1002, + txHash: 'tx-exec-1', + eventIndex: 2, + occurredAt: '2026-10-20T10:10:00.000Z', + }; + + await processMultisigActionEvents([event]); + + expect(mockPrisma.multisigAction.updateMany).toHaveBeenCalledWith({ + where: { actionId: 'act-101' }, + data: expect.objectContaining({ + status: 'executed', + }), + }); + }); + + it('ignores duplicate events in the same batch', async () => { + const event = { + eventType: 'ActionProposed', + actionId: 'act-102', + proposedBy: VALID_STELLAR_ADDR, + ledger: 1000, + txHash: 'tx-prop-2', + eventIndex: 0, + }; + + await processMultisigActionEvents([event, event]); + + expect(mockPrisma.multisigAction.upsert).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/indexer/multisig-action-indexer.service.ts b/src/modules/indexer/multisig-action-indexer.service.ts new file mode 100644 index 00000000..5f6fd21a --- /dev/null +++ b/src/modules/indexer/multisig-action-indexer.service.ts @@ -0,0 +1,181 @@ +// src/modules/indexer/multisig-action-indexer.service.ts + +import { Prisma } from '@prisma/client'; +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { buildLogFields } from '../../utils/log-fields.utils'; +import { + processIndexerChainEvents, + IndexerChainEvent, +} from '../../utils/indexer-event-processor.utils'; + +export interface ActionProposedEvent extends IndexerChainEvent { + eventType: 'ACTION_PROPOSED' | 'ActionProposed' | 'action_proposed'; + actionId: string; + changeType?: string; + actionType?: string; + payload?: Record; + params?: Record; + threshold?: number; + totalSigners?: number; + proposedBy: string; + proposedAt?: string; + occurredAt?: string; +} + +export interface ActionSignedEvent extends IndexerChainEvent { + eventType: 'ACTION_SIGNED' | 'ActionSigned' | 'action_signed'; + actionId: string; + signer: string; + signedAt?: string; + occurredAt?: string; +} + +export interface ActionExecutedEvent extends IndexerChainEvent { + eventType: 'ACTION_EXECUTED' | 'ActionExecuted' | 'action_executed'; + actionId: string; + status?: string; + executedAt?: string; + expiredAt?: string; + occurredAt?: string; +} + +export type MultisigChainEvent = + | ActionProposedEvent + | ActionSignedEvent + | ActionExecutedEvent; + +/** + * Processes a batch of multi-sig action governance events (ActionProposed, ActionSigned, ActionExecuted). + * + * - Indexes events into `multisig_actions` and `signatures` tables. + * - Deduplicates events and handles replay via unique constraints (P2002). + */ +export async function processMultisigActionEvents( + events: IndexerChainEvent[] +): Promise { + await processIndexerChainEvents(events, async event => { + const rawType = event.eventType?.toUpperCase() || ''; + const normalizedType = rawType.replace(/_/g, ''); + + if ( + normalizedType !== 'ACTIONPROPOSED' && + normalizedType !== 'ACTIONSIGNED' && + normalizedType !== 'ACTIONEXECUTED' + ) { + return; + } + + try { + if (normalizedType === 'ACTIONPROPOSED') { + const e = event as ActionProposedEvent; + if (!e.actionId || !e.proposedBy) { + logger.warn( + buildLogFields({ + type: 'multisig_action_event_invalid', + eventId: `${event.txHash}:${event.eventIndex}`, + eventType: event.eventType, + }), + 'Skipping ActionProposed event due to missing required fields' + ); + return; + } + + const timestamp = e.proposedAt || e.occurredAt ? new Date(e.proposedAt || e.occurredAt!) : new Date(); + + await prisma.multisigAction.upsert({ + where: { actionId: e.actionId }, + create: { + actionId: e.actionId, + changeType: e.changeType || e.actionType || 'general', + payload: (e.payload || e.params || {}) as Prisma.InputJsonValue, + status: 'pending', + threshold: Number(e.threshold ?? 2), + totalSigners: Number(e.totalSigners ?? 3), + proposedBy: e.proposedBy, + proposedAt: timestamp, + ledger: typeof event.ledger === 'number' ? event.ledger : null, + txHash: event.txHash ? String(event.txHash) : null, + }, + update: { + // If already exists, don't overwrite pending status unless necessary + }, + }); + } else if (normalizedType === 'ACTIONSIGNED') { + const e = event as ActionSignedEvent; + if (!e.actionId || !e.signer) { + logger.warn( + buildLogFields({ + type: 'multisig_signature_event_invalid', + eventId: `${event.txHash}:${event.eventIndex}`, + eventType: event.eventType, + }), + 'Skipping ActionSigned event due to missing required fields' + ); + return; + } + + const timestamp = e.signedAt || e.occurredAt ? new Date(e.signedAt || e.occurredAt!) : new Date(); + + await prisma.signature.create({ + data: { + actionId: e.actionId, + signer: e.signer, + signedAt: timestamp, + ledger: typeof event.ledger === 'number' ? event.ledger : null, + txHash: event.txHash ? String(event.txHash) : null, + }, + }).catch(error => { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + logger.debug( + { actionId: e.actionId, signer: e.signer }, + 'Signature already indexed; skipping duplicate' + ); + return; + } + throw error; + }); + } else if (normalizedType === 'ACTIONEXECUTED') { + const e = event as ActionExecutedEvent; + if (!e.actionId) { + logger.warn( + buildLogFields({ + type: 'multisig_executed_event_invalid', + eventId: `${event.txHash}:${event.eventIndex}`, + eventType: event.eventType, + }), + 'Skipping ActionExecuted event due to missing actionId' + ); + return; + } + + const finalStatus = e.status ? e.status.toLowerCase() : 'executed'; + const timestamp = e.executedAt || e.expiredAt || e.occurredAt ? new Date(e.executedAt || e.expiredAt || e.occurredAt!) : new Date(); + + await prisma.multisigAction.updateMany({ + where: { actionId: e.actionId }, + data: { + status: finalStatus, + executedAt: finalStatus === 'executed' ? timestamp : undefined, + expiredAt: finalStatus === 'expired' ? timestamp : undefined, + }, + }); + } + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + logger.debug( + { eventId: `${event.txHash}:${event.eventIndex}` }, + 'Multisig action event already applied; skipping replay' + ); + return; + } + throw error; + } + }); +} diff --git a/src/modules/multisig/__tests__/multisig-actions.integration.test.ts b/src/modules/multisig/__tests__/multisig-actions.integration.test.ts new file mode 100644 index 00000000..c19242ac --- /dev/null +++ b/src/modules/multisig/__tests__/multisig-actions.integration.test.ts @@ -0,0 +1,183 @@ +// src/modules/multisig/__tests__/multisig-actions.integration.test.ts + +import request from 'supertest'; +import express from 'express'; +import multisigRouter from '../multisig.routes'; +import { errorHandler } from '../../../middlewares/error.middleware'; +import { prisma } from '../../../utils/prisma.utils'; + +jest.mock('../../../utils/prisma.utils', () => ({ + prisma: { + multisigAction: { + findMany: jest.fn(), + findUnique: jest.fn(), + count: jest.fn(), + }, + }, +})); + +const mockPrisma = prisma as unknown as { + multisigAction: { + findMany: jest.Mock; + findUnique: jest.Mock; + count: jest.Mock; + }; +}; + +const app = express(); +app.use(express.json()); +app.use('/multisig', multisigRouter); +app.use(errorHandler); + +const VALID_STELLAR_ADDR = 'GABUYER00000000000000000000000000000000000000000000000000'; + +describe('Multisig Action REST API (#1085)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /multisig/actions/pending', () => { + it('returns empty list when no pending actions exist', async () => { + mockPrisma.multisigAction.findMany.mockResolvedValue([]); + + const res = await request(app).get('/multisig/actions/pending'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.actions).toEqual([]); + }); + + it('returns pending actions with accurate signature count and threshold', async () => { + mockPrisma.multisigAction.findMany.mockResolvedValue([ + { + actionId: 'act-pending-1', + changeType: 'update_limit', + payload: { limit: 100 }, + status: 'pending', + threshold: 2, + totalSigners: 3, + proposedBy: VALID_STELLAR_ADDR, + signatures: [ + { signer: VALID_STELLAR_ADDR, signedAt: new Date(), ledger: 10, txHash: 'tx-s1' }, + ], + proposedAt: new Date(), + ledger: 10, + txHash: 'tx-p1', + }, + ]); + + const res = await request(app).get('/multisig/actions/pending'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.actions).toHaveLength(1); + expect(res.body.data.actions[0]).toMatchObject({ + actionId: 'act-pending-1', + changeType: 'update_limit', + status: 'pending', + threshold: 2, + totalSigners: 3, + signatureCount: 1, + }); + expect(res.body.data.actions[0].signatures).toHaveLength(1); + }); + }); + + describe('GET /multisig/actions/:action_id', () => { + it('returns 404 when action does not exist', async () => { + mockPrisma.multisigAction.findUnique.mockResolvedValue(null); + + const res = await request(app).get('/multisig/actions/nonexistent'); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); + + it('returns correct action detail with signer list and parameter data', async () => { + mockPrisma.multisigAction.findUnique.mockResolvedValue({ + actionId: 'act-detail-1', + changeType: 'upgrade_contract', + payload: { contractHash: 'abc123hash' }, + status: 'pending', + threshold: 2, + totalSigners: 3, + proposedBy: VALID_STELLAR_ADDR, + signatures: [ + { signer: VALID_STELLAR_ADDR, signedAt: new Date(), ledger: 10, txHash: 'tx-s1' }, + { signer: VALID_STELLAR_ADDR, signedAt: new Date(), ledger: 11, txHash: 'tx-s2' }, + ], + proposedAt: new Date(), + executedAt: null, + expiredAt: null, + ledger: 10, + txHash: 'tx-p1', + }); + + const res = await request(app).get('/multisig/actions/act-detail-1'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toMatchObject({ + actionId: 'act-detail-1', + changeType: 'upgrade_contract', + status: 'pending', + threshold: 2, + totalSigners: 3, + signatureCount: 2, + }); + expect(res.body.data.signatures).toHaveLength(2); + expect(res.body.data.params).toEqual({ contractHash: 'abc123hash' }); + }); + }); + + describe('GET /multisig/actions/history', () => { + it('returns accurate completed and expired records', async () => { + mockPrisma.multisigAction.findMany.mockResolvedValue([ + { + actionId: 'act-exec-1', + changeType: 'change_owner', + payload: { newOwner: VALID_STELLAR_ADDR }, + status: 'executed', + threshold: 2, + totalSigners: 3, + proposedBy: VALID_STELLAR_ADDR, + signatures: [], + proposedAt: new Date(), + executedAt: new Date('2026-10-20T11:00:00.000Z'), + expiredAt: null, + ledger: 15, + txHash: 'tx-exec', + }, + { + actionId: 'act-exp-1', + changeType: 'deprecated_action', + payload: {}, + status: 'expired', + threshold: 2, + totalSigners: 3, + proposedBy: VALID_STELLAR_ADDR, + signatures: [], + proposedAt: new Date(), + executedAt: null, + expiredAt: new Date('2026-10-20T12:00:00.000Z'), + ledger: 16, + txHash: 'tx-exp', + }, + ]); + mockPrisma.multisigAction.count.mockResolvedValue(2); + + const res = await request(app).get('/multisig/actions/history'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.history).toHaveLength(2); + expect(res.body.data.pagination.total).toBe(2); + expect(res.body.data.history).toEqual( + expect.arrayContaining([ + expect.objectContaining({ actionId: 'act-exec-1', status: 'executed' }), + expect.objectContaining({ actionId: 'act-exp-1', status: 'expired' }), + ]) + ); + }); + }); +}); diff --git a/src/modules/multisig/__tests__/multisig-actions.service.test.ts b/src/modules/multisig/__tests__/multisig-actions.service.test.ts new file mode 100644 index 00000000..354ca83a --- /dev/null +++ b/src/modules/multisig/__tests__/multisig-actions.service.test.ts @@ -0,0 +1,131 @@ +// src/modules/multisig/__tests__/multisig-actions.service.test.ts + +jest.mock('../../../utils/prisma.utils', () => ({ + prisma: { + multisigAction: { + findMany: jest.fn(), + findUnique: jest.fn(), + count: jest.fn(), + }, + }, +})); + +import { prisma } from '../../../utils/prisma.utils'; +import { + getPendingActions, + getActionDetail, + getActionHistory, + MultisigActionNotFoundError, + validateSignerEligibility, +} from '../multisig-actions.service'; + +const mockPrisma = prisma as unknown as { + multisigAction: { + findMany: jest.Mock; + findUnique: jest.Mock; + count: jest.Mock; + }; +}; + +const VALID_STELLAR_ADDR = 'GA37PRKUOWJN5DWJWBCBLAH4WWHWEQQKSKFQGSIEPKT3VJUE5DVQHSXT'; + +describe('Multisig Actions Service', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getPendingActions', () => { + it('should return pending actions with signature counts', async () => { + mockPrisma.multisigAction.findMany.mockResolvedValue([ + { + actionId: 'act-1', + changeType: 'upgrade', + payload: { v: 1 }, + status: 'pending', + threshold: 2, + totalSigners: 3, + signatures: [{ signer: VALID_STELLAR_ADDR, signedAt: new Date() }], + proposedBy: VALID_STELLAR_ADDR, + proposedAt: new Date(), + ledger: 100, + txHash: 'tx-1', + }, + ]); + + const result = await getPendingActions(); + expect(result).toHaveLength(1); + expect(result[0].actionId).toBe('act-1'); + expect(result[0].signatureCount).toBe(1); + expect(result[0].status).toBe('pending'); + }); + }); + + describe('getActionDetail', () => { + it('should return action detail when found', async () => { + mockPrisma.multisigAction.findUnique.mockResolvedValue({ + actionId: 'act-2', + changeType: 'config', + payload: { key: 'val' }, + status: 'pending', + threshold: 2, + totalSigners: 3, + signatures: [], + proposedBy: VALID_STELLAR_ADDR, + proposedAt: new Date(), + executedAt: null, + expiredAt: null, + ledger: 101, + txHash: 'tx-2', + }); + + const result = await getActionDetail('act-2'); + expect(result.actionId).toBe('act-2'); + expect(result.params).toEqual({ key: 'val' }); + expect(result.signatureCount).toBe(0); + }); + + it('should throw MultisigActionNotFoundError when not found', async () => { + mockPrisma.multisigAction.findUnique.mockResolvedValue(null); + + await expect(getActionDetail('unknown')).rejects.toThrow(MultisigActionNotFoundError); + }); + }); + + describe('getActionHistory', () => { + it('should return history with pagination', async () => { + mockPrisma.multisigAction.findMany.mockResolvedValue([ + { + actionId: 'act-3', + changeType: 'param', + payload: {}, + status: 'executed', + threshold: 2, + totalSigners: 3, + signatures: [], + proposedBy: VALID_STELLAR_ADDR, + proposedAt: new Date(), + executedAt: new Date(), + expiredAt: null, + ledger: 102, + txHash: 'tx-3', + }, + ]); + mockPrisma.multisigAction.count.mockResolvedValue(1); + + const result = await getActionHistory({ page: 1, limit: 10 }); + expect(result.history).toHaveLength(1); + expect(result.history[0].status).toBe('executed'); + expect(result.pagination.total).toBe(1); + }); + }); + + describe('validateSignerEligibility', () => { + it('should validate Stellar address format', async () => { + const valid = await validateSignerEligibility(VALID_STELLAR_ADDR); + expect(valid).toBe(true); + + const invalid = await validateSignerEligibility('INVALID'); + expect(invalid).toBe(false); + }); + }); +}); diff --git a/src/modules/multisig/multisig-actions.controllers.ts b/src/modules/multisig/multisig-actions.controllers.ts new file mode 100644 index 00000000..471c1342 --- /dev/null +++ b/src/modules/multisig/multisig-actions.controllers.ts @@ -0,0 +1,84 @@ +// src/modules/multisig/multisig-actions.controllers.ts + +import { Request, Response, NextFunction } from 'express'; +import { AsyncController } from '../../types/auth.types'; +import { z } from 'zod'; +import { + sendSuccess, + sendValidationError, + sendNotFound, + zodIssuesToDetails, +} from '../../utils/api-response.utils'; +import { + getPendingActions, + getActionDetail, + getActionHistory, + MultisigActionNotFoundError, +} from './multisig-actions.service'; +import { logger } from '../../utils/logger.utils'; + +const historyQuerySchema = z.object({ + status: z.string().optional(), + page: z.coerce.number().int().positive().optional().default(1), + limit: z.coerce.number().int().positive().max(100).optional().default(20), +}); + +export const httpGetPendingActions: AsyncController = async ( + _req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const actions = await getPendingActions(); + sendSuccess(res, { actions }); + } catch (error) { + logger.error({ error }, 'Get pending multisig actions failed'); + next(error); + } +}; + +export const httpGetActionDetail: AsyncController = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const rawActionId = req.params.actionId || req.params.action_id; + const actionId = Array.isArray(rawActionId) ? rawActionId[0] : rawActionId; + + if (!actionId || typeof actionId !== 'string' || !actionId.trim()) { + sendValidationError(res, 'actionId parameter is required'); + return; + } + + const action = await getActionDetail(actionId); + sendSuccess(res, action); + } catch (error) { + if (error instanceof MultisigActionNotFoundError) { + sendNotFound(res, error.message); + return; + } + logger.error({ error }, 'Get multisig action detail failed'); + next(error); + } +}; + +export const httpGetActionHistory: AsyncController = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const parsed = historyQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError(res, 'Invalid query parameters', zodIssuesToDetails(parsed.error.issues)); + return; + } + + const result = await getActionHistory(parsed.data); + sendSuccess(res, result); + } catch (error) { + logger.error({ error }, 'Get multisig action history failed'); + next(error); + } +}; diff --git a/src/modules/multisig/multisig-actions.service.ts b/src/modules/multisig/multisig-actions.service.ts new file mode 100644 index 00000000..edc72788 --- /dev/null +++ b/src/modules/multisig/multisig-actions.service.ts @@ -0,0 +1,176 @@ +// src/modules/multisig/multisig-actions.service.ts + +import { prisma } from '../../utils/prisma.utils'; +import { envConfig } from '../../config'; +import { getLedgerEntries } from '../../utils/soroban-rpc.utils'; +import { logger } from '../../utils/logger.utils'; + +const STELLAR_ADDRESS_PATTERN = /^G[A-Z2-7]{55}$/; + +export class MultisigActionNotFoundError extends Error { + constructor(actionId: string) { + super(`Multisig action not found: ${actionId}`); + this.name = 'MultisigActionNotFoundError'; + } +} + +export function parseMultisigAdminWallets(): string[] { + const raw = envConfig.ADMIN_MULTISIG_WALLETS; + if (!raw) return []; + return raw + .split(',') + .map(wallet => wallet.trim()) + .filter(wallet => wallet.length > 0); +} + +/** + * Fetches the signer set from on-chain contract ledger entries if configured, + * or falls back to ADMIN_MULTISIG_WALLETS environment variable configuration. + */ +export async function getOnChainSignerSet(): Promise { + const cfg = envConfig as Record; + const contractId = (cfg.ADMIN_MULTISIG_CONTRACT_ID || cfg.GOVERNANCE_CONTRACT_ID) as string | undefined; + if (contractId) { + try { + const result = await getLedgerEntries([contractId]); + if (result && result.entries && result.entries.length > 0) { + // If contract returns signer set entries, parse them here. + // Falls through to environment variable config below if empty or unparsed. + } + } catch (error) { + logger.warn({ error }, 'Failed to fetch on-chain signer set from Soroban RPC; falling back to config'); + } + } + + const adminWallets = parseMultisigAdminWallets(); + return adminWallets; +} + +export async function validateSignerEligibility(signer: string): Promise { + if (!STELLAR_ADDRESS_PATTERN.test(signer)) { + return false; + } + + const adminWallets = await getOnChainSignerSet(); + if (adminWallets.length === 0) { + // Development fallback: any valid Stellar address is allowed when no quorum is configured + return true; + } + + const adminSet = new Set(adminWallets.map(w => w.toLowerCase())); + return adminSet.has(signer.toLowerCase()); +} + +export async function getPendingActions() { + const actions = await prisma.multisigAction.findMany({ + where: { status: 'pending' }, + include: { signatures: true }, + orderBy: { proposedAt: 'desc' }, + }); + + return actions.map(action => ({ + actionId: action.actionId, + changeType: action.changeType, + payload: action.payload, + status: action.status, + threshold: action.threshold, + totalSigners: action.totalSigners, + signatureCount: action.signatures.length, + signatures: action.signatures.map(s => ({ + signer: s.signer, + signedAt: s.signedAt, + ledger: s.ledger, + txHash: s.txHash, + })), + proposedBy: action.proposedBy, + proposedAt: action.proposedAt, + ledger: action.ledger, + txHash: action.txHash, + })); +} + +export async function getActionDetail(actionId: string) { + const action = await prisma.multisigAction.findUnique({ + where: { actionId }, + include: { signatures: true }, + }); + + if (!action) { + throw new MultisigActionNotFoundError(actionId); + } + + return { + actionId: action.actionId, + changeType: action.changeType, + params: action.payload, + payload: action.payload, + status: action.status, + threshold: action.threshold, + totalSigners: action.totalSigners, + signatureCount: action.signatures.length, + signatures: action.signatures.map(s => ({ + signer: s.signer, + signedAt: s.signedAt, + ledger: s.ledger, + txHash: s.txHash, + })), + proposedBy: action.proposedBy, + proposedAt: action.proposedAt, + executedAt: action.executedAt, + expiredAt: action.expiredAt, + ledger: action.ledger, + txHash: action.txHash, + }; +} + +export async function getActionHistory(query?: { status?: string; page?: number; limit?: number }) { + const page = query?.page && query.page > 0 ? query.page : 1; + const limit = query?.limit && query.limit > 0 ? query.limit : 50; + const skip = (page - 1) * limit; + + const where: any = { + status: query?.status ? query.status : { not: 'pending' }, + }; + + const [actions, total] = await Promise.all([ + prisma.multisigAction.findMany({ + where, + include: { signatures: true }, + orderBy: { updatedAt: 'desc' }, + skip, + take: limit, + }), + prisma.multisigAction.count({ where }), + ]); + + return { + history: actions.map(action => ({ + actionId: action.actionId, + changeType: action.changeType, + payload: action.payload, + params: action.payload, + status: action.status, + threshold: action.threshold, + totalSigners: action.totalSigners, + signatureCount: action.signatures.length, + signatures: action.signatures.map(s => ({ + signer: s.signer, + signedAt: s.signedAt, + ledger: s.ledger, + txHash: s.txHash, + })), + proposedBy: action.proposedBy, + proposedAt: action.proposedAt, + executedAt: action.executedAt, + expiredAt: action.expiredAt, + ledger: action.ledger, + txHash: action.txHash, + })), + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit) || 1, + }, + }; +} diff --git a/src/modules/multisig/multisig.routes.ts b/src/modules/multisig/multisig.routes.ts new file mode 100644 index 00000000..07edd208 --- /dev/null +++ b/src/modules/multisig/multisig.routes.ts @@ -0,0 +1,17 @@ +// src/modules/multisig/multisig.routes.ts + +import { Router } from 'express'; +import { + httpGetPendingActions, + httpGetActionDetail, + httpGetActionHistory, +} from './multisig-actions.controllers'; + +const router = Router(); + +router.get('/actions/pending', httpGetPendingActions); +router.get('/actions/history', httpGetActionHistory); +router.get('/actions/:actionId', httpGetActionDetail); +router.get('/actions/:action_id', httpGetActionDetail); + +export default router;