diff --git a/packages/agents-api/src/api/routes/escrows.ts b/packages/agents-api/src/api/routes/escrows.ts index c53bbe9..eadede0 100644 --- a/packages/agents-api/src/api/routes/escrows.ts +++ b/packages/agents-api/src/api/routes/escrows.ts @@ -1,31 +1,82 @@ import { Router } from 'express' +import { ethers } from 'ethers' import type { AgentsDatabase } from '../../db/database.js' +const ESCROW_CONTRACT = '0xDd4396d4F28d2b513175ae17dE11e56a898d19c3' +const CHAIN_ID = 8453 + +const ESCROW_ABI = [ + 'function createEscrow(bytes32 contentHash, bytes32 keyCommitment, address paymentToken, uint256 amount, uint256 expiryDays, bytes32 metadataHash, bytes32 category, bytes32 expectedDataHash, address designatedBuyer, bool fastSettle) returns (uint256)', + 'function fundEscrow(uint256 escrowId) payable', + 'function fundEscrowWithToken(uint256 escrowId)', +] + +const escrowInterface = new ethers.Interface(ESCROW_ABI) + export function escrowRoutes(db: AgentsDatabase): Router { const router = Router() - // GET /escrows/:escrowId router.get('/:escrowId', (req, res) => { const escrowId = parseInt(req.params.escrowId, 10) if (isNaN(escrowId)) return res.status(400).json({ error: 'Invalid escrow ID' }) - const escrow = db.getEscrow(escrowId) if (!escrow) return res.status(404).json({ error: 'Escrow not found' }) - res.json(escrow) }) - // GET /escrows?seller=&buyer=&state=&limit=&offset= router.get('/', (req, res) => { const { seller, buyer, state } = req.query as Record const limit = Math.min(parseInt(req.query.limit as string) || 50, 100) const offset = parseInt(req.query.offset as string) || 0 - const escrows = db.listEscrows({ seller, buyer, state, limit, offset }) const total = db.countEscrows({ seller, buyer, state }) - res.json({ escrows, total, limit, offset }) }) + router.post('/', (req, res) => { + try { + const { action } = req.body + if (action === 'fund') { + const { escrowId } = req.body + if (!escrowId) return res.status(400).json({ error: 'escrowId is required for funding' }) + const escrow = db.getEscrow(parseInt(escrowId, 10)) + if (!escrow) return res.status(404).json({ error: 'Escrow not found' }) + if (escrow.state !== 'created') return res.status(400).json({ error: `Escrow cannot be funded - current state: ${escrow.state}`, currentState: escrow.state }) + const isNativeToken = escrow.payment_token === '0x0000000000000000000000000000000000000000' + const functionName = isNativeToken ? 'fundEscrow' : 'fundEscrowWithToken' + const calldata = escrowInterface.encodeFunctionData(functionName, [escrowId]) + return res.json({ + action: 'fund', escrowId: escrow.id, + transaction: { to: ESCROW_CONTRACT, data: calldata, value: isNativeToken ? escrow.amount : '0', chainId: CHAIN_ID }, + escrow: { seller: escrow.seller, amount: escrow.amount, paymentToken: escrow.payment_token, state: escrow.state }, + instructions: isNativeToken ? `Send ${escrow.amount} wei to fund escrow #${escrowId}` : `Approve ${escrow.amount} tokens first, then call fundEscrowWithToken`, + }) + } else if (action === 'create') { + const { contentHash, keyCommitment, paymentToken = '0x0000000000000000000000000000000000000000', amount, expiryDays = 7, metadataHash = ethers.ZeroHash, category = ethers.ZeroHash, expectedDataHash = ethers.ZeroHash, designatedBuyer = ethers.ZeroAddress, fastSettle = false } = req.body + if (!contentHash) return res.status(400).json({ error: 'contentHash is required' }) + if (!keyCommitment) return res.status(400).json({ error: 'keyCommitment is required' }) + if (!amount) return res.status(400).json({ error: 'amount is required' }) + const bytes32Regex = /^0x[a-fA-F0-9]{64}$/ + if (!bytes32Regex.test(contentHash)) return res.status(400).json({ error: 'contentHash must be a valid bytes32 (0x + 64 hex chars)' }) + if (!bytes32Regex.test(keyCommitment)) return res.status(400).json({ error: 'keyCommitment must be a valid bytes32 (0x + 64 hex chars)' }) + const calldata = escrowInterface.encodeFunctionData('createEscrow', [contentHash, keyCommitment, paymentToken, amount, expiryDays, metadataHash, category, expectedDataHash, designatedBuyer, fastSettle]) + return res.json({ + action: 'create', + transaction: { to: ESCROW_CONTRACT, data: calldata, value: '0', chainId: CHAIN_ID }, + params: { contentHash, keyCommitment, paymentToken, amount, expiryDays, metadataHash, category, expectedDataHash, designatedBuyer, fastSettle }, + instructions: 'Submit this transaction to create the escrow. The escrow ID will be emitted in the EscrowCreated event.', + }) + } else { + return res.status(400).json({ + error: 'Invalid action. Use "create" (seller) or "fund" (buyer)', + usage: { create: { action: 'create', contentHash: '0x... (bytes32)', keyCommitment: '0x... (bytes32)', amount: '1000000000000000000 (wei)', paymentToken: '0x0000... (optional)', expiryDays: 7 }, fund: { action: 'fund', escrowId: '123' } } + }) + } + } catch (err) { + console.error('[escrows] POST error:', err) + return res.status(500).json({ error: 'Failed to prepare transaction' }) + } + }) + return router } diff --git a/packages/agents-api/test/api.test.ts b/packages/agents-api/test/api.test.ts index bd4a71d..22d0325 100644 --- a/packages/agents-api/test/api.test.ts +++ b/packages/agents-api/test/api.test.ts @@ -15,7 +15,6 @@ describe('API routes', () => { tmpDir = mkdtempSync(join(tmpdir(), 'agents-api-test-')) db = new AgentsDatabase(join(tmpDir, 'test.db')) - // Create a mock indexer const indexer = { getStatus: () => ({ chains: [] }), start: async () => {}, @@ -24,7 +23,6 @@ describe('API routes', () => { server = createServer(db, indexer) - // Insert test data db.upsertEscrow({ id: 1, chainId: 8453, @@ -46,14 +44,18 @@ describe('API routes', () => { rmSync(tmpDir, { recursive: true }) }) - // Use supertest-like approach with raw http - async function request(path: string): Promise<{ status: number; body: any }> { + async function request(path: string, options?: { method?: string; body?: any }): Promise<{ status: number; body: any }> { return new Promise((resolve) => { const { createServer: httpServer } = require('http') const s = httpServer(server) s.listen(0, () => { const port = s.address().port - fetch(`http://localhost:${port}${path}`) + const fetchOptions: RequestInit = { + method: options?.method || 'GET', + headers: options?.body ? { 'Content-Type': 'application/json' } : undefined, + body: options?.body ? JSON.stringify(options.body) : undefined, + } + fetch(\`http://localhost:\${port}\${path}\`, fetchOptions) .then(async (res) => { const body = await res.json() s.close() @@ -103,4 +105,72 @@ describe('API routes', () => { expect(res.status).toBe(200) expect(res.body.total_escrows).toBeDefined() }) + + // POST /escrows tests + it('POST /api/v1/escrows without action returns usage info', async () => { + const res = await request('/api/v1/escrows', { method: 'POST', body: {} }) + expect(res.status).toBe(400) + expect(res.body.error).toContain('Invalid action') + expect(res.body.usage).toBeDefined() + expect(res.body.usage.create).toBeDefined() + expect(res.body.usage.fund).toBeDefined() + }) + + it('POST /api/v1/escrows with action=fund returns transaction data', async () => { + db.upsertEscrow({ + id: 99, + chainId: 8453, + seller: '0x3333333333333333333333333333333333333333', + amount: '500000000000000000', + paymentToken: '0x0000000000000000000000000000000000000000', + state: 'created', + createdAt: 1700000000, + }) + + const res = await request('/api/v1/escrows', { + method: 'POST', + body: { action: 'fund', escrowId: '99' } + }) + expect(res.status).toBe(200) + expect(res.body.action).toBe('fund') + expect(res.body.transaction).toBeDefined() + expect(res.body.transaction.to).toBe('0xDd4396d4F28d2b513175ae17dE11e56a898d19c3') + expect(res.body.transaction.data).toMatch(/^0x/) + expect(res.body.transaction.chainId).toBe(8453) + }) + + it('POST /api/v1/escrows with action=fund for non-existent escrow returns 404', async () => { + const res = await request('/api/v1/escrows', { + method: 'POST', + body: { action: 'fund', escrowId: '99999' } + }) + expect(res.status).toBe(404) + }) + + it('POST /api/v1/escrows with action=create returns transaction data', async () => { + const res = await request('/api/v1/escrows', { + method: 'POST', + body: { + action: 'create', + contentHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + keyCommitment: '0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321', + amount: '1000000000000000000', + } + }) + expect(res.status).toBe(200) + expect(res.body.action).toBe('create') + expect(res.body.transaction).toBeDefined() + expect(res.body.transaction.to).toBe('0xDd4396d4F28d2b513175ae17dE11e56a898d19c3') + expect(res.body.transaction.data).toMatch(/^0x/) + expect(res.body.params.contentHash).toBe('0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef') + }) + + it('POST /api/v1/escrows with action=create validates required fields', async () => { + const res = await request('/api/v1/escrows', { + method: 'POST', + body: { action: 'create' } + }) + expect(res.status).toBe(400) + expect(res.body.error).toContain('contentHash is required') + }) })