From 1725a6563551a363c290169ae3a99d8c75caaff5 Mon Sep 17 00:00:00 2001 From: Usman Abeeb <136495186+therealbibson@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:25:56 +0100 Subject: [PATCH 1/8] feat(x402): add settle spend ceiling and allow-list guards --- src/x402/settleGuards.ts | 184 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/x402/settleGuards.ts diff --git a/src/x402/settleGuards.ts b/src/x402/settleGuards.ts new file mode 100644 index 0000000..d720abd --- /dev/null +++ b/src/x402/settleGuards.ts @@ -0,0 +1,184 @@ +import type { FastifyRequest } from 'fastify' +import { 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 — the amount the facilitator + * will sponsor when `areFeesSponsored` is on. + */ +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). + */ +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 `feeStroops` 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. + * + * Per-network keys: exhausting testnet does not block mainnet. + * Fail closed: any store error refuses the settle. + */ +export async function reserveDailySpend( + network: NetworkName, + feeStroops: number, + ceilingStroops: number, + store: SpendStore = redis, +): Promise { + const key = dailySpendKey(network) + try { + const newTotal = await store.incrby(key, feeStroops) + // Survive process restart; TTL covers a UTC day boundary with margin. + await store.expire(key, 60 * 60 * 48) + + if (newTotal > ceilingStroops) { + await store.decrby(key, feeStroops) + return { + ok: false, + reason: 'daily_cap', + feeStroops, + errorMessage: `Daily facilitator spend ceiling reached for ${network} (${ceilingStroops} stroops).`, + } + } + return { ok: true } + } catch (err) { + return { + ok: false, + reason: 'store_unavailable', + feeStroops, + errorMessage: `Spend ledger unavailable; refusing settle (fail-closed): ${(err as Error).message}`, + } + } +} + +/** 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. + } +} From abc13bad9c415348b7ddaf14702bb80850db33c8 Mon Sep 17 00:00:00 2001 From: Usman Abeeb <136495186+therealbibson@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:25:59 +0100 Subject: [PATCH 2/8] feat(x402): enforce settle hardening controls on POST /settle --- src/routes/facilitator.ts | 76 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/src/routes/facilitator.ts b/src/routes/facilitator.ts index 7f8ecae..6fb2c39 100644 --- a/src/routes/facilitator.ts +++ b/src/routes/facilitator.ts @@ -4,6 +4,14 @@ 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, + releaseDailySpend, + reserveDailySpend, + type GuardRefusal, +} from '../x402/settleGuards' /** * `POST /settle` — the facilitator endpoint that actually submits a payment. @@ -28,8 +36,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 +164,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 +208,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 +266,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, @@ -226,6 +279,19 @@ export async function registerSettleRoute(app: FastifyInstance) { return resolveFromLedger(existing!.id, txHash, network, caip2) } + // Daily ceiling — only for new attempts. Fail closed on store outage. + const ceiling = getNetworkConfig(network).facilitator.dailySpendCeilingStroops + const reserved = await reserveDailySpend(network, feeStroops, ceiling) + if (!reserved.ok) { + req.log.warn( + { network, txHash, reason: reserved.reason, feeStroops, ceiling }, + '[facilitator] settle refused by daily spend ceiling', + ) + const response = declineFromGuard(txHash, caip2, reserved) + await finalise(attemptId, 'failed', response) + return reply.code(403).send(response) + } + try { const response = (await facilitator.settle(body.paymentPayload, body.paymentRequirements)) as SettleResponseShape const normalised: SettleResponseShape = { @@ -238,9 +304,13 @@ export async function registerSettleRoute(app: FastifyInstance) { 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, feeStroops) const response = settleFailure(txHash, caip2, SETTLE_ERROR_REASONS.unexpected, (err as Error).message) await finalise(attemptId, 'failed', response) return response } - }) + }, + ) } From bb49a3207c3a59630e74529d1b56bca77814f636 Mon Sep 17 00:00:00 2001 From: Usman Abeeb <136495186+therealbibson@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:26:03 +0100 Subject: [PATCH 3/8] feat(x402): add per-network daily spend ceiling config --- src/config.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 6fd3490..97117ee 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,16 @@ 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' + const facilitatorDailySpendCeilingStroops = parseInt( + process.env[`FACILITATOR_DAILY_SPEND_STROOPS_${suffix}`] || + process.env.FACILITATOR_DAILY_SPEND_STROOPS || + facilitatorDailyDefault, + 10 + ) + return { horizon: { url: horizonUrl }, rpc: { url: rpcUrl }, @@ -208,7 +224,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, + }, } } From f62883af6c287ebe73554c80f8387e770f0c3381 Mon Sep 17 00:00:00 2001 From: Usman Abeeb <136495186+therealbibson@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:26:07 +0100 Subject: [PATCH 4/8] docs: document facilitator hardening env vars --- .env.example | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.env.example b/.env.example index deb4849..236db05 100644 --- a/.env.example +++ b/.env.example @@ -166,4 +166,13 @@ 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 +# Optional comma-separated allow-list of resource-server Origins (or values of +# the x-facilitator-caller header). Empty = open (demo-compatible). +FACILITATOR_ALLOWED_ORIGINS= +# Per-IP rate limit for POST /settle (default 20/min; global unauth default is 100). +FACILITATOR_SETTLE_RATE_MAX=20 From 993d1235e7fee56305150098496c5c3a58949609 Mon Sep 17 00:00:00 2001 From: Usman Abeeb <136495186+therealbibson@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:26:10 +0100 Subject: [PATCH 5/8] test(x402): mock spend ledger for existing settle tests --- src/__tests__/facilitatorSettle.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/__tests__/facilitatorSettle.test.ts b/src/__tests__/facilitatorSettle.test.ts index 3d449fa..cbc41b1 100644 --- a/src/__tests__/facilitatorSettle.test.ts +++ b/src/__tests__/facilitatorSettle.test.ts @@ -30,6 +30,16 @@ vi.mock('../db', () => ({ }, })) +// 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 } From 21ddbd6b14dd7cd01c4f2abd5ab083b8f9ec7a3f Mon Sep 17 00:00:00 2001 From: Usman Abeeb <136495186+therealbibson@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:26:13 +0100 Subject: [PATCH 6/8] test(x402): cover spend ceiling, allow-list, settle rate limit --- src/__tests__/facilitatorHarden.test.ts | 299 ++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 src/__tests__/facilitatorHarden.test.ts diff --git a/src/__tests__/facilitatorHarden.test.ts b/src/__tests__/facilitatorHarden.test.ts new file mode 100644 index 0000000..9ae5257 --- /dev/null +++ b/src/__tests__/facilitatorHarden.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import Fastify from 'fastify' +import { + Account, + Asset, + Keypair, + Networks, + Operation, + TransactionBuilder, +} from '@stellar/stellar-sdk' + +const { + mockCreate, + mockFindUnique, + mockUpdate, + mockGetFacilitator, + mockSettle, + mockIncrby, + mockDecrby, + mockExpire, +} = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockFindUnique: vi.fn(), + mockUpdate: vi.fn(), + mockGetFacilitator: vi.fn(), + mockSettle: vi.fn(), + mockIncrby: vi.fn(), + mockDecrby: vi.fn(), + mockExpire: vi.fn(), +})) + +vi.mock('../db', () => ({ + prisma: { + settlementAttempt: { + create: mockCreate, + findUnique: mockFindUnique, + update: mockUpdate, + }, + }, +})) + +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 } +}) + +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: 'G' + 'A'.repeat(55), + maxTimeoutSeconds: 60, + }, + } +} + +async function buildApp() { + const app = Fastify({ logger: false }) + await registerSettleRoute(app) + await app.ready() + return app +} + +beforeEach(() => { + mockCreate.mockReset().mockResolvedValue({ id: 'attempt-1' }) + mockFindUnique.mockReset().mockResolvedValue(null) + mockUpdate.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) + 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() + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ state: 'failed' }) }), + ) + }) + + it('tracks daily spend under a per-network redis key', async () => { + const app = await buildApp() + await app.inject({ method: 'POST', url: '/settle', payload: settleBody() }) + + expect(mockIncrby).toHaveBeenCalledWith( + expect.stringContaining('lens:facilitator:daily-spend:testnet:'), + 100, + ) + }) + + 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('registers a /settle rate limit tighter than the global 100/min default', async () => { + const app = await buildApp() + expect(app.hasRoute({ method: 'POST', url: '/settle' })).toBe(true) + + // Pull the route config Fastify stored when registerSettleRoute ran. + const routes: Array<{ method: string | string[]; path?: string; url?: string; opts?: any; config?: any }> = + (app as any).routes ?? [] + const post = routes.find(r => { + const methods = Array.isArray(r.method) ? r.method : [r.method] + const path = r.path ?? r.url + return methods.includes('POST') && path === '/settle' + }) + const max = post?.opts?.config?.rateLimit?.max ?? post?.config?.rateLimit?.max + expect(max).toBeDefined() + expect(max!).toBeLessThan(100) + expect(max!).toBe(20) + }) +} +) From c38ab03ed74e47a413666c92d4983a6d63531bfa Mon Sep 17 00:00:00 2001 From: Usman Abeeb <136495186+therealbibson@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:18:16 +0100 Subject: [PATCH 7/8] fix(x402): validate spend ceiling, stop leaking store errors, test rate limit behaviourally --- src/__tests__/facilitatorHarden.test.ts | 31 ++++++++++++++----------- src/config.ts | 27 +++++++++++++++++---- src/x402/settleGuards.ts | 7 +++++- 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/__tests__/facilitatorHarden.test.ts b/src/__tests__/facilitatorHarden.test.ts index 9ae5257..bde85c1 100644 --- a/src/__tests__/facilitatorHarden.test.ts +++ b/src/__tests__/facilitatorHarden.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import Fastify from 'fastify' +import rateLimit from '@fastify/rate-limit' import { Account, Asset, @@ -97,6 +98,9 @@ function settleBody(transaction = LOW_FEE_ENVELOPE, network = 'stellar:testnet') 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 @@ -278,22 +282,23 @@ describe('POST /settle — hardening (#147)', () => { expect(second.json()).toEqual(stored) }) - it('registers a /settle rate limit tighter than the global 100/min default', async () => { + 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) - // Pull the route config Fastify stored when registerSettleRoute ran. - const routes: Array<{ method: string | string[]; path?: string; url?: string; opts?: any; config?: any }> = - (app as any).routes ?? [] - const post = routes.find(r => { - const methods = Array.isArray(r.method) ? r.method : [r.method] - const path = r.path ?? r.url - return methods.includes('POST') && path === '/settle' - }) - const max = post?.opts?.config?.rateLimit?.max ?? post?.config?.rateLimit?.max - expect(max).toBeDefined() - expect(max!).toBeLessThan(100) - expect(max!).toBe(20) + // 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/config.ts b/src/config.ts index 97117ee..39772f1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -201,12 +201,29 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { // 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' - const facilitatorDailySpendCeilingStroops = parseInt( + // 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 || - facilitatorDailyDefault, - 10 - ) + 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 }, diff --git a/src/x402/settleGuards.ts b/src/x402/settleGuards.ts index d720abd..57fd622 100644 --- a/src/x402/settleGuards.ts +++ b/src/x402/settleGuards.ts @@ -161,11 +161,16 @@ export async function reserveDailySpend( } 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, - errorMessage: `Spend ledger unavailable; refusing settle (fail-closed): ${(err as Error).message}`, + errorMessage: 'Spend ledger unavailable; refusing settle (fail-closed).', } } } From 78ccc75862586b80afb8161d5674a25eecbf93f5 Mon Sep 17 00:00:00 2001 From: Usman Abeeb <136495186+therealbibson@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:47:59 +0100 Subject: [PATCH 8/8] fix(x402): book the real settlement fee against the daily ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ceiling counted `tx.fee` from the caller-supplied envelope, which ExactStellarScheme.settle() discards when it rebuilds the transaction on the facilitator's own account. A caller declaring `fee: 100` was booked for 100 while the facilitator sponsored up to FACILITATOR_FEE_STROOPS (50000) — a 500x under-count that let the control be evaded by exactly the party it bounds. - the /settle route reserves getNetworkConfig(network).facilitator.feeStroops, the most the scheme can sponsor for one settlement, before submitting - reconcileDailySpend reads feeCharged from the settled transaction and releases the unused headroom; if the ledger is unreadable the full reservation stands, so the error is never in the unsafe direction - a failed `expire` now rolls the increment back instead of leaving a reservation with no TTL that would drift the ceiling down permanently - a guard decline withdraws the pre-submission attempt row rather than finalising it as a terminal `failed`, so a routine daily-cap refusal clears with the ceiling instead of bricking the payer's payload forever - .env.example documents the un-suffixed FACILITATOR_DAILY_SPEND_STROOPS fallback the config reads (main's drift guard failed on it) and states that the allow-list is a misconfiguration guard, not access control - tests: a valid payTo fixture, the worst-case reservation and its reconciliation, and the retryable daily-cap path --- .env.example | 6 +- src/__tests__/facilitatorHarden.test.ts | 51 +++++++++++++-- src/__tests__/facilitatorSettle.test.ts | 7 +- src/routes/facilitator.ts | 48 ++++++++++++-- src/x402/settleGuards.ts | 85 ++++++++++++++++++++++--- 5 files changed, 173 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index 236db05..cf6a381 100644 --- a/.env.example +++ b/.env.example @@ -170,8 +170,12 @@ FACILITATOR_FEE_STROOPS_MAINNET=50000 # 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). +# 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 index bde85c1..97f98e1 100644 --- a/src/__tests__/facilitatorHarden.test.ts +++ b/src/__tests__/facilitatorHarden.test.ts @@ -14,20 +14,24 @@ 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', () => ({ @@ -36,6 +40,7 @@ vi.mock('../db', () => ({ create: mockCreate, findUnique: mockFindUnique, update: mockUpdate, + delete: mockDelete, }, }, })) @@ -53,6 +58,15 @@ vi.mock('../x402/facilitator', async importOriginal => { 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, @@ -90,7 +104,7 @@ function settleBody(transaction = LOW_FEE_ENVELOPE, network = 'stellar:testnet') network, amount: '1000000', asset: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA', - payTo: 'G' + 'A'.repeat(55), + payTo: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', maxTimeoutSeconds: 60, }, } @@ -110,6 +124,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', @@ -120,6 +135,7 @@ beforeEach(() => { mockIncrby.mockReset().mockResolvedValue(100) mockDecrby.mockReset().mockResolvedValue(0) mockExpire.mockReset().mockResolvedValue(1) + mockReconcileDailySpend.mockReset().mockResolvedValue(undefined) delete process.env.FACILITATOR_ALLOWED_ORIGINS }) @@ -216,19 +232,42 @@ describe('POST /settle — hardening (#147)', () => { expect(res.json().errorReason).toBe(SETTLE_ERROR_REASONS.declined) expect(res.json().errorMessage).toMatch(/Daily facilitator spend ceiling/) expect(mockSettle).not.toHaveBeenCalled() - expect(mockUpdate).toHaveBeenCalledWith( - expect.objectContaining({ data: expect.objectContaining({ state: 'failed' }) }), - ) + // 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('tracks daily spend under a per-network redis key', async () => { + 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:'), - 100, + 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 () => { diff --git a/src/__tests__/facilitatorSettle.test.ts b/src/__tests__/facilitatorSettle.test.ts index cbc41b1..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,6 +27,7 @@ vi.mock('../db', () => ({ create: mockCreate, findUnique: mockFindUnique, update: mockUpdate, + delete: mockDelete, }, }, })) @@ -94,7 +96,7 @@ function settleBody( network: overrides.network ?? 'stellar:testnet', amount: '1000000', asset: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA', - payTo: 'G' + 'A'.repeat(55), + payTo: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', maxTimeoutSeconds: 60, }, } @@ -115,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/routes/facilitator.ts b/src/routes/facilitator.ts index 6fb2c39..b3ecc4e 100644 --- a/src/routes/facilitator.ts +++ b/src/routes/facilitator.ts @@ -8,6 +8,7 @@ import { assertCallerAllowed, assertFeeWithinCap, FACILITATOR_DECLINED, + reconcileDailySpend, releaseDailySpend, reserveDailySpend, type GuardRefusal, @@ -276,19 +277,51 @@ 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 reserved = await reserveDailySpend(network, feeStroops, ceiling) + 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, ceiling }, + { network, txHash, reason: reserved.reason, feeStroops, reservedStroops, ceiling }, '[facilitator] settle refused by daily spend ceiling', ) const response = declineFromGuard(txHash, caip2, reserved) - await finalise(attemptId, 'failed', response) + // 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) } @@ -301,12 +334,17 @@ 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, feeStroops) + 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 index 57fd622..756deb0 100644 --- a/src/x402/settleGuards.ts +++ b/src/x402/settleGuards.ts @@ -1,5 +1,5 @@ import type { FastifyRequest } from 'fastify' -import { TransactionBuilder } from '@stellar/stellar-sdk' +import { rpc, TransactionBuilder } from '@stellar/stellar-sdk' import { getNetworkConfig, type NetworkName } from '../config' import { redis } from '../redis' @@ -80,8 +80,14 @@ export function assertCallerAllowed(req: FastifyRequest): GuardRefusal | null { } /** - * Reads the fee (stroops) the envelope declares — the amount the facilitator - * will sponsor when `areFeesSponsored` is on. + * 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 { @@ -98,6 +104,12 @@ export function extractFeeStroops(transactionXdr: string, network: NetworkName): * 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) @@ -131,31 +143,48 @@ export function dailySpendKey(network: NetworkName, day = utcDayKey()): string { } /** - * Atomically reserves `feeStroops` against the network's rolling daily + * 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, - feeStroops: number, + amountStroops: number, ceilingStroops: number, store: SpendStore = redis, ): Promise { const key = dailySpendKey(network) try { - const newTotal = await store.incrby(key, feeStroops) + const newTotal = await store.incrby(key, amountStroops) // Survive process restart; TTL covers a UTC day boundary with margin. - await store.expire(key, 60 * 60 * 48) + 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, feeStroops) + await store.decrby(key, amountStroops) return { ok: false, reason: 'daily_cap', - feeStroops, + feeStroops: amountStroops, errorMessage: `Daily facilitator spend ceiling reached for ${network} (${ceilingStroops} stroops).`, } } @@ -169,7 +198,7 @@ export async function reserveDailySpend( return { ok: false, reason: 'store_unavailable', - feeStroops, + feeStroops: amountStroops, errorMessage: 'Spend ledger unavailable; refusing settle (fail-closed).', } } @@ -187,3 +216,39 @@ export async function releaseDailySpend( // 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. + } +}