Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion src/routes/relayer.routes.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
59 changes: 59 additions & 0 deletions src/services/escrow-sync.service.js
Original file line number Diff line number Diff line change
@@ -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<Object>} `{ 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 };
12 changes: 11 additions & 1 deletion src/validation/schemas/escrow.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
64 changes: 64 additions & 0 deletions tests/routes/relayer.routes.test.js
Original file line number Diff line number Diff line change
@@ -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');

Expand All @@ -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({});
Expand Down Expand Up @@ -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');
Expand Down
101 changes: 101 additions & 0 deletions tests/services/escrow-sync.service.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
32 changes: 31 additions & 1 deletion tests/validation/schemas/escrow.schema.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
Loading