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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
87 changes: 87 additions & 0 deletions clients/typescript/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, never>;
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
Expand Down Expand Up @@ -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"];
};
};
}
2 changes: 1 addition & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2845,7 +2845,7 @@
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
"description": "Invalid parameters"
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
Expand Down
2 changes: 1 addition & 1 deletion openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1921,7 +1921,7 @@ paths:
schema:
$ref: '#/components/schemas/LendingRatesResponse'
'400':
$ref: '#/components/responses/BadRequest'
description: Invalid parameters
'429':
$ref: '#/components/responses/TooManyRequests'

Expand Down
195 changes: 194 additions & 1 deletion src/__tests__/facilitatorSettle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -27,6 +28,7 @@ vi.mock('../db', () => ({
create: mockCreate,
findUnique: mockFindUnique,
update: mockUpdate,
count: mockCount,
},
},
}))
Expand All @@ -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<string, unknown>
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<string, unknown>
return {
Expand Down Expand Up @@ -116,6 +137,7 @@ beforeEach(() => {
})
mockGetFacilitator.mockReset().mockReturnValue({ settle: mockSettle })
mockGetTransaction.mockReset()
mockCount.mockReset().mockResolvedValue(0)
})

describe('POST /settle', () => {
Expand Down Expand Up @@ -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!
})
})
Loading
Loading