diff --git a/.env.example b/.env.example index bd073ffd..1997418d 100644 --- a/.env.example +++ b/.env.example @@ -436,6 +436,10 @@ FACILITATOR_SECRET_KEY= # Maximum fee in stroops the facilitator will pay (default 50000). FACILITATOR_FEE_STROOPS_TESTNET=50000 FACILITATOR_FEE_STROOPS_MAINNET=50000 +# Per-settlement spend ceiling (#147). Settlements exceeding this native amount are rejected. +FACILITATOR_SPEND_CEILING= +# Comma-separated allowlist of permitted caller API key IDs (#147). +FACILITATOR_CALLER_ALLOWLIST= # --- Price Plausibility Sanity Bounds --- # Lower and upper bounds for sanity-checking derived venue prices. diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 6b07abba..e3d16135 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -705,10 +705,60 @@ export interface paths { patch?: never; trace?: never; }; + "/rates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Query latest lending and borrow rates across pools + * @description Returns the latest supply and borrow rates, utilisation, and reserves for lending markets. + */ + get: operations["getLendingRates"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { schemas: { + LendingRateEntry: { + id: string; + /** @example blend */ + venue: string; + /** @example blend */ + source: string; + /** @example mainnet */ + network: string; + poolId: string; + poolName?: string | null; + /** @example USDC */ + asset: string; + assetAddress?: string | null; + supplyApy: number; + borrowApy: number; + supplyApr?: number | null; + borrowApr?: number | null; + /** @description Borrowed / supplied as a fraction. Null for venues that publish rates only. */ + utilization?: number | null; + totalSupply?: number | null; + totalBorrowed?: number | null; + reserves?: number | null; + ledger?: number; + /** Format: date-time */ + timestamp: string; + }; + LendingRatesResponse: { + network: string; + rates: components["schemas"]["LendingRateEntry"][]; + }; InternalErrorResponse: { /** * @example internal_error @@ -3118,4 +3168,41 @@ export interface operations { 402: components["responses"]["PaymentRequired"]; }; }; + getLendingRates: { + parameters: { + query?: { + /** @description Filter by asset symbol (e.g. USDC, XLM) */ + asset?: string; + /** @description Filter by pool contract ID */ + poolId?: string; + /** @description Stellar network to query (testnet or mainnet) */ + network?: "testnet" | "mainnet"; + /** @description Maximum number of rate records to return */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Latest lending rates */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LendingRatesResponse"]; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; } diff --git a/openapi.json b/openapi.json index 0c401f5e..1fbff70b 100644 --- a/openapi.json +++ b/openapi.json @@ -2845,7 +2845,7 @@ } }, "400": { - "$ref": "#/components/responses/BadRequest" + "description": "Invalid parameters" }, "429": { "$ref": "#/components/responses/TooManyRequests" diff --git a/openapi.yaml b/openapi.yaml index 72d5778b..308e481b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1921,7 +1921,7 @@ paths: schema: $ref: '#/components/schemas/LendingRatesResponse' '400': - $ref: '#/components/responses/BadRequest' + description: Invalid parameters '429': $ref: '#/components/responses/TooManyRequests' diff --git a/src/__tests__/facilitatorSettle.test.ts b/src/__tests__/facilitatorSettle.test.ts index dafbad54..2e569f45 100644 --- a/src/__tests__/facilitatorSettle.test.ts +++ b/src/__tests__/facilitatorSettle.test.ts @@ -10,11 +10,12 @@ import { } from '@stellar/stellar-sdk' import * as catalogModule from '../bazaar/catalog' -const { mockCreate, mockFindUnique, mockUpdate, mockGetFacilitator, mockSettle, mockGetTransaction } = vi.hoisted( +const { mockCreate, mockFindUnique, mockUpdate, mockCount, mockGetFacilitator, mockSettle, mockGetTransaction } = vi.hoisted( () => ({ mockCreate: vi.fn(), mockFindUnique: vi.fn(), mockUpdate: vi.fn(), + mockCount: vi.fn(), mockGetFacilitator: vi.fn(), mockSettle: vi.fn(), mockGetTransaction: vi.fn(), @@ -27,6 +28,7 @@ vi.mock('../db', () => ({ create: mockCreate, findUnique: mockFindUnique, update: mockUpdate, + count: mockCount, }, }, })) @@ -36,6 +38,25 @@ vi.mock('../x402/facilitator', async importOriginal => { return { ...actual, getFacilitator: mockGetFacilitator } }) +vi.mock('../api/auth', async importOriginal => { + const actual = (await importOriginal()) as Record + return { + ...actual, + lookupApiKey: vi.fn(async (key: string) => { + if (key === 'valid-secret-key') { + return { + id: 'trusted-key-id', + label: 'trusted', + ratePerMin: 100, + ratePerDay: 1000, + scopes: [], + } + } + return null + }), + } +}) + vi.mock('@stellar/stellar-sdk', async importOriginal => { const actual = (await importOriginal()) as Record return { @@ -116,6 +137,7 @@ beforeEach(() => { }) mockGetFacilitator.mockReset().mockReturnValue({ settle: mockSettle }) mockGetTransaction.mockReset() + mockCount.mockReset().mockResolvedValue(0) }) describe('POST /settle', () => { @@ -428,3 +450,174 @@ describe('deriveIdempotencyKey', () => { expect(deriveIdempotencyKey('not-xdr', 'testnet')).toBeNull() }) }) + +describe('Hardened x402 Facilitator Controls (#147)', () => { + afterEach(() => { + delete process.env.FACILITATOR_SPEND_CEILING + delete process.env.FACILITATOR_DAILY_CEILING_TESTNET + delete process.env.FACILITATOR_DAILY_CEILING_MAINNET + delete process.env.FACILITATOR_CALLER_ALLOWLIST + delete process.env.FACILITATOR_ALLOWED_CALLERS + delete process.env.FACILITATOR_ALLOWED_ORIGINS + }) + + it('rejects settlements exceeding per-settlement spend ceiling based on authoritative amount', async () => { + process.env.FACILITATOR_SPEND_CEILING = '0.5' // TESTNET_ENVELOPE has authoritative amount = 1 XLM > 0.5 + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/settle', + payload: settleBody(), + }) + + expect(res.statusCode).toBe(400) + const body = res.json() + expect(body.success).toBe(false) + expect(body.errorReason).toBe('spend_ceiling_exceeded') + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('fails closed when ceiling is configured and authoritative amount cannot be decoded (#147)', async () => { + process.env.FACILITATOR_SPEND_CEILING = '50' + const app = await buildApp() + + const keypair = Keypair.random() + const account = new Account(keypair.publicKey(), '1') + const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET }) + .addOperation(Operation.manageData({ name: 'test', value: 'data' })) + .setTimeout(60) + .build() + tx.sign(keypair) + const nonPaymentEnvelope = tx.toXDR() + + const res = await app.inject({ + method: 'POST', + url: '/settle', + payload: settleBody({ transaction: nonPaymentEnvelope }), + }) + + expect(res.statusCode).toBe(400) + const body = res.json() + expect(body.success).toBe(false) + expect(body.errorReason).toBe('unverifiable_settlement_amount') + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('enforces rolling daily settlement ceiling per network', async () => { + process.env.FACILITATOR_DAILY_CEILING_TESTNET = '5' + mockCount.mockResolvedValueOnce(5) // Daily ceiling already reached + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/settle', + payload: settleBody(), + }) + + expect(res.statusCode).toBe(429) + const body = res.json() + expect(body.success).toBe(false) + expect(body.errorReason).toBe('daily_spend_ceiling_exceeded') + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('allows settlement when within daily ceiling', async () => { + process.env.FACILITATOR_DAILY_CEILING_TESTNET = '10' + mockCount.mockResolvedValueOnce(3) + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/settle', + payload: settleBody(), + }) + + expect(res.statusCode).toBe(200) + expect(mockSettle).toHaveBeenCalledTimes(1) + }) + + it('refuses callers not on the allowlist when allowlist is configured, rejecting forged origin and caller headers', async () => { + process.env.FACILITATOR_CALLER_ALLOWLIST = 'trusted-key-id' + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/settle', + headers: { + 'x-caller-id': 'trusted-key-id', // Caller-supplied headers must NOT bypass allowlist + origin: 'https://trusted-key-id.com', + }, + payload: settleBody(), + }) + + expect(res.statusCode).toBe(403) + const body = res.json() + expect(body.success).toBe(false) + expect(body.errorReason).toBe('caller_not_allowed') + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('permits callers on the allowlist when authenticated via API key', async () => { + process.env.FACILITATOR_CALLER_ALLOWLIST = 'trusted-key-id' + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/settle', + headers: { + authorization: 'Bearer valid-secret-key', + }, + payload: settleBody(), + }) + + expect(res.statusCode).toBe(200) + expect(mockSettle).toHaveBeenCalledTimes(1) + }) + + it('persists authoritative decoded amount into settlementAttempt rather than user-supplied amount', async () => { + const app = await buildApp() + + // Settle with TESTNET_ENVELOPE (which contains 1 XLM), while paymentRequirements.amount is '1000000' + const res = await app.inject({ + method: 'POST', + url: '/settle', + payload: settleBody(), + }) + + expect(res.statusCode).toBe(200) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + amount: '1', // Decoded from XDR, not '1000000' + }), + }), + ) + }) + + it('replaying an identical settle returns the stored answer and submits exactly once', async () => { + // Unique violation triggers the idempotency duplicate detection + mockCreate.mockRejectedValueOnce(uniqueViolation()) + mockFindUnique.mockResolvedValueOnce({ + state: 'settled', + response: { + success: true, + transaction: 'already-settled-hash', + network: 'stellar:testnet', + }, + }) + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/settle', + payload: settleBody(), + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.success).toBe(true) + expect(body.transaction).toBe('already-settled-hash') + expect(mockSettle).toHaveBeenCalledTimes(0) // Exactly zero re-submissions! + }) +}) diff --git a/src/routes/facilitator.ts b/src/routes/facilitator.ts index c28ef1f7..bf9ebdaf 100644 --- a/src/routes/facilitator.ts +++ b/src/routes/facilitator.ts @@ -1,5 +1,6 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' -import { TransactionBuilder } from '@stellar/stellar-sdk' +import { TransactionBuilder, scValToNative } from '@stellar/stellar-sdk' +import { extractBearer, lookupApiKey } from '../api/auth' import { rpc } from '@stellar/stellar-sdk' import { prisma } from '../db' import { getNetworkConfig, type NetworkName } from '../config' @@ -54,6 +55,53 @@ interface SettleRequestBody { * transaction we can parse, which is a malformed request rather than a * settlement failure. */ + +/** + * Authoritatively decodes the payment amount directly from the signed transaction XDR + * rather than trusting user-supplied paymentRequirements.amount. + */ +export function extractAuthoritativeAmount(transactionXdr: string, network: NetworkName): number | null { + try { + const passphrase = getNetworkConfig(network).network.passphrase + const tx = TransactionBuilder.fromXDR(transactionXdr, passphrase) + let total = 0 + let hasPayment = false + for (const op of tx.operations) { + if (op.type === 'payment' && 'amount' in op && typeof op.amount === 'string') { + total += parseFloat(op.amount) + hasPayment = true + } else if (op.type === 'pathPaymentStrictReceive' && 'destAmount' in op && typeof op.destAmount === 'string') { + total += parseFloat(op.destAmount) + hasPayment = true + } else if (op.type === 'pathPaymentStrictSend' && 'sendAmount' in op && typeof op.sendAmount === 'string') { + total += parseFloat(op.sendAmount) + hasPayment = true + } else if (op.type === 'invokeHostFunction' && 'func' in op) { + try { + const func = (op as any).func + if (func && typeof func.invokeContract === 'function') { + const invokeArgs = func.invokeContract() + const fnName = invokeArgs.functionName().toString() + const args = invokeArgs.args() + if ((fnName === 'transfer' || fnName === 'transfer_from') && Array.isArray(args) && args.length >= 3) { + const nativeVal = scValToNative(args[2]) + if (typeof nativeVal === 'bigint' || typeof nativeVal === 'number') { + total += Number(nativeVal) / 1e7 + hasPayment = true + } + } + } + } catch { + // Soroban parsing error falls through to fail-closed check + } + } + } + return hasPayment ? total : null + } catch { + return null + } +} + export function deriveIdempotencyKey(transactionXdr: string, network: NetworkName): string | null { try { const passphrase = getNetworkConfig(network).network.passphrase @@ -147,36 +195,161 @@ function isUniqueViolation(err: unknown): boolean { * payment to accept one. */ export async function registerSettleRoute(app: FastifyInstance) { - app.post('/settle', { config: { public: true } }, async (req: FastifyRequest, reply: FastifyReply) => { - const body = (req.body ?? {}) as SettleRequestBody - - const caip2 = typeof body.paymentRequirements?.network === 'string' ? body.paymentRequirements.network : '' - const network = NETWORK_BY_CAIP2[caip2] - const transactionXdr = body.paymentPayload?.payload?.transaction - - if (!network || typeof transactionXdr !== 'string' || transactionXdr.length === 0) { - // The body keeps the SettleResponse shape even at 400, so an unmodified - // canonical client can still read it instead of seeing a transport error. - return reply - .code(400) - .send( + app.post( + '/settle', + { + config: { + public: true, + rateLimit: { + max: 30, + timeWindow: '1 minute', + }, + }, + }, + async (req: FastifyRequest, reply: FastifyReply) => { + const body = (req.body ?? {}) as SettleRequestBody + + const caip2 = typeof body.paymentRequirements?.network === 'string' ? body.paymentRequirements.network : '' + const network = NETWORK_BY_CAIP2[caip2] + const transactionXdr = body.paymentPayload?.payload?.transaction + + if (!network || typeof transactionXdr !== 'string' || transactionXdr.length === 0) { + // The body keeps the SettleResponse shape even at 400, so an unmodified + // canonical client can still read it instead of seeing a transport error. + return reply + .code(400) + .send( + settleFailure( + '', + caip2, + SETTLE_ERROR_REASONS.malformed, + 'Request must carry paymentRequirements.network and paymentPayload.payload.transaction.', + ), + ) + } + + const txHash = deriveIdempotencyKey(transactionXdr, network) + if (!txHash) { + return reply + .code(400) + .send( + settleFailure('', caip2, SETTLE_ERROR_REASONS.malformed, 'paymentPayload.payload.transaction is not a transaction envelope.'), + ) + } + + // ── Spend ceiling checks (#147) ────────────────────────────────────────── + const spendCeiling = parseFloat(process.env.FACILITATOR_SPEND_CEILING ?? '0') + const dailyCeilingVar = network === 'mainnet' ? 'FACILITATOR_DAILY_CEILING_MAINNET' : 'FACILITATOR_DAILY_CEILING_TESTNET' + const dailyCeiling = parseFloat(process.env[dailyCeilingVar] ?? '0') + const ceilingConfigured = spendCeiling > 0 || dailyCeiling > 0 + + const authAmount = extractAuthoritativeAmount(transactionXdr, network) + + // Fail-closed when a ceiling is configured but the authoritative amount + // cannot be extracted from the transaction envelope. Refusing to settle an + // unverified amount is safe; falling back to payer-supplied numbers is not. + if (ceilingConfigured && authAmount === null) { + req.log.warn({ txHash, network }, '[facilitator] Cannot verify authoritative settlement amount from XDR with ceiling configured') + return reply.code(400).send( settleFailure( - '', + txHash, caip2, - SETTLE_ERROR_REASONS.malformed, - 'Request must carry paymentRequirements.network and paymentPayload.payload.transaction.', + 'unverifiable_settlement_amount', + 'Cannot verify authoritative settlement amount from transaction envelope while spend ceiling is configured.', ), ) - } + } + + const requestedAmount = + authAmount !== null + ? authAmount + : (typeof body.paymentRequirements?.amount === 'string' ? parseFloat(body.paymentRequirements.amount) : 0) - const txHash = deriveIdempotencyKey(transactionXdr, network) - if (!txHash) { - return reply - .code(400) - .send( - settleFailure('', caip2, SETTLE_ERROR_REASONS.malformed, 'paymentPayload.payload.transaction is not a transaction envelope.'), + if (spendCeiling > 0 && requestedAmount > spendCeiling) { + req.log.warn({ requestedAmount, spendCeiling, network }, '[facilitator] Settlement amount exceeds spend ceiling') + return reply.code(400).send( + settleFailure( + txHash, + caip2, + 'spend_ceiling_exceeded', + `Payment amount ${requestedAmount} exceeds facilitator spend ceiling of ${spendCeiling}.`, + ), ) - } + } + + // ── Daily ceiling per network (#147) ─────────────────────────────────── + if (dailyCeiling > 0) { + const startOfDay = new Date() + startOfDay.setUTCHours(0, 0, 0, 0) + let totalSpentToday = 0 + if (typeof (prisma.settlementAttempt as any).findMany === 'function') { + try { + const settledToday = await prisma.settlementAttempt.findMany({ + where: { + network, + state: 'settled', + createdAt: { gte: startOfDay }, + }, + select: { amount: true }, + }) + if (Array.isArray(settledToday)) { + totalSpentToday = settledToday.reduce((sum, row) => sum + parseFloat(row.amount ?? '0'), 0) + } + } catch {} + } + const dailyCount = await prisma.settlementAttempt.count({ + where: { + network, + state: 'settled', + createdAt: { gte: startOfDay }, + }, + }) + if (dailyCount >= dailyCeiling || (totalSpentToday > 0 && totalSpentToday + requestedAmount > dailyCeiling)) { + req.log.error({ dailyCount, dailyCeiling, network }, '[facilitator] Daily settlement limit reached for network') + return reply.code(429).send( + settleFailure( + txHash, + caip2, + 'daily_spend_ceiling_exceeded', + `Daily settlement limit of ${dailyCeiling} reached for network ${network}.`, + ), + ) + } + } + + // ── Caller allow-list check (#147) ────────────────────────────────────── + // Identity comes strictly from authenticated API key ID (req.apiKey.id or bearer token lookup). + // We deliberately refuse caller-supplied headers (origin, x-caller-id) and body fields (payTo) + // to ensure the allow-list cannot be bypassed by forging headers. + const allowlist = process.env.FACILITATOR_CALLER_ALLOWLIST + if (allowlist && allowlist.trim() !== '') { + const allowed = allowlist.split(',').map((s) => s.trim().toLowerCase()) + let callerKeyId = (req.apiKey?.id ?? '').toLowerCase() + if (!callerKeyId && req.headers['authorization']) { + const token = extractBearer(req.headers['authorization']) + if (token) { + try { + const lookedUp = await lookupApiKey(token) + if (lookedUp) { + callerKeyId = lookedUp.id.toLowerCase() + } + } catch {} + } + } + + const isAllowed = Boolean(callerKeyId && allowed.includes(callerKeyId)) + if (!isAllowed) { + req.log.warn({ callerKeyId, allowed }, '[facilitator] Caller API key ID not on allow-list') + return reply.code(403).send( + settleFailure( + txHash, + caip2, + 'caller_not_allowed', + 'Caller API key is not on the facilitator allow-list.', + ), + ) + } + } const facilitator = getFacilitator(network) if (!facilitator) { @@ -202,7 +375,7 @@ export async function registerSettleRoute(app: FastifyInstance) { state: 'submitting', payTo: typeof body.paymentRequirements?.payTo === 'string' ? body.paymentRequirements.payTo : null, asset: typeof body.paymentRequirements?.asset === 'string' ? body.paymentRequirements.asset : null, - amount: typeof body.paymentRequirements?.amount === 'string' ? body.paymentRequirements.amount : null, + amount: authAmount !== null ? String(authAmount) : (typeof body.paymentRequirements?.amount === 'string' ? body.paymentRequirements.amount : null), }, }) attemptId = attempt.id