diff --git a/src/routes/relayer.routes.js b/src/routes/relayer.routes.js index 94bafc5..5411569 100644 --- a/src/routes/relayer.routes.js +++ b/src/routes/relayer.routes.js @@ -1,10 +1,12 @@ const express = require('express'); const router = express.Router(); const { validate } = require('../middleware/validate.middleware'); -const { submitEscrowSchema } = require('../validation/schemas/escrow.schema'); +const { submitEscrowSchema, syncEscrowSchema } = require('../validation/schemas/escrow.schema'); const { fundWalletSchema } = require('../validation/schemas/funding.schema'); const { createWalletProvider } = require('../providers/wallet.provider'); const { createFundingService } = require('../services/funding.service'); +const { createEscrowSyncService } = require('../services/escrow-sync.service'); +const { EscrowIntentRepository } = require('../repositories/escrow-intent.repository'); // TODO: Import escrow service and horizon service (to be implemented in Phase 4) // const escrowService = require('../services/escrow.service'); @@ -14,6 +16,10 @@ const { createFundingService } = require('../services/funding.service'); const walletProvider = createWalletProvider(); const fundingService = createFundingService({ walletProvider }); +// Compose the on-chain synchronization service against the escrow intent repository. +const escrowIntentRepository = new EscrowIntentRepository(); +const escrowSyncService = createEscrowSyncService({ escrowIntentRepository }); + /** * POST /submit-escrow * Endpoint for the WhatsApp bot to request a new escrow action. @@ -42,6 +48,32 @@ router.post('/fund', validate(fundWalletSchema), async (req, res, next) => { } }); +/** + * POST /escrow/:id/sync + * Internal/webhook endpoint that bridges an EscrowIntent's off-chain record + * with the outcome of its on-chain transaction: on a reported SUCCESS it + * deterministically stamps the intent with the on-chain escrow id and + * transitions its status from PENDING to LOCKED. Not tied to a specific + * user session, so it is not gated by the `authenticate` middleware. + */ +router.post('/escrow/:id/sync', validate(syncEscrowSchema), async (req, res, next) => { + try { + const result = await escrowSyncService.syncEscrowOnChain({ + escrowIntentId: req.params.id, + sorobanEscrowId: req.body.sorobanEscrowId, + status: req.body.status, + }); + + res.status(200).json({ + success: true, + message: 'Escrow intent synchronization processed', + data: result, + }); + } catch (error) { + next(error); + } +}); + /** * GET /status/:txId * Endpoint to check the on-chain status of a previously submitted transaction. diff --git a/src/services/escrow-sync.service.js b/src/services/escrow-sync.service.js new file mode 100644 index 0000000..62c45fc --- /dev/null +++ b/src/services/escrow-sync.service.js @@ -0,0 +1,59 @@ +const AppError = require('../errors/AppError'); + +/** + * Factory function for the On-Chain Synchronization Service. + * + * Bridges the off-chain EscrowIntent record with the on-chain truth: given + * the outcome of a previously submitted transaction (reported by a webhook + * or internal callback, never polled directly here), it deterministically + * stamps the intent with the resulting Soroban escrow id and transitions its + * status from PENDING to LOCKED. + * + * @param {Object} deps - Dependencies + * @param {Object} deps.escrowIntentRepository - Escrow Intent Data Access Repository. + */ +const createEscrowSyncService = ({ escrowIntentRepository }) => { + /** + * Synchronizes an EscrowIntent with the outcome of its on-chain transaction. + * @param {Object} params - Synchronization parameters + * @param {string} params.escrowIntentId - The EscrowIntent to synchronize. + * @param {string} params.sorobanEscrowId - The on-chain escrow id to stamp. + * @param {string} params.status - The reported transaction status (e.g. 'SUCCESS'). + * @returns {Promise} `{ escrowIntent, synchronized }` + */ + const syncEscrowOnChain = async ({ escrowIntentId, sorobanEscrowId, status }) => { + const escrowIntent = await escrowIntentRepository.findById(escrowIntentId); + if (!escrowIntent) { + throw new AppError('Escrow intent not found', 404); + } + + if (status !== 'SUCCESS') { + // Not a caller error: the reported transaction simply didn't succeed, + // so there is nothing to synchronize. + return { escrowIntent, synchronized: false }; + } + + if (escrowIntent.status === 'LOCKED') { + if (escrowIntent.sorobanEscrowId === sorobanEscrowId) { + // Duplicate delivery of the same synchronization event: idempotent no-op. + return { escrowIntent, synchronized: true }; + } + throw new AppError('Escrow intent already locked with a different on-chain escrow id', 409); + } + + if (escrowIntent.status !== 'PENDING') { + throw new AppError(`Escrow intent is not awaiting synchronization in its current state: ${escrowIntent.status}`, 409); + } + + const updatedEscrowIntent = await escrowIntentRepository.update(escrowIntentId, { + sorobanEscrowId, + status: 'LOCKED', + }); + + return { escrowIntent: updatedEscrowIntent, synchronized: true }; + }; + + return { syncEscrowOnChain }; +}; + +module.exports = { createEscrowSyncService }; diff --git a/src/validation/schemas/escrow.schema.js b/src/validation/schemas/escrow.schema.js index 75120f3..5383b59 100644 --- a/src/validation/schemas/escrow.schema.js +++ b/src/validation/schemas/escrow.schema.js @@ -22,4 +22,14 @@ const escrowActionSchema = z.object({ }), }); -module.exports = { submitEscrowSchema, createEscrowSchema, escrowActionSchema }; +const syncEscrowSchema = z.object({ + params: z.object({ + id: z.string().min(1, "Escrow ID is required"), + }), + body: z.object({ + sorobanEscrowId: z.string().min(1, "On-chain escrow id is required"), + status: z.enum(['SUCCESS', 'FAILED', 'NOT_FOUND', 'PENDING']), + }), +}); + +module.exports = { submitEscrowSchema, createEscrowSchema, escrowActionSchema, syncEscrowSchema }; diff --git a/tests/routes/relayer.routes.test.js b/tests/routes/relayer.routes.test.js index 672855b..0f87414 100644 --- a/tests/routes/relayer.routes.test.js +++ b/tests/routes/relayer.routes.test.js @@ -1,5 +1,20 @@ const express = require('express'); const request = require('supertest'); +const AppError = require('../../src/errors/AppError'); + +// Mock prisma so repository construction never touches a real database. +jest.mock('../../src/clients/prisma.client', () => ({})); + +// Mock the escrow sync service so route tests never touch the database. +jest.mock('../../src/services/escrow-sync.service', () => ({ + createEscrowSyncService: jest.fn().mockReturnValue({ + syncEscrowOnChain: jest.fn(), + }), +})); + +const { createEscrowSyncService } = require('../../src/services/escrow-sync.service'); +const escrowSyncServiceMock = createEscrowSyncService(); + const relayerRoutes = require('../../src/routes/relayer.routes'); const errorHandler = require('../../src/middleware/error.middleware'); @@ -13,6 +28,10 @@ describe('Relayer Routes', () => { app.use(errorHandler); }); + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('POST /api/relayer/submit-escrow', () => { it('should fail validation when payload is missing', async () => { const res = await request(app).post('/api/relayer/submit-escrow').send({}); @@ -77,6 +96,51 @@ describe('Relayer Routes', () => { }); }); + describe('POST /api/relayer/escrow/:id/sync', () => { + it('should reject an invalid payload at the validation layer', async () => { + const res = await request(app) + .post('/api/relayer/escrow/intent-1/sync') + .send({ status: 'SUCCESS' }); // missing sorobanEscrowId + + expect(res.status).toBe(400); + expect(res.body.error).toBe('VALIDATION_ERROR'); + expect(escrowSyncServiceMock.syncEscrowOnChain).not.toHaveBeenCalled(); + }); + + it('should synchronize a valid payload and return 200', async () => { + const serviceResult = { + escrowIntent: { id: 'intent-1', status: 'LOCKED', sorobanEscrowId: '42' }, + synchronized: true, + }; + escrowSyncServiceMock.syncEscrowOnChain.mockResolvedValue(serviceResult); + + const res = await request(app) + .post('/api/relayer/escrow/intent-1/sync') + .send({ sorobanEscrowId: '42', status: 'SUCCESS' }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toEqual(serviceResult); + expect(escrowSyncServiceMock.syncEscrowOnChain).toHaveBeenCalledWith({ + escrowIntentId: 'intent-1', + sorobanEscrowId: '42', + status: 'SUCCESS', + }); + }); + + it('should propagate service errors through the error handler', async () => { + escrowSyncServiceMock.syncEscrowOnChain.mockRejectedValue(new AppError('Escrow intent not found', 404)); + + const res = await request(app) + .post('/api/relayer/escrow/intent-1/sync') + .send({ sorobanEscrowId: '42', status: 'SUCCESS' }); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('Escrow intent not found'); + }); + }); + describe('GET /api/relayer/status/:txId', () => { it('should hit scaffolded route and return txId', async () => { const res = await request(app).get('/api/relayer/status/12345'); diff --git a/tests/services/escrow-sync.service.test.js b/tests/services/escrow-sync.service.test.js new file mode 100644 index 0000000..e1388d0 --- /dev/null +++ b/tests/services/escrow-sync.service.test.js @@ -0,0 +1,101 @@ +const { createEscrowSyncService } = require('../../src/services/escrow-sync.service'); + +describe('Escrow Sync Service', () => { + let escrowSyncService; + let escrowIntentRepositoryMock; + + const pendingIntent = { + id: 'intent-1', + buyerId: 'buyer-1', + sellerId: 'seller-1', + amount: 100, + status: 'PENDING', + sorobanEscrowId: null, + }; + + beforeEach(() => { + escrowIntentRepositoryMock = { + findById: jest.fn().mockResolvedValue({ ...pendingIntent }), + update: jest.fn().mockResolvedValue({ ...pendingIntent, status: 'LOCKED', sorobanEscrowId: '42' }), + }; + + escrowSyncService = createEscrowSyncService({ escrowIntentRepository: escrowIntentRepositoryMock }); + }); + + it('should transition a PENDING intent to LOCKED and stamp the on-chain escrow id', async () => { + const result = await escrowSyncService.syncEscrowOnChain({ + escrowIntentId: 'intent-1', + sorobanEscrowId: '42', + status: 'SUCCESS', + }); + + expect(escrowIntentRepositoryMock.update).toHaveBeenCalledWith('intent-1', { + sorobanEscrowId: '42', + status: 'LOCKED', + }); + expect(result.synchronized).toBe(true); + expect(result.escrowIntent.status).toBe('LOCKED'); + expect(result.escrowIntent.sorobanEscrowId).toBe('42'); + }); + + it('should throw 404 when the escrow intent does not exist', async () => { + escrowIntentRepositoryMock.findById.mockResolvedValue(null); + + await expect( + escrowSyncService.syncEscrowOnChain({ escrowIntentId: 'missing', sorobanEscrowId: '42', status: 'SUCCESS' }) + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it('should not mutate the record and report synchronized:false for a non-SUCCESS status', async () => { + const result = await escrowSyncService.syncEscrowOnChain({ + escrowIntentId: 'intent-1', + sorobanEscrowId: '42', + status: 'FAILED', + }); + + expect(result.synchronized).toBe(false); + expect(result.escrowIntent.status).toBe('PENDING'); + expect(escrowIntentRepositoryMock.update).not.toHaveBeenCalled(); + }); + + it('should be idempotent when the same synchronization event is delivered twice', async () => { + escrowIntentRepositoryMock.findById.mockResolvedValue({ + ...pendingIntent, + status: 'LOCKED', + sorobanEscrowId: '42', + }); + + const result = await escrowSyncService.syncEscrowOnChain({ + escrowIntentId: 'intent-1', + sorobanEscrowId: '42', + status: 'SUCCESS', + }); + + expect(result.synchronized).toBe(true); + expect(escrowIntentRepositoryMock.update).not.toHaveBeenCalled(); + }); + + it('should throw 409 when already LOCKED with a different on-chain escrow id', async () => { + escrowIntentRepositoryMock.findById.mockResolvedValue({ + ...pendingIntent, + status: 'LOCKED', + sorobanEscrowId: '42', + }); + + await expect( + escrowSyncService.syncEscrowOnChain({ escrowIntentId: 'intent-1', sorobanEscrowId: '99', status: 'SUCCESS' }) + ).rejects.toMatchObject({ statusCode: 409 }); + + expect(escrowIntentRepositoryMock.update).not.toHaveBeenCalled(); + }); + + it('should throw 409 when the intent is in an unexpected lifecycle state', async () => { + escrowIntentRepositoryMock.findById.mockResolvedValue({ ...pendingIntent, status: 'CANCELLED' }); + + await expect( + escrowSyncService.syncEscrowOnChain({ escrowIntentId: 'intent-1', sorobanEscrowId: '42', status: 'SUCCESS' }) + ).rejects.toMatchObject({ statusCode: 409 }); + + expect(escrowIntentRepositoryMock.update).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/validation/schemas/escrow.schema.test.js b/tests/validation/schemas/escrow.schema.test.js index 84ca696..81f31da 100644 --- a/tests/validation/schemas/escrow.schema.test.js +++ b/tests/validation/schemas/escrow.schema.test.js @@ -1,4 +1,4 @@ -const { createEscrowSchema, submitEscrowSchema, escrowActionSchema } = require('../../../src/validation/schemas/escrow.schema'); +const { createEscrowSchema, submitEscrowSchema, escrowActionSchema, syncEscrowSchema } = require('../../../src/validation/schemas/escrow.schema'); describe('Escrow Schemas', () => { describe('createEscrowSchema', () => { @@ -59,4 +59,34 @@ describe('Escrow Schemas', () => { expect(result.success).toBe(false); }); }); + + describe('syncEscrowSchema', () => { + it('should validate a correct synchronization payload', () => { + const payload = { params: { id: 'intent-1' }, body: { sorobanEscrowId: '42', status: 'SUCCESS' } }; + const result = syncEscrowSchema.safeParse(payload); + expect(result.success).toBe(true); + }); + + it('should accept any recognized transaction status', () => { + ['SUCCESS', 'FAILED', 'NOT_FOUND', 'PENDING'].forEach((status) => { + const result = syncEscrowSchema.safeParse({ params: { id: 'intent-1' }, body: { sorobanEscrowId: '42', status } }); + expect(result.success).toBe(true); + }); + }); + + it('should fail when sorobanEscrowId is missing', () => { + const result = syncEscrowSchema.safeParse({ params: { id: 'intent-1' }, body: { status: 'SUCCESS' } }); + expect(result.success).toBe(false); + }); + + it('should fail for an unrecognized status', () => { + const result = syncEscrowSchema.safeParse({ params: { id: 'intent-1' }, body: { sorobanEscrowId: '42', status: 'BOGUS' } }); + expect(result.success).toBe(false); + }); + + it('should fail when escrow id param is missing', () => { + const result = syncEscrowSchema.safeParse({ params: {}, body: { sorobanEscrowId: '42', status: 'SUCCESS' } }); + expect(result.success).toBe(false); + }); + }); });