diff --git a/.env.example b/.env.example index deb4849..cf6a381 100644 --- a/.env.example +++ b/.env.example @@ -166,4 +166,17 @@ FACILITATOR_SECRET_KEY= # Maximum fee in stroops the facilitator will pay (default 50000). FACILITATOR_FEE_STROOPS_TESTNET=50000 FACILITATOR_FEE_STROOPS_MAINNET=50000 +# Rolling UTC-day sponsored-fee ceilings in stroops (per network). Fail-closed +# when the spend ledger (Redis) is unreachable. +FACILITATOR_DAILY_SPEND_STROOPS_TESTNET=100000000 +FACILITATOR_DAILY_SPEND_STROOPS_MAINNET=10000000 +# Unsuffixed fallback used for both networks when the per-network value is unset. +FACILITATOR_DAILY_SPEND_STROOPS= +# Optional comma-separated allow-list of resource-server Origins (or values of +# the x-facilitator-caller header). Empty = open (demo-compatible). This is a +# misconfiguration guard, not access control: Origin and x-facilitator-caller +# are both caller-supplied and trivially spoofed by any non-browser client. +FACILITATOR_ALLOWED_ORIGINS= +# Per-IP rate limit for POST /settle (default 20/min; global unauth default is 100). +FACILITATOR_SETTLE_RATE_MAX=20 diff --git a/src/__tests__/facilitatorHarden.test.ts b/src/__tests__/facilitatorHarden.test.ts new file mode 100644 index 0000000..97f98e1 --- /dev/null +++ b/src/__tests__/facilitatorHarden.test.ts @@ -0,0 +1,343 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import Fastify from 'fastify' +import rateLimit from '@fastify/rate-limit' +import { + Account, + Asset, + Keypair, + Networks, + Operation, + TransactionBuilder, +} from '@stellar/stellar-sdk' + +const { + mockCreate, + mockFindUnique, + mockUpdate, + mockDelete, + mockGetFacilitator, + mockSettle, + mockIncrby, + mockDecrby, + mockExpire, + mockReconcileDailySpend, +} = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockFindUnique: vi.fn(), + mockUpdate: vi.fn(), + mockDelete: vi.fn(), + mockGetFacilitator: vi.fn(), + mockSettle: vi.fn(), + mockIncrby: vi.fn(), + mockDecrby: vi.fn(), + mockExpire: vi.fn(), + mockReconcileDailySpend: vi.fn(), +})) + +vi.mock('../db', () => ({ + prisma: { + settlementAttempt: { + create: mockCreate, + findUnique: mockFindUnique, + update: mockUpdate, + delete: mockDelete, + }, + }, +})) + +vi.mock('../redis', () => ({ + redis: { + incrby: mockIncrby, + decrby: mockDecrby, + expire: mockExpire, + }, +})) + +vi.mock('../x402/facilitator', async importOriginal => { + const actual = (await importOriginal()) as Record + return { ...actual, getFacilitator: mockGetFacilitator } +}) + +// Keep the ledger read out of the unit app: on a successful settle the route +// reconciles the worst-case reservation down to the fee actually charged by +// calling rpc.getTransaction, which would be a real network call here. Only +// `reconcileDailySpend` is replaced — the rest of the guard module stays real. +vi.mock('../x402/settleGuards', async importOriginal => { + const actual = (await importOriginal()) as Record + return { ...actual, reconcileDailySpend: mockReconcileDailySpend } +}) + +import { + assertCallerAllowed, + assertFeeWithinCap, + dailySpendKey, + FACILITATOR_DECLINED, + reserveDailySpend, +} from '../x402/settleGuards' +import { registerSettleRoute, SETTLE_ERROR_REASONS } from '../routes/facilitator' + +function buildEnvelope(passphrase: string, fee: string): string { + const keypair = Keypair.random() + const account = new Account(keypair.publicKey(), '1') + const tx = new TransactionBuilder(account, { fee, networkPassphrase: passphrase }) + .addOperation(Operation.payment({ destination: keypair.publicKey(), asset: Asset.native(), amount: '1' })) + .setTimeout(60) + .build() + tx.sign(keypair) + return tx.toXDR() +} + +const LOW_FEE_ENVELOPE = buildEnvelope(Networks.TESTNET, '100') +const HIGH_FEE_ENVELOPE = buildEnvelope(Networks.TESTNET, '999999') + +function settleBody(transaction = LOW_FEE_ENVELOPE, network = 'stellar:testnet') { + return { + x402Version: 2, + paymentPayload: { + x402Version: 2, + scheme: 'exact', + network, + payload: { transaction }, + }, + paymentRequirements: { + scheme: 'exact', + network, + amount: '1000000', + asset: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA', + payTo: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + maxTimeoutSeconds: 60, + }, + } +} + +async function buildApp() { + const app = Fastify({ logger: false }) + // The route-level `rateLimit` config only takes effect once the plugin is + // registered, so the test app has to register it too. + await app.register(rateLimit, { max: 100, timeWindow: '1 minute' }) + await registerSettleRoute(app) + await app.ready() + return app +} + +beforeEach(() => { + mockCreate.mockReset().mockResolvedValue({ id: 'attempt-1' }) + mockFindUnique.mockReset().mockResolvedValue(null) + mockUpdate.mockReset().mockResolvedValue({}) + mockDelete.mockReset().mockResolvedValue({}) + mockSettle.mockReset().mockResolvedValue({ + success: true, + transaction: 'onchain-hash', + network: 'stellar:testnet', + payer: 'GPAYER', + }) + mockGetFacilitator.mockReset().mockReturnValue({ settle: mockSettle }) + mockIncrby.mockReset().mockResolvedValue(100) + mockDecrby.mockReset().mockResolvedValue(0) + mockExpire.mockReset().mockResolvedValue(1) + mockReconcileDailySpend.mockReset().mockResolvedValue(undefined) + delete process.env.FACILITATOR_ALLOWED_ORIGINS +}) + +afterEach(() => { + delete process.env.FACILITATOR_ALLOWED_ORIGINS +}) + +describe('settle guards — unit', () => { + it('rejects a fee above the per-settlement cap before any store write', () => { + // Default FACILITATOR_FEE_STROOPS is 50000; 999999 must fail. + const result = assertFeeWithinCap(HIGH_FEE_ENVELOPE, 'testnet') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('fee_cap') + }) + + it('accepts a fee within the per-settlement cap', () => { + const result = assertFeeWithinCap(LOW_FEE_ENVELOPE, 'testnet') + expect(result).toEqual({ ok: true, feeStroops: 100 }) + }) + + it('allows any caller when the allow-list is empty', () => { + const req = { headers: {} } as any + expect(assertCallerAllowed(req)).toBeNull() + }) + + it('refuses a caller outside the allow-list', () => { + process.env.FACILITATOR_ALLOWED_ORIGINS = 'https://allowed.example' + const req = { headers: { origin: 'https://evil.example' } } as any + const block = assertCallerAllowed(req) + expect(block?.reason).toBe('caller_not_allowed') + }) + + it('allows a caller on the allow-list', () => { + process.env.FACILITATOR_ALLOWED_ORIGINS = 'https://allowed.example' + const req = { headers: { origin: 'https://allowed.example' } } as any + expect(assertCallerAllowed(req)).toBeNull() + }) + + it('reserves against the daily ceiling and rolls back when exceeded', async () => { + const store = { + incrby: vi.fn().mockResolvedValue(600), + decrby: vi.fn().mockResolvedValue(500), + expire: vi.fn().mockResolvedValue(1), + } + const refused = await reserveDailySpend('testnet', 100, 500, store) + expect(refused.ok).toBe(false) + if (!refused.ok) expect(refused.reason).toBe('daily_cap') + expect(store.decrby).toHaveBeenCalledWith(dailySpendKey('testnet'), 100) + }) + + it('scopes daily spend keys per network', () => { + expect(dailySpendKey('testnet')).not.toBe(dailySpendKey('mainnet')) + }) + + it('fails closed when the spend store is unavailable', async () => { + const store = { + incrby: vi.fn().mockRejectedValue(new Error('ECONNREFUSED')), + decrby: vi.fn(), + expire: vi.fn(), + } + const refused = await reserveDailySpend('testnet', 100, 1_000_000, store) + expect(refused.ok).toBe(false) + if (!refused.ok) expect(refused.reason).toBe('store_unavailable') + }) +}) + +describe('POST /settle — hardening (#147)', () => { + it('rejects an over-cap fee before creating an attempt or submitting', async () => { + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/settle', + payload: settleBody(HIGH_FEE_ENVELOPE), + }) + + expect(res.statusCode).toBe(403) + expect(res.json()).toMatchObject({ + success: false, + errorReason: FACILITATOR_DECLINED, + }) + expect(mockCreate).not.toHaveBeenCalled() + expect(mockSettle).not.toHaveBeenCalled() + expect(mockIncrby).not.toHaveBeenCalled() + }) + + it('refuses further settles once the daily cap is reached', async () => { + mockIncrby.mockResolvedValueOnce(100_000_001) // default testnet ceiling 1e8 + const app = await buildApp() + + const res = await app.inject({ method: 'POST', url: '/settle', payload: settleBody() }) + + expect(res.statusCode).toBe(403) + expect(res.json().errorReason).toBe(SETTLE_ERROR_REASONS.declined) + expect(res.json().errorMessage).toMatch(/Daily facilitator spend ceiling/) + expect(mockSettle).not.toHaveBeenCalled() + // The pre-submission row is withdrawn, not finalised as terminal `failed`: + // a stored decline would be replayed as this payload's final answer forever. + expect(mockDelete).toHaveBeenCalledWith({ where: { id: 'attempt-1' } }) + expect(mockUpdate).not.toHaveBeenCalled() + }) + + it('does not brick a payload the daily ceiling declined', async () => { + mockIncrby.mockResolvedValueOnce(100_000_001) // first attempt: cap reached + const app = await buildApp() + const body = settleBody() + + const first = await app.inject({ method: 'POST', url: '/settle', payload: body }) + expect(first.statusCode).toBe(403) + expect(mockSettle).not.toHaveBeenCalled() + + // Once the ceiling clears (next UTC day, or a reconciled reservation), the + // same payload must settle rather than replay the withdrawn decline. + const second = await app.inject({ method: 'POST', url: '/settle', payload: body }) + expect(second.statusCode).toBe(200) + expect(second.json().success).toBe(true) + expect(mockSettle).toHaveBeenCalledTimes(1) + }) + + it('books the worst-case per-settlement fee, not the caller-declared envelope fee', async () => { + const app = await buildApp() + await app.inject({ method: 'POST', url: '/settle', payload: settleBody() }) + + // Default FACILITATOR_FEE_STROOPS is 50000 while the envelope declares 100. + // The ledger must reserve 50000, or a caller declaring `fee: 100` could + // consume ~1/500th of its real exposure and evade the ceiling. + expect(mockIncrby).toHaveBeenCalledWith( + expect.stringContaining('lens:facilitator:daily-spend:testnet:'), + 50000, + ) + // …and the reservation is reconciled down to the fee the ledger charged. + expect(mockReconcileDailySpend).toHaveBeenCalledWith('testnet', 50000, 'onchain-hash') + }) + + it('does not share daily ceilings across networks', async () => { + // Exhausting testnet reservation must use the testnet key only. + const app = await buildApp() + await app.inject({ method: 'POST', url: '/settle', payload: settleBody() }) + const key = mockIncrby.mock.calls[0][0] as string + expect(key).toContain(':testnet:') + expect(key).not.toContain(':mainnet:') + }) + + it('refuses a caller outside the allow-list with a SettleResponse shape', async () => { + process.env.FACILITATOR_ALLOWED_ORIGINS = 'https://rs.example' + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/settle', + headers: { origin: 'https://other.example' }, + payload: settleBody(), + }) + + expect(res.statusCode).toBe(403) + expect(res.json()).toMatchObject({ success: false, errorReason: FACILITATOR_DECLINED }) + expect(mockCreate).not.toHaveBeenCalled() + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('is unchanged when the allow-list is empty', async () => { + 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('replays an identical settle without a second submission or second spend', async () => { + const app = await buildApp() + const body = settleBody() + + const first = await app.inject({ method: 'POST', url: '/settle', payload: body }) + expect(first.statusCode).toBe(200) + + const stored = first.json() + mockCreate.mockRejectedValueOnce(Object.assign(new Error('Unique constraint failed'), { code: 'P2002' })) + mockFindUnique.mockResolvedValue({ id: 'attempt-1', state: 'settled', response: stored }) + + const second = await app.inject({ method: 'POST', url: '/settle', payload: body }) + + expect(mockSettle).toHaveBeenCalledTimes(1) + expect(mockIncrby).toHaveBeenCalledTimes(1) + expect(second.json()).toEqual(stored) + }) + + it('rejects the 21st /settle request with 429 (route limit beats the 100/min default)', async () => { + const app = await buildApp() + expect(app.hasRoute({ method: 'POST', url: '/settle' })).toBe(true) + + // Assert the behaviour, not Fastify internals: `app.routes` is not a + // Fastify API, so the previous introspection read `undefined` and the + // assertion could only ever fail for the wrong reason. The route-level + // override is 20/min against the 100/min global default, so the 21st + // request is the first one that must be rejected. + const statuses: number[] = [] + for (let i = 0; i < 21; i += 1) { + const res = await app.inject({ method: 'POST', url: '/settle', payload: settleBody() }) + statuses.push(res.statusCode) + } + + expect(statuses.filter(code => code === 429)).toHaveLength(1) + expect(statuses[20]).toBe(429) + }) +} +) diff --git a/src/__tests__/facilitatorSettle.test.ts b/src/__tests__/facilitatorSettle.test.ts index 3d449fa..c642889 100644 --- a/src/__tests__/facilitatorSettle.test.ts +++ b/src/__tests__/facilitatorSettle.test.ts @@ -9,11 +9,12 @@ import { TransactionBuilder, } from '@stellar/stellar-sdk' -const { mockCreate, mockFindUnique, mockUpdate, mockGetFacilitator, mockSettle, mockGetTransaction } = vi.hoisted( +const { mockCreate, mockFindUnique, mockUpdate, mockDelete, mockGetFacilitator, mockSettle, mockGetTransaction } = vi.hoisted( () => ({ mockCreate: vi.fn(), mockFindUnique: vi.fn(), mockUpdate: vi.fn(), + mockDelete: vi.fn(), mockGetFacilitator: vi.fn(), mockSettle: vi.fn(), mockGetTransaction: vi.fn(), @@ -26,10 +27,21 @@ vi.mock('../db', () => ({ create: mockCreate, findUnique: mockFindUnique, update: mockUpdate, + delete: mockDelete, }, }, })) +// Spend-ceiling ledger (#147). Generous in-memory totals so existing settle +// tests keep exercising settlement / idempotency rather than the daily cap. +vi.mock('../redis', () => ({ + redis: { + incrby: vi.fn().mockResolvedValue(100), + decrby: vi.fn().mockResolvedValue(0), + expire: vi.fn().mockResolvedValue(1), + }, +})) + vi.mock('../x402/facilitator', async importOriginal => { const actual = (await importOriginal()) as Record return { ...actual, getFacilitator: mockGetFacilitator } @@ -84,7 +96,7 @@ function settleBody( network: overrides.network ?? 'stellar:testnet', amount: '1000000', asset: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA', - payTo: 'G' + 'A'.repeat(55), + payTo: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', maxTimeoutSeconds: 60, }, } @@ -105,6 +117,7 @@ beforeEach(() => { mockCreate.mockReset().mockResolvedValue({ id: 'attempt-1' }) mockFindUnique.mockReset().mockResolvedValue(null) mockUpdate.mockReset().mockResolvedValue({}) + mockDelete.mockReset().mockResolvedValue({}) mockSettle.mockReset().mockResolvedValue({ success: true, transaction: 'onchain-hash', diff --git a/src/config.ts b/src/config.ts index 6fd3490..39772f1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,8 +44,14 @@ export interface NetworkConfig { facilitator: { /** Secret key for the facilitator's fee-paying account */ secretKey?: string - /** Maximum fee in stroops the facilitator will pay */ + /** Maximum fee in stroops the facilitator will pay per settlement (#147) */ feeStroops: number + /** + * Rolling UTC-day spend ceiling (stroops) for sponsored fees on this + * network. Independent of the per-settlement cap. Tracked in Redis and + * enforced fail-closed (#147). + */ + dailySpendCeilingStroops: number } } @@ -192,6 +198,33 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { 10 ) + // Per-network daily ceilings: mainnet defaults tighter than testnet so a + // drained test faucet cannot be confused with mainnet exposure (#147). + const facilitatorDailyDefault = network === 'mainnet' ? '10000000' : '100000000' + // A safety control must not be able to silently disable itself. + // `parseInt` returns NaN for a typo'd value and `newTotal > NaN` is always + // false, so the cap would be off with nothing in the logs — and it parses + // "1e8" as 1, which would brick settlement. Require a positive safe + // integer, otherwise fall back to the default and say so loudly. + const spendCeilingRaw = + process.env[`FACILITATOR_DAILY_SPEND_STROOPS_${suffix}`] || + process.env.FACILITATOR_DAILY_SPEND_STROOPS + const parsedSpendCeiling = Number(spendCeilingRaw) + const spendCeilingValid = + spendCeilingRaw !== undefined && + Number.isSafeInteger(parsedSpendCeiling) && + parsedSpendCeiling > 0 + if (spendCeilingRaw !== undefined && !spendCeilingValid) { + console.warn( + `[config] FACILITATOR_DAILY_SPEND_STROOPS_${suffix} is not a positive safe integer ` + + `(${JSON.stringify(spendCeilingRaw)}); falling back to the default ` + + `${facilitatorDailyDefault} stroops.`, + ) + } + const facilitatorDailySpendCeilingStroops = spendCeilingValid + ? parsedSpendCeiling + : Number(facilitatorDailyDefault) + return { horizon: { url: horizonUrl }, rpc: { url: rpcUrl }, @@ -208,7 +241,11 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { }, oracle: { enabled: oracleEnabled, reflectorContractId }, pairs: parseWatchedPairs(rawPairs), - facilitator: { secretKey: facilitatorSecretKey, feeStroops: facilitatorFeeStroops }, + facilitator: { + secretKey: facilitatorSecretKey, + feeStroops: facilitatorFeeStroops, + dailySpendCeilingStroops: facilitatorDailySpendCeilingStroops, + }, } } diff --git a/src/routes/facilitator.ts b/src/routes/facilitator.ts index 7f8ecae..b3ecc4e 100644 --- a/src/routes/facilitator.ts +++ b/src/routes/facilitator.ts @@ -4,6 +4,15 @@ import { rpc } from '@stellar/stellar-sdk' import { prisma } from '../db' import { getNetworkConfig, type NetworkName } from '../config' import { CAIP2_BY_NETWORK, getFacilitator, type SettleResponseShape } from '../x402/facilitator' +import { + assertCallerAllowed, + assertFeeWithinCap, + FACILITATOR_DECLINED, + reconcileDailySpend, + releaseDailySpend, + reserveDailySpend, + type GuardRefusal, +} from '../x402/settleGuards' /** * `POST /settle` — the facilitator endpoint that actually submits a payment. @@ -28,8 +37,20 @@ export const SETTLE_ERROR_REASONS = { malformed: 'invalid_exact_stellar_payload_malformed', transactionFailed: 'settle_exact_stellar_transaction_failed', unexpected: 'unexpected_settle_error', + /** Spec-shaped decline when a hardening control refuses to settle (#147). */ + declined: FACILITATOR_DECLINED, } as const +/** Tighter than the global unauthenticated IP default (100/min). */ +function settleRateLimitMax(): number { + const parsed = parseInt(process.env.FACILITATOR_SETTLE_RATE_MAX ?? '20', 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 20 +} + +function declineFromGuard(txHash: string, caip2: string, refusal: GuardRefusal): SettleResponseShape { + return settleFailure(txHash, caip2, SETTLE_ERROR_REASONS.declined, refusal.errorMessage) +} + type AttemptState = 'submitting' | 'settled' | 'failed' interface SettleRequestBody { @@ -144,7 +165,20 @@ 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) => { + app.post( + '/settle', + { + config: { + public: true, + // Bound frequency separately from the spend ceiling — rate limits are + // not a substitute for a balance cap (#147). + rateLimit: { + max: settleRateLimitMax(), + timeWindow: '1 minute', + }, + }, + }, + async (req: FastifyRequest, reply: FastifyReply) => { const body = (req.body ?? {}) as SettleRequestBody const caip2 = typeof body.paymentRequirements?.network === 'string' ? body.paymentRequirements.network : '' @@ -175,6 +209,26 @@ export async function registerSettleRoute(app: FastifyInstance) { ) } + // ── Hardening (#147): allow-list + per-settlement fee cap BEFORE submit ── + const callerBlock = assertCallerAllowed(req) + if (callerBlock) { + req.log.warn( + { network, txHash, reason: callerBlock.reason, caller: req.headers.origin ?? req.headers['x-facilitator-caller'] }, + '[facilitator] settle refused by caller allow-list', + ) + return reply.code(403).send(declineFromGuard(txHash, caip2, callerBlock)) + } + + const feeCheck = assertFeeWithinCap(transactionXdr, network) + if (!feeCheck.ok) { + req.log.warn( + { network, txHash, reason: feeCheck.reason, feeStroops: feeCheck.feeStroops }, + '[facilitator] settle refused by per-settlement fee cap', + ) + return reply.code(403).send(declineFromGuard(txHash, caip2, feeCheck)) + } + const feeStroops = feeCheck.feeStroops + const facilitator = getFacilitator(network) if (!facilitator) { return settleFailure( @@ -213,7 +267,7 @@ export async function registerSettleRoute(app: FastifyInstance) { }) // A payload is consumed the moment its record exists. A second settle - // replays the stored answer rather than paying twice. + // replays the stored answer rather than paying twice — no second fee spend. if (existing?.state === 'settled' || existing?.state === 'failed') { return (existing.response as unknown as SettleResponseShape) ?? settleFailure( txHash, @@ -223,7 +277,52 @@ export async function registerSettleRoute(app: FastifyInstance) { ) } - return resolveFromLedger(existing!.id, txHash, network, caip2) + if (!existing) { + // The row was withdrawn between the unique-constraint rejection and this + // read: a concurrent attempt was refused by a guard before submitting + // and cleaned its row up. Nothing reached the ledger, so the honest + // answer is "retry" rather than throwing on a non-null assertion. + return settleFailure( + txHash, + caip2, + SETTLE_ERROR_REASONS.unexpected, + 'A concurrent attempt for this payload was withdrawn before submission; please retry.', + ) + } + + return resolveFromLedger(existing.id, txHash, network, caip2) + } + + // Daily ceiling — only for new attempts. Fail closed on store outage. + // + // Book the *worst case* the scheme can sponsor, not `feeStroops`. The + // envelope's `fee` is caller-supplied and the scheme discards it, rebuilding + // the transaction on the facilitator's own account and paying + // `minResourceFee + BASE_FEE` (bounded by `facilitator.feeStroops`, i.e. + // `maxTransactionFeeStroops`). Booking the declared fee would let a caller + // set `fee: 100` and consume ~1/500th of its real exposure from the ledger. + // The reservation is reconciled down to `feeCharged` after a successful + // settle, and released in full if settlement aborts before submission. + const ceiling = getNetworkConfig(network).facilitator.dailySpendCeilingStroops + const reservedStroops = getNetworkConfig(network).facilitator.feeStroops + const reserved = await reserveDailySpend(network, reservedStroops, ceiling) + if (!reserved.ok) { + req.log.warn( + { network, txHash, reason: reserved.reason, feeStroops, reservedStroops, ceiling }, + '[facilitator] settle refused by daily spend ceiling', + ) + const response = declineFromGuard(txHash, caip2, reserved) + // Withdraw the pre-submission row instead of finalising it as a terminal + // `failed`. A daily-cap refusal is a routine, self-healing condition — it + // clears at the next UTC midnight — but the idempotency path above replays + // a stored `failed` as the payload's final answer, so finalising here + // would brick the payer's payload permanently and hand back this 403 for + // every later attempt. Nothing was submitted (the reservation is taken + // before `facilitator.settle`), so dropping the row — and the implicit + // "payload consumed" lock that comes with it — makes the payload + // retryable rather than poisoning it. + await prisma.settlementAttempt.delete({ where: { id: attemptId } }).catch(() => undefined) + return reply.code(403).send(response) } try { @@ -235,12 +334,21 @@ export async function registerSettleRoute(app: FastifyInstance) { ...(response.success ? {} : { errorReason: response.errorReason ?? SETTLE_ERROR_REASONS.unexpected }), } + // Release the unused headroom down to what the ledger actually charged. + if (normalised.success) { + await reconcileDailySpend(network, reservedStroops, normalised.transaction) + } + await finalise(attemptId, normalised.success ? 'settled' : 'failed', normalised) return normalised } catch (err) { + // Submission never landed — release the daily reservation so a later + // legitimate settle is not charged for an aborted attempt. + await releaseDailySpend(network, reservedStroops) const response = settleFailure(txHash, caip2, SETTLE_ERROR_REASONS.unexpected, (err as Error).message) await finalise(attemptId, 'failed', response) return response } - }) + }, + ) } diff --git a/src/x402/settleGuards.ts b/src/x402/settleGuards.ts new file mode 100644 index 0000000..756deb0 --- /dev/null +++ b/src/x402/settleGuards.ts @@ -0,0 +1,254 @@ +import type { FastifyRequest } from 'fastify' +import { rpc, TransactionBuilder } from '@stellar/stellar-sdk' +import { getNetworkConfig, type NetworkName } from '../config' +import { redis } from '../redis' + +/** + * Hardening controls for POST /settle (#147): per-settlement fee ceiling, + * rolling daily spend ceiling (per network), and an optional caller allow-list. + * + * Fail closed on spend-store outages — a silent Redis failure must not unlock + * unlimited sponsored settling. + */ + +export const FACILITATOR_DECLINED = 'facilitator_declined' as const + +export type GuardRefusalReason = + | 'fee_cap' + | 'daily_cap' + | 'caller_not_allowed' + | 'store_unavailable' + | 'fee_unreadable' + +export interface GuardRefusal { + ok: false + reason: GuardRefusalReason + errorMessage: string + feeStroops?: number +} + +export interface FeeOk { + ok: true + feeStroops: number +} + +/** Minimal Redis surface so unit tests can substitute an in-memory store. */ +export interface SpendStore { + incrby(key: string, n: number): Promise + decrby(key: string, n: number): Promise + expire(key: string, seconds: number): Promise +} + +/** + * Optional allow-list of resource-server origins / caller ids. + * Empty (default) preserves open settle behaviour for the demo path. + */ +export function getAllowedCallers(): string[] { + const raw = process.env.FACILITATOR_ALLOWED_ORIGINS ?? '' + return raw + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0) +} + +/** + * Resolves the caller identity from Origin or x-facilitator-caller. + * Allow-list match is exact string equality against the configured entries. + */ +export function resolveCallerIdentity(req: FastifyRequest): string { + const origin = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : '' + if (origin) return origin + const caller = req.headers['x-facilitator-caller'] + if (typeof caller === 'string' && caller.trim()) return caller.trim() + return '' +} + +export function assertCallerAllowed(req: FastifyRequest): GuardRefusal | null { + const allowed = getAllowedCallers() + if (allowed.length === 0) return null + + const identity = resolveCallerIdentity(req) + if (identity && allowed.includes(identity)) return null + + return { + ok: false, + reason: 'caller_not_allowed', + errorMessage: identity + ? `Caller "${identity}" is not on FACILITATOR_ALLOWED_ORIGINS.` + : 'Caller identity required when FACILITATOR_ALLOWED_ORIGINS is set (Origin or x-facilitator-caller).', + } +} + +/** + * Reads the fee (stroops) the envelope declares. This is **not** the amount the + * facilitator sponsors: `ExactStellarScheme.settle()` discards the envelope and + * rebuilds the transaction on the facilitator's own account, paying + * `minResourceFee + BASE_FEE` from its own simulation. The declared fee is + * caller-supplied and never reaches a ledger, so it must not be used to account + * for spend (see {@link reserveDailySpend}). It is still worth reading here + * because a payload declaring a fee above the per-settlement cap is malformed + * and can be refused cheaply, before the scheme simulates it. + */ +export function extractFeeStroops(transactionXdr: string, network: NetworkName): number | null { + try { + const passphrase = getNetworkConfig(network).network.passphrase + const tx = TransactionBuilder.fromXDR(transactionXdr, passphrase) + const fee = parseInt(tx.fee, 10) + return Number.isFinite(fee) && fee >= 0 ? fee : null + } catch { + return null + } +} + +/** + * Per-settlement fee ceiling — rejects before any ledger submission or daily + * reservation. Independent of the rate limiter (which bounds frequency, not + * balance). + * + * This compares the *caller-declared* envelope fee, which the scheme discards; + * the authoritative per-settlement bound is `maxTransactionFeeStroops`, which + * `ExactStellarScheme.verify()` enforces against its own simulation. The check + * here is therefore only a cheap malformed-payload guard, not the spend control + * — the daily ledger books the worst case instead (see {@link reserveDailySpend}). + */ +export function assertFeeWithinCap(transactionXdr: string, network: NetworkName): FeeOk | GuardRefusal { + const feeStroops = extractFeeStroops(transactionXdr, network) + if (feeStroops === null) { + return { + ok: false, + reason: 'fee_unreadable', + errorMessage: 'Could not read transaction fee from payment payload.', + } + } + + const perSettlementCap = getNetworkConfig(network).facilitator.feeStroops + if (feeStroops > perSettlementCap) { + return { + ok: false, + reason: 'fee_cap', + feeStroops, + errorMessage: `Settlement fee ${feeStroops} stroops exceeds per-settlement cap ${perSettlementCap} for ${network}.`, + } + } + + return { ok: true, feeStroops } +} + +function utcDayKey(d = new Date()): string { + return d.toISOString().slice(0, 10) +} + +export function dailySpendKey(network: NetworkName, day = utcDayKey()): string { + return `lens:facilitator:daily-spend:${network}:${day}` +} + +/** + * Atomically reserves `amountStroops` against the network's rolling daily + * ceiling. Increments first, then rolls back if the new total exceeds the + * ceiling — so concurrent settles cannot race past the cap. + * + * `amountStroops` must be the *worst case* the facilitator can sponsor for one + * settlement (`getNetworkConfig(network).facilitator.feeStroops`), never the + * caller-declared envelope fee: the scheme rebuilds the transaction and pays + * `minResourceFee + BASE_FEE`, so a caller can declare `fee: 100` while the + * facilitator sponsors 50000. Booking the declared number under-counts by up to + * the ratio of the two caps and lets the control be evaded by the party it + * bounds. The reservation is reconciled down to the fee actually charged after + * a successful settle (see {@link reconcileDailySpend}). + * + * Per-network keys: exhausting testnet does not block mainnet. + * Fail closed: any store error refuses the settle. + */ +export async function reserveDailySpend( + network: NetworkName, + amountStroops: number, + ceilingStroops: number, + store: SpendStore = redis, +): Promise { + const key = dailySpendKey(network) + try { + const newTotal = await store.incrby(key, amountStroops) + // Survive process restart; TTL covers a UTC day boundary with margin. + try { + await store.expire(key, 60 * 60 * 48) + } catch (expireErr) { + // The increment landed but the TTL did not, so this reservation would + // never expire and the ceiling would drift down permanently. Undo it + // before failing closed. + await store.decrby(key, amountStroops).catch(() => undefined) + throw expireErr + } + + if (newTotal > ceilingStroops) { + await store.decrby(key, amountStroops) + return { + ok: false, + reason: 'daily_cap', + feeStroops: amountStroops, + errorMessage: `Daily facilitator spend ceiling reached for ${network} (${ceilingStroops} stroops).`, + } + } + return { ok: true } + } catch (err) { + // Fail closed, but never echo the driver error to the caller: /settle is a + // public route and an ioredis failure reads + // "connect ECONNREFUSED :", which is internal topology. The + // detail stays in the server log. + console.error('[settleGuards] spend ledger unavailable; refusing settle', err) + return { + ok: false, + reason: 'store_unavailable', + feeStroops: amountStroops, + errorMessage: 'Spend ledger unavailable; refusing settle (fail-closed).', + } + } +} + +/** Releases a reservation when settlement aborts before submission. */ +export async function releaseDailySpend( + network: NetworkName, + feeStroops: number, + store: SpendStore = redis, +): Promise { + try { + await store.decrby(dailySpendKey(network), feeStroops) + } catch { + // Best-effort rollback; the key expires in 48h regardless. + } +} + +/** + * Best-effort downward reconciliation of a worst-case daily reservation. + * + * {@link reserveDailySpend} books the full per-settlement ceiling, so after a + * successful settle we read the fee the ledger actually charged (`feeCharged` + * from the transaction result) and release the unused headroom. The ledger's + * number is authoritative; the reservation is only a conservative pre-charge. + * + * If the transaction cannot be read, or is not yet SUCCESS, the full + * reservation stands — the ceiling over-counts rather than under-counts, which + * is the safe direction. Best-effort by design: reconciliation must never turn + * a successful settle into a failure. + */ +export async function reconcileDailySpend( + network: NetworkName, + reservedStroops: number, + txHash: string, + store: SpendStore = redis, +): Promise { + try { + const server = new rpc.Server(getNetworkConfig(network).rpc.url) + const tx = await server.getTransaction(txHash) + if (tx.status !== 'SUCCESS') return + + // `resultXdr` is the TransactionResult; its `feeCharged` is an Int64 of the + // stroops the ledger actually deducted. + const feeCharged = Number(tx.resultXdr.feeCharged.toString()) + if (!Number.isSafeInteger(feeCharged) || feeCharged < 0) return + + const unused = reservedStroops - feeCharged + if (unused > 0) await releaseDailySpend(network, unused, store) + } catch { + // Keep the conservative reservation when the ledger is unreadable. + } +}