diff --git a/backend/src/__tests__/agentFloatService.test.ts b/backend/src/__tests__/agentFloatService.test.ts index 24c3d95d..ff45cdad 100644 --- a/backend/src/__tests__/agentFloatService.test.ts +++ b/backend/src/__tests__/agentFloatService.test.ts @@ -899,4 +899,301 @@ describe('AgentFloatService', () => { expect(result).toBeDefined(); }); }); + + describe('rebuildTransferTx (#842)', () => { + const awaitingTransfer = { + id: 'ft_rebuild_1', + from_agent: AGENT_A, + to_agent: AGENT_B, + amount: '40', + status: 'AWAITING_SIGNATURE', + required_approvals: 2, + approval_count: 2, + tx_hash: null, + } as const; + + it('rebuilds the unsigned XDR for an AWAITING_SIGNATURE transfer and writes an audit log', async () => { + process.env.AGENT_VAULT_CONTRACT_ID = 'C123456789AGENTVAULT'; + mockQuery.mockResolvedValueOnce({ rows: [awaitingTransfer] }); + mockBuildTransferToAgentTx.mockResolvedValueOnce({ unsignedTxXdr: 'FRESH...XDR' }); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + const result = await service.rebuildTransferTx({ + transferId: awaitingTransfer.id, + requester: AGENT_A, + }); + + expect(result.unsignedTxXdr).toBe('FRESH...XDR'); + expect(result.executedOnChain).toBe(false); + expect(mockBuildTransferToAgentTx).toHaveBeenCalledWith(AGENT_A, AGENT_B, 400_000_000n); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO audit_logs'), + expect.arrayContaining([ + AGENT_A, + 'AGENT_FLOAT_TRANSFER_XDR_REBUILT', + `TransferID:${awaitingTransfer.id}`, + expect.stringContaining('"executedOnChain":false'), + ]), + ); + }); + + it('rejects rebuild when transfer is not in AWAITING_SIGNATURE status', async () => { + process.env.AGENT_VAULT_CONTRACT_ID = 'C123456789AGENTVAULT'; + mockQuery.mockResolvedValueOnce({ + rows: [{ ...awaitingTransfer, status: 'PENDING_APPROVAL' }], + }); + + await expect( + service.rebuildTransferTx({ + transferId: awaitingTransfer.id, + requester: AGENT_A, + }), + ).rejects.toThrow("Transfer is already in status 'PENDING_APPROVAL'."); + + expect(mockBuildTransferToAgentTx).not.toHaveBeenCalled(); + }); + + it('rejects rebuild by an unauthorized third party', async () => { + process.env.AGENT_VAULT_CONTRACT_ID = 'C123456789AGENTVAULT'; + mockQuery.mockResolvedValueOnce({ rows: [awaitingTransfer] }); + + await expect( + service.rebuildTransferTx({ + transferId: awaitingTransfer.id, + requester: THIRD_PARTY, + }), + ).rejects.toThrow(/Only the initiating agent, recipient agent, or an admin/); + + expect(mockBuildTransferToAgentTx).not.toHaveBeenCalled(); + }); + + it('fails closed when AGENT_VAULT_CONTRACT_ID is not configured', async () => { + mockQuery.mockResolvedValueOnce({ rows: [awaitingTransfer] }); + + await expect( + service.rebuildTransferTx({ + transferId: awaitingTransfer.id, + requester: AGENT_A, + }), + ).rejects.toThrow('AGENT_VAULT_CONTRACT_ID is not configured'); + + expect(mockBuildTransferToAgentTx).not.toHaveBeenCalled(); + }); + }); + + describe('cancelTransfer & expireStaleTransfers (#842)', () => { + it('cancels an AWAITING_SIGNATURE transfer, logs an audit entry, and releases pair limits', async () => { + const store: Array<{ + id: string; + from_agent: string; + to_agent: string; + amount: string; + status: string; + }> = [ + { + id: 'ft_stuck_1', + from_agent: AGENT_A, + to_agent: AGENT_B, + amount: '95000', + status: 'AWAITING_SIGNATURE', + }, + ]; + + mockQuery.mockImplementation(async (sql: string, params: unknown[] = []) => { + if (sql.includes('SELECT * FROM agent_float_transfers WHERE id = $1')) { + return { rows: store.filter((r) => r.id === params[0]).map((r) => ({ ...r })) }; + } + if (sql.includes("SET status = 'CANCELLED'")) { + const row = store.find((r) => r.id === params[0]); + if (!row) return { rows: [] }; + row.status = 'CANCELLED'; + return { rows: [{ ...row }] }; + } + if (sql.includes('COALESCE(SUM(amount), 0) as total')) { + const activeStatuses = new Set(['PENDING_APPROVAL', 'AWAITING_SIGNATURE', 'COMPLETED']); + const sum = store + .filter( + (r) => + r.from_agent === params[0] && + r.to_agent === params[1] && + activeStatuses.has(r.status), + ) + .reduce((acc, r) => acc + Number(r.amount), 0); + return { rows: [{ total: String(sum) }] }; + } + if (sql.includes('INSERT INTO agent_float_transfers')) { + const created = { + id: String(params[0]), + from_agent: String(params[1]), + to_agent: String(params[2]), + amount: String(params[3]), + status: 'PENDING_APPROVAL', + }; + store.push(created); + return { rows: [created] }; + } + if (sql.includes('INSERT INTO agent_float_transfer_approvals')) { + return { + rows: [{ id: 1, transfer_id: params[0], approver: params[1], role: params[2] }], + }; + } + return { rows: [] }; + }); + + // Before cancelling, a 10,000 transfer exceeds the 100,000 daily limit (95,000 + 10,000 > 100,000) + await expect( + service.initiateTransfer({ + fromAgent: AGENT_A, + toAgent: AGENT_B, + amount: 10000, + createdBy: AGENT_A, + }), + ).rejects.toThrow(/exceeds daily limit/i); + + // Cancel the stuck AWAITING_SIGNATURE transfer + const cancelled = await service.cancelTransfer({ + transferId: 'ft_stuck_1', + canceller: AGENT_A, + }); + expect(cancelled.status).toBe('CANCELLED'); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO audit_logs'), + expect.arrayContaining([ + AGENT_A, + 'AGENT_FLOAT_TRANSFER_CANCELLED', + 'TransferID:ft_stuck_1', + expect.stringContaining('"previousStatus":"AWAITING_SIGNATURE"'), + ]), + ); + + // Now pair limit is released and the 10,000 transfer succeeds + const initiated = await service.initiateTransfer({ + fromAgent: AGENT_A, + toAgent: AGENT_B, + amount: 10000, + createdBy: AGENT_A, + }); + expect(initiated.transfer.status).toBe('PENDING_APPROVAL'); + }); + + it('rejects cancellation by an unauthorized third party or on a completed transfer', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 'ft_cancel_unauth', + from_agent: AGENT_A, + to_agent: AGENT_B, + status: 'AWAITING_SIGNATURE', + }, + ], + }); + + await expect( + service.cancelTransfer({ + transferId: 'ft_cancel_unauth', + canceller: THIRD_PARTY, + }), + ).rejects.toThrow(/Only the initiating agent, recipient agent, or an admin/); + + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 'ft_cancel_done', + from_agent: AGENT_A, + to_agent: AGENT_B, + status: 'COMPLETED', + }, + ], + }); + + await expect( + service.cancelTransfer({ + transferId: 'ft_cancel_done', + canceller: AGENT_A, + }), + ).rejects.toThrow("Transfer is already in status 'COMPLETED'."); + }); + + it('expires stale AWAITING_SIGNATURE transfers, logs audit events, and releases pair limits', async () => { + const store: Array<{ + id: string; + from_agent: string; + to_agent: string; + amount: string; + status: string; + }> = [ + { + id: 'ft_stale_1', + from_agent: AGENT_A, + to_agent: AGENT_B, + amount: '95000', + status: 'AWAITING_SIGNATURE', + }, + ]; + + mockQuery.mockImplementation(async (sql: string, params: unknown[] = []) => { + if (sql.includes("SET status = 'EXPIRED'")) { + const expiredRows = store + .filter((r) => r.status === 'AWAITING_SIGNATURE') + .map((r) => { + r.status = 'EXPIRED'; + return { ...r }; + }); + return { rows: expiredRows }; + } + if (sql.includes('COALESCE(SUM(amount), 0) as total')) { + const activeStatuses = new Set(['PENDING_APPROVAL', 'AWAITING_SIGNATURE', 'COMPLETED']); + const sum = store + .filter( + (r) => + r.from_agent === params[0] && + r.to_agent === params[1] && + activeStatuses.has(r.status), + ) + .reduce((acc, r) => acc + Number(r.amount), 0); + return { rows: [{ total: String(sum) }] }; + } + if (sql.includes('INSERT INTO agent_float_transfers')) { + const created = { + id: String(params[0]), + from_agent: String(params[1]), + to_agent: String(params[2]), + amount: String(params[3]), + status: 'PENDING_APPROVAL', + }; + store.push(created); + return { rows: [created] }; + } + if (sql.includes('INSERT INTO agent_float_transfer_approvals')) { + return { + rows: [{ id: 1, transfer_id: params[0], approver: params[1], role: params[2] }], + }; + } + return { rows: [] }; + }); + + const expired = await service.expireStaleTransfers(60_000); + expect(expired).toHaveLength(1); + expect(expired[0]?.id).toBe('ft_stale_1'); + expect(expired[0]?.status).toBe('EXPIRED'); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO audit_logs'), + expect.arrayContaining([ + 'SYSTEM', + 'AGENT_FLOAT_TRANSFER_EXPIRED', + 'TransferID:ft_stale_1', + expect.stringContaining('"windowMs":60000'), + ]), + ); + + // Pair limit is now released after expiry + const initiated = await service.initiateTransfer({ + fromAgent: AGENT_A, + toAgent: AGENT_B, + amount: 10000, + createdBy: AGENT_A, + }); + expect(initiated.transfer.status).toBe('PENDING_APPROVAL'); + }); + }); }); diff --git a/backend/src/__tests__/agentFloatTransfer.test.ts b/backend/src/__tests__/agentFloatTransfer.test.ts index f8d5ff10..23a376fc 100644 --- a/backend/src/__tests__/agentFloatTransfer.test.ts +++ b/backend/src/__tests__/agentFloatTransfer.test.ts @@ -375,6 +375,70 @@ describe('Agent Float Transfer API (/api/agents/float-transfer)', () => { }); }); + describe('POST /api/agents/float-transfer/:id/rebuild & /cancel (#842)', () => { + it('rebuilds the unsigned XDR for an awaiting-signature transfer', async () => { + process.env.AGENT_VAULT_CONTRACT_ID = 'C123456789AGENTVAULT'; + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 'ft_12345', + from_agent: AGENT_A, + to_agent: AGENT_B, + amount: '5000', + status: 'AWAITING_SIGNATURE', + }, + ], + }); + mockBuildTransferToAgentTx.mockResolvedValueOnce({ unsignedTxXdr: 'REBUILT...XDR' }); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + const response = await request(app) + .post('/api/agents/float-transfer/ft_12345/rebuild') + .set(bearer(AGENT_A)) + .send(); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.unsignedTxXdr).toBe('REBUILT...XDR'); + expect(response.body.data.executedOnChain).toBe(false); + }); + + it('cancels an awaiting-signature transfer', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 'ft_12345', + from_agent: AGENT_A, + to_agent: AGENT_B, + amount: '5000', + status: 'AWAITING_SIGNATURE', + }, + ], + }); + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: 'ft_12345', + from_agent: AGENT_A, + to_agent: AGENT_B, + amount: '5000', + status: 'CANCELLED', + }, + ], + }); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + const response = await request(app) + .post('/api/agents/float-transfer/ft_12345/cancel') + .set(bearer(AGENT_A)) + .send(); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.transfer.status).toBe('CANCELLED'); + }); + }); + describe('GET /api/agents/float-transfer/limits & PUT /api/agents/float-transfer/limits', () => { it('gets configured or default pair limits', async () => { mockQuery.mockResolvedValueOnce({ diff --git a/backend/src/config/envSchema.ts b/backend/src/config/envSchema.ts index adc756ce..d632853e 100644 --- a/backend/src/config/envSchema.ts +++ b/backend/src/config/envSchema.ts @@ -130,6 +130,7 @@ export const envShape = { STELLAR_EURC_ISSUER: optionalString(), STELLAR_PHP_ISSUER: optionalString(), PAUSE_STATE_REFRESH_MS: int(30000), + FLOAT_TRANSFER_AWAITING_SIGNATURE_TTL_MS: positiveInt(60 * 60 * 1000), // Governance GOVERNANCE_SIGNERS: list(), diff --git a/backend/src/routes/agentFloatRoutes.ts b/backend/src/routes/agentFloatRoutes.ts index 8585d569..1257424b 100644 --- a/backend/src/routes/agentFloatRoutes.ts +++ b/backend/src/routes/agentFloatRoutes.ts @@ -230,6 +230,100 @@ router.post( }), ); +/** + * @swagger + * /agents/float-transfer/{id}/rebuild: + * post: + * summary: Rebuild the unsigned transaction XDR for an awaiting-signature float transfer + * tags: [Agents] + * security: + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Unsigned float transfer transaction XDR rebuilt. + * 400: + * description: Invalid transfer status. + * 401: + * description: Unauthorized. + * 403: + * description: Forbidden. + * 404: + * description: Transfer not found. + */ +router.post( + '/float-transfer/:id/rebuild', + requireJwtAuth, + validateParams(floatTransferIdParamsSchema), + validateQuery(emptyAgentQuerySchema), + validateBody(emptyAgentBodySchema), + asyncHandler(async (req: Request, res: Response) => { + const id = req.params.id as string; + const publicKey = (req as { user?: { publicKey?: string } }).user?.publicKey; + const role = (req as { user?: { role?: string } }).user?.role; + + const result = await agentFloatService.rebuildTransferTx({ + transferId: id, + requester: publicKey!, + userRole: role, + }); + + res.status(200).json({ success: true, data: result }); + }), +); + +/** + * @swagger + * /agents/float-transfer/{id}/cancel: + * post: + * summary: Cancel an awaiting-signature or pending float transfer and release pair limits + * tags: [Agents] + * security: + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Float transfer cancelled. + * 400: + * description: Invalid transfer status. + * 401: + * description: Unauthorized. + * 403: + * description: Forbidden. + * 404: + * description: Transfer not found. + */ +router.post( + '/float-transfer/:id/cancel', + requireJwtAuth, + validateParams(floatTransferIdParamsSchema), + validateQuery(emptyAgentQuerySchema), + validateBody(emptyAgentBodySchema), + asyncHandler(async (req: Request, res: Response) => { + const id = req.params.id as string; + const publicKey = (req as { user?: { publicKey?: string } }).user?.publicKey; + const role = (req as { user?: { role?: string } }).user?.role; + + const result = await agentFloatService.cancelTransfer({ + transferId: id, + canceller: publicKey!, + userRole: role, + }); + + res.status(200).json({ success: true, data: { transfer: result } }); + }), +); + /** * @swagger * /agents/float-transfer/limits: diff --git a/backend/src/services/agentFloatService.ts b/backend/src/services/agentFloatService.ts index d1ef32af..6dc71179 100644 --- a/backend/src/services/agentFloatService.ts +++ b/backend/src/services/agentFloatService.ts @@ -34,6 +34,18 @@ export interface RejectTransferInput { userRole?: string | undefined; } +export interface RebuildTransferInput { + transferId: string; + requester: string; + userRole?: string | undefined; +} + +export interface CancelTransferInput { + transferId: string; + canceller: string; + userRole?: string | undefined; +} + export interface SetLimitsInput { fromAgent: string; toAgent: string; @@ -54,6 +66,7 @@ export interface AgentFloatTransferRow { | 'COMPLETED' | 'REJECTED' | 'CANCELLED' + | 'EXPIRED' | 'FAILED'; required_approvals: number; approval_count: number; @@ -599,6 +612,169 @@ export class AgentFloatService { return updatedTransfer; } + /** + * Rebuild the unsigned Soroban transaction XDR for an AWAITING_SIGNATURE transfer (#842). + * Applies the same contract configuration and participant/admin checks as initial build. + */ + async rebuildTransferTx(input: RebuildTransferInput): Promise<{ + transfer: AgentFloatTransferRow; + executedOnChain: false; + unsignedTxXdr: string; + }> { + const { transferId, requester, userRole } = input; + + const transferRes = await query(`SELECT * FROM agent_float_transfers WHERE id = $1`, [ + transferId, + ]); + if (transferRes.rows.length === 0) { + throw AppError.notFound(`Float transfer request '${transferId}' not found.`); + } + + const transfer = transferRes.rows[0] as AgentFloatTransferRow; + if (transfer.status !== 'AWAITING_SIGNATURE') { + throw AppError.badRequest(`Transfer is already in status '${transfer.status}'.`); + } + + const isParticipant = requester === transfer.from_agent || requester === transfer.to_agent; + const isAdmin = userRole === 'admin' || userRole === 'super_admin'; + if (!isParticipant && !isAdmin) { + throw AppError.forbidden( + 'Only the initiating agent, recipient agent, or an admin can rebuild this transfer transaction.', + ); + } + + if (!env.AGENT_VAULT_CONTRACT_ID?.trim()) { + throw AppError.internal('AGENT_VAULT_CONTRACT_ID is not configured'); + } + + const result = await sorobanService.buildTransferToAgentTx( + transfer.from_agent, + transfer.to_agent, + toStroops(String(transfer.amount)), + ); + + await this.logAudit({ + actor: requester, + action: 'AGENT_FLOAT_TRANSFER_XDR_REBUILT', + target: `TransferID:${transferId}`, + payload: { + fromAgent: transfer.from_agent, + toAgent: transfer.to_agent, + amount: transfer.amount, + executedOnChain: false, + }, + status: 200, + }); + + return { + transfer, + executedOnChain: false, + unsignedTxXdr: result.unsignedTxXdr, + }; + } + + /** + * Cancel an in-progress float transfer (AWAITING_SIGNATURE or PENDING_APPROVAL), + * moving it to CANCELLED and releasing its contribution toward pair limits (#842). + */ + async cancelTransfer(input: CancelTransferInput): Promise { + const { transferId, canceller, userRole } = input; + + const transferRes = await query(`SELECT * FROM agent_float_transfers WHERE id = $1`, [ + transferId, + ]); + if (transferRes.rows.length === 0) { + throw AppError.notFound(`Float transfer request '${transferId}' not found.`); + } + + const transfer = transferRes.rows[0] as AgentFloatTransferRow; + if (transfer.status !== 'AWAITING_SIGNATURE' && transfer.status !== 'PENDING_APPROVAL') { + throw AppError.badRequest(`Transfer is already in status '${transfer.status}'.`); + } + + const isParticipant = canceller === transfer.from_agent || canceller === transfer.to_agent; + const isAdmin = userRole === 'admin' || userRole === 'super_admin'; + if (!isParticipant && !isAdmin) { + throw AppError.forbidden( + 'Only the initiating agent, recipient agent, or an admin can cancel this transfer.', + ); + } + + const previousStatus = transfer.status; + + const updateRes = await query( + `UPDATE agent_float_transfers + SET status = 'CANCELLED', updated_at = NOW() + WHERE id = $1 AND status IN ('PENDING_APPROVAL', 'AWAITING_SIGNATURE') + RETURNING *`, + [transferId], + ); + if (updateRes.rows.length === 0) { + throw AppError.conflict('Float transfer status changed before cancellation completed'); + } + + const updatedTransfer = updateRes.rows[0] as AgentFloatTransferRow; + + await this.logAudit({ + actor: canceller, + action: 'AGENT_FLOAT_TRANSFER_CANCELLED', + target: `TransferID:${transferId}`, + payload: { + canceller, + previousStatus, + fromAgent: transfer.from_agent, + toAgent: transfer.to_agent, + amount: transfer.amount, + }, + status: 200, + }); + + return updatedTransfer; + } + + /** + * Expire AWAITING_SIGNATURE transfers older than the configured window (#842), + * recording an audit event for each and releasing their pair limits. + */ + async expireStaleTransfers( + windowMs: number = env.FLOAT_TRANSFER_AWAITING_SIGNATURE_TTL_MS, + ): Promise { + if (!Number.isFinite(windowMs) || windowMs <= 0) { + throw AppError.badRequest('Expiry window must be a positive number of milliseconds.'); + } + + const res = await query( + `UPDATE agent_float_transfers + SET status = 'EXPIRED', updated_at = NOW() + WHERE status = 'AWAITING_SIGNATURE' + AND updated_at <= NOW() - ($1::bigint * INTERVAL '1 millisecond') + RETURNING *`, + [Math.floor(windowMs)], + ); + + const expired = res.rows as AgentFloatTransferRow[]; + + await Promise.all( + expired.map((row) => + this.logAudit({ + actor: 'SYSTEM', + action: 'AGENT_FLOAT_TRANSFER_EXPIRED', + target: `TransferID:${row.id}`, + payload: { + fromAgent: row.from_agent, + toAgent: row.to_agent, + amount: row.amount, + previousStatus: 'AWAITING_SIGNATURE', + windowMs: Math.floor(windowMs), + }, + status: 200, + }), + ), + ); + + return expired; + } + /** * Get single transfer details with approvals and audit logs. */ diff --git a/backend/src/swagger/openapi.json b/backend/src/swagger/openapi.json index 0f306288..ca54f51f 100644 --- a/backend/src/swagger/openapi.json +++ b/backend/src/swagger/openapi.json @@ -3423,6 +3423,86 @@ } } }, + "/agents/float-transfer/{id}/rebuild": { + "post": { + "summary": "Rebuild the unsigned transaction XDR for an awaiting-signature float transfer", + "tags": [ + "Agents" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Unsigned float transfer transaction XDR rebuilt." + }, + "400": { + "description": "Invalid transfer status." + }, + "401": { + "description": "Unauthorized." + }, + "403": { + "description": "Forbidden." + }, + "404": { + "description": "Transfer not found." + } + } + } + }, + "/agents/float-transfer/{id}/cancel": { + "post": { + "summary": "Cancel an awaiting-signature or pending float transfer and release pair limits", + "tags": [ + "Agents" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Float transfer cancelled." + }, + "400": { + "description": "Invalid transfer status." + }, + "401": { + "description": "Unauthorized." + }, + "403": { + "description": "Forbidden." + }, + "404": { + "description": "Transfer not found." + } + } + } + }, "/agents/float-transfer/limits": { "get": { "summary": "Get pair limits for an agent pair",