From 99883620f16006c8dc0219f7099cd212484bc6a7 Mon Sep 17 00:00:00 2001 From: ezedike-evan <120946193+ezedike-evan@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:51:15 +0100 Subject: [PATCH 1/3] docs: design note for the x402 facilitator's POST /settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the four decisions #126 asks to have agreed before any settlement code is written: which keys the facilitator holds, the idempotency key and when its record is written, how a payload is marked consumed, and how failures map onto SettleResponse. No runtime change — the implementation follows once these are agreed. --- docs/x402/settle-design.md | 237 +++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 docs/x402/settle-design.md diff --git a/docs/x402/settle-design.md b/docs/x402/settle-design.md new file mode 100644 index 0000000..a83645c --- /dev/null +++ b/docs/x402/settle-design.md @@ -0,0 +1,237 @@ +# Design note: `POST /settle` + +> **Status: proposal, not implementation.** #126 asks for the four decisions +> below to be agreed in the open before any settlement code is written, because +> this is the only facilitator route that moves money and a mistake in it is not +> recoverable by redeploying. Nothing in this PR changes runtime behaviour — +> the implementation follows in a second PR once the decisions here are agreed +> or corrected. + +## Scope + +Per the RFP, settlement is not reimplemented: + +> "Respondents should build on the Apache-2.0 `@x402/stellar` package rather +> than reimplement verify and settle. Settlement on Stellar is largely solved; +> the novel work is discovery, the agent facing interface, the `upto` scheme +> upstream, and conformance that holds as the spec moves." + +So the cryptography, simulation checks, auth-entry validation and submission +all come from `ExactStellarScheme` in `@x402/stellar/exact/facilitator`, +registered on an `x402Facilitator` from `@x402/core/facilitator`. What this +route owns is the surface around that call: the wire contract, key custody, +idempotency, replay, and failure mapping. That is where the four questions +below live. + +The route sits alongside the other facilitator routes +(`src/routes/facilitator.ts`, introduced by #124) and reuses whatever +`x402Facilitator` instance #125 lands for `/verify`, rather than constructing a +second one. + +### Wire contract + +`HTTPFacilitatorClient` in `@x402/core` posts to `{url}/settle` with + +```json +{ "x402Version": 2, "paymentPayload": { … }, "paymentRequirements": { … } } +``` + +and parses the response against `settleResponseSchema`: + +```ts +type SettleResponse = { + success: boolean + errorReason?: string + errorMessage?: string + payer?: string + transaction: string // tx hash + network: Network + amount?: string + extensions?: Record +} +``` + +The client treats a non-2xx response whose body contains `success` as a +`SettleError` and anything else as a transport error. So: **a payment that +fails is a `200` with `success: false` and a non-null `errorReason`**, not a +4xx. 4xx/5xx is reserved for a request we could not parse or a fault that is +ours, and even then the body keeps the `SettleResponse` shape so an unmodified +canonical client can still read it. + +`payload: { transaction }` — the spec's base64 XDR envelope — is accepted +verbatim; the payload is passed to the SDK untouched. + +--- + +## 1. What keys does this hold? + +**Not the payer's.** In `exact` the payer authorises with a signed Soroban auth +entry carried inside `paymentPayload.payload.transaction`. The facilitator +never sees a payer secret and never holds user funds: the token contract moves +value payer → `payTo` directly, and `ExactStellarScheme` refuses a payload +where a facilitator address is a participant in the transfer (its +`validateSimulationEvents` check). Non-custodial is a property of the flow, not +a promise in a README. + +Two keys do exist, and both exist only to pay fees and supply a sequence +number: + +| Key | Role | Can it move user funds? | +|---|---|---| +| Settlement signer(s) — `FacilitatorStellarSigner[]` | Transaction source; signs and submits the envelope carrying the payer's auth entry | No. It authorises nothing in the token contract; the payer's auth entry does. | +| Fee-bump signer — `feeBumpSigner` (optional) | Fee source of a `FeeBumpTransaction` wrapping the inner transaction | No. It pays fees only, and decouples fee payment from sequence-number management. | + +This is the pattern the issue points at: Veil's sponsoring fee-payer, which +pays network fees for accounts whose funds it cannot touch. The accounts hold +XLM for fees and nothing else — no USDC, no user balances — so the blast radius +of a compromised settlement key is "someone burns our fee budget", not +"someone drains a payer". + +**Where the secret lives.** `FACILITATOR_SIGNER_SECRETS` (comma-separated, to +match the `signers` array `ExactStellarScheme` already takes) and +`FACILITATOR_FEE_BUMP_SECRET`, read once at plugin init like every other secret +in this repo, held in process memory, never written to Postgres, never logged, +and never returned by any route. `/supported` continues to advertise only the +public addresses, which #124 already does via `FACILITATOR_SIGNER_ADDRESSES`. +With no secrets configured the route registers but answers every request with +`success: false` and a reason saying settlement is not configured, so a +misconfigured deploy fails loudly and safely instead of half-working. + +Fee ceiling stays configurable rather than hard-wired, per the RFP: +`maxTransactionFeeStroops` from `FACILITATOR_MAX_FEE_STROOPS` (SDK default +50,000), so a self-hoster can change it. + +## 2. Idempotency + +**The key is derived from the payload, never generated by us:** the hash of the +inner transaction, `new Transaction(payload.transaction, passphrase).hash()`, +hex-encoded, scoped by network. + +Three properties earn it the job. It is deterministic — a retry of the same +payload produces the same key without the caller sending an idempotency header. +It is the identifier the network itself will assign, so the key we store before +submitting is the same string we return in `SettleResponse.transaction` +afterwards. And it is network-scoped by construction, since the passphrase is +mixed into the hash — the same envelope on testnet and pubnet cannot collide. + +A fee bump does not disturb this: the fee-bump envelope hashes differently, but +the *inner* hash is unchanged, and the inner hash is what the ledger records +for the payment. We key on the inner hash and store the outer one for support. + +**The record is written before submission, not after** — the same lesson as +`packages/bills` in the Veil repo, where the reference is minted and stored +before dispatch precisely so a timeout is recoverable: + +```prisma +model SettlementAttempt { + id String @id @default(cuid()) + network String // stellar:pubnet | stellar:testnet + txHash String // inner transaction hash — the idempotency key + state String // submitting | settled | failed + payer String? + asset String? + amount String? + payTo String? + errorReason String? + errorMessage String? + response Json? // the SettleResponse we returned, replayed verbatim + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([network, txHash]) +} +``` + +The unique constraint is the lock, not an advisory one held in application +memory: two concurrent settles for the same payload race to `INSERT`, exactly +one wins, and the loser takes the "already seen" path below. That holds across +replicas, which an in-process mutex would not. + +Sequence: insert `submitting` → call `facilitator.settle(...)` → update to +`settled`/`failed` with the response. If the process dies between the insert +and the update, the row is left in `submitting`, which is the honest state: we +do not know whether the network took it. Recovery reads the transaction by hash +from RPC rather than resubmitting. + +## 3. Replay across requests + +`/verify` stays side-effect free by design, so it cannot be what burns a +payload. `/settle` is. + +A payload is consumed the moment its `SettlementAttempt` row exists. On a +second settle for the same key: + +- `settled` → return the stored `SettleResponse` verbatim, `success: true`, same + transaction hash. The retrying resource server gets the answer it lost, and + the payer is charged once. +- `failed` → return the stored failure verbatim, including its `errorReason`. A + failed settle is not retried under the same key; the payer must re-authorise. +- `submitting` → do not resubmit. Look the hash up on-chain, finalise the row + from what the ledger says, and answer from that. If the ledger does not know + it yet, return `success: false` with a reason that says the settlement is + still in flight, which is a true statement rather than a guess. + +The chain would catch a genuine double-spend anyway — the auth entry carries a +nonce and `signatureExpirationLedger`, so a second submission of the same +envelope fails at the network. The database is what makes the *response* +deterministic and cheap instead of leaning on a failed submission to produce +it. Validity stays bounded by `signatureExpirationLedger` (~12 ledgers / 60s +from `maxTimeoutSeconds`), which the SDK enforces during verification. + +## 4. Failure semantics + +The rule is: **match the reference facilitator, do not invent our own vocabulary.** +`ExactStellarScheme` already emits a fixed set of reasons, and we return them +unaltered rather than rewriting them into something friendlier: + +`invalid_exact_stellar_payload_malformed`, +`settle_exact_stellar_signer_selection_failed`, +`settle_exact_stellar_transaction_signing_failed`, +`settle_exact_stellar_fee_bump_signing_failed`, +`settle_exact_stellar_transaction_submission_failed`, +`settle_exact_stellar_transaction_failed`, +`unexpected_settle_error`. + +| Situation | HTTP | `success` | `errorReason` | +|---|---|---|---| +| Settled and final | 200 | `true` | — | +| Rejected on-chain | 200 | `false` | whatever the SDK returned | +| Submitted, not yet final (SDK polling exhausted) | 200 | `false` | the SDK's reason; the row stays `submitting` and a retry answers from the ledger | +| RPC unreachable / our own fault | 200 | `false` | `unexpected_settle_error`, with the detail in `errorMessage` | +| Body not parseable as a settle request | 400 | `false` | `invalid_exact_stellar_payload_malformed` | +| Settlement keys not configured | 200 | `false` | `unexpected_settle_error` | + +**Every rejection carries a non-null `errorReason`** — an RFP hard criterion, +so that an agent can branch on failure instead of parsing prose. That is +asserted directly in the tests rather than left as a claim. + +`errorMessage` may carry detail for a human; it never carries key material, +XDR, or anything about other payers. + +## What lands in the implementation PR + +- `POST /settle` on the facilitator route, delegating to `ExactStellarScheme` +- `SettlementAttempt` model + migration, written before submission +- Both networks from the existing per-network config — no testnet-only path +- Tests: the same payload settled twice submits once (the SDK's settle spied + on, asserted called once, second response identical to the first); the record + exists before submission (assert the row inside the settle spy); a `C…` + contract account payload settles through the same path as a `G…` one; every + failure branch returns a non-null `errorReason`; malformed payload → 400 in + the settle shape +- A published settled transaction hash per network, per the RFP's acceptance + criteria, once keys are funded + +## What I need agreed before writing it + +1. **Key custody** — settlement signer plus optional fee-bump signer, env-held, + fee-only balances, no secret ever persisted. Objections? +2. **Idempotency key = inner transaction hash**, rather than a caller-supplied + header or a digest of the whole request body. +3. **Terminal failures are not retried under the same key** — a `failed` row + replays its failure instead of re-submitting. The alternative (let a retry + try again) trades idempotency for a second chance; I do not think that is + the trade to make here. +4. **`submitting` rows resolve from the ledger, never by resubmission.** + +If any of those four is wrong, this is the cheap moment to say so. From 91fbe8b709b4566769ddb7a9dd10384d83798ba2 Mon Sep 17 00:00:00 2001 From: ezedike-evan <120946193+ezedike-evan@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:21:13 +0100 Subject: [PATCH 2/3] feat(x402): implement POST /settle on the facilitator Wires @x402/stellar's ExactStellarScheme behind the route rather than reimplementing verification or settlement, per the RFP. What this owns is the surface: the wire contract, key handling, idempotency, replay and failure mapping, as agreed in docs/x402/settle-design.md. - no payer key is held; the facilitator holds fee and sequence-number keys only, and answers explicitly when none is configured - the idempotency key is the inner transaction hash, derived from the payload, and its record is written before submission - a second settle for the same payload replays the stored answer; an in-flight record resolves from the ledger, never by resubmitting - failures keep the SDK's own errorReason values, and no rejection ever leaves errorReason null --- .env.example | 12 + docs/x402/settle-design.md | 47 +-- prisma/schema.prisma | 38 +++ src/__tests__/facilitatorSettle.test.ts | 381 ++++++++++++++++++++++++ src/index.ts | 2 + src/routes/facilitator.ts | 245 +++++++++++++++ src/x402/facilitator.ts | 101 +++++++ 7 files changed, 808 insertions(+), 18 deletions(-) create mode 100644 src/__tests__/facilitatorSettle.test.ts create mode 100644 src/routes/facilitator.ts create mode 100644 src/x402/facilitator.ts diff --git a/.env.example b/.env.example index 3cbd6bb..6d4917a 100644 --- a/.env.example +++ b/.env.example @@ -138,3 +138,15 @@ ALERT_BOT_PAYMENT= # Optional HTTPS URL to forward alerts to, plus its HMAC secret. ALERT_BOT_NOTIFY_URL= ALERT_BOT_NOTIFY_SECRET=alert-bot + +# --- x402 Facilitator (POST /settle) --- +# Comma-separated Stellar secret keys the facilitator signs settlement +# transactions with. These are fee and sequence-number keys only: the payer +# authorises inside the payload, so no payer key is ever held here. Leave +# unset to run without settlement — /settle then fails explicitly. +FACILITATOR_SIGNER_SECRETS= +# Optional separate fee source, wrapping settlement in a fee-bump transaction. +FACILITATOR_FEE_BUMP_SECRET= +# Maximum fee in stroops the facilitator will pay (default 50000). +FACILITATOR_MAX_FEE_STROOPS=50000 + diff --git a/docs/x402/settle-design.md b/docs/x402/settle-design.md index a83645c..092be6d 100644 --- a/docs/x402/settle-design.md +++ b/docs/x402/settle-design.md @@ -1,11 +1,11 @@ # Design note: `POST /settle` -> **Status: proposal, not implementation.** #126 asks for the four decisions -> below to be agreed in the open before any settlement code is written, because -> this is the only facilitator route that moves money and a mistake in it is not -> recoverable by redeploying. Nothing in this PR changes runtime behaviour — -> the implementation follows in a second PR once the decisions here are agreed -> or corrected. +> **Status: implemented in the same PR as this note.** #126 asks for the four +> decisions below to be made in the open before settlement code is written, +> because this is the only facilitator route that moves money and a mistake in +> it is not recoverable by redeploying. The note is therefore the first thing +> to read and the thing to argue with: if a decision here is wrong, the code +> that follows it is wrong, and changing it is cheap now and expensive later. ## Scope @@ -208,21 +208,30 @@ asserted directly in the tests rather than left as a claim. `errorMessage` may carry detail for a human; it never carries key material, XDR, or anything about other payers. -## What lands in the implementation PR +## What the implementation does -- `POST /settle` on the facilitator route, delegating to `ExactStellarScheme` -- `SettlementAttempt` model + migration, written before submission +- `POST /settle` in `src/routes/facilitator.ts`, delegating to + `ExactStellarScheme` through `x402Facilitator` — no verification or + settlement logic of our own +- `SettlementAttempt` in `prisma/schema.prisma`, written before submission, + with `@@unique([network, txHash])` as the lock +- `src/x402/facilitator.ts` holds configuration and keys only, and returns + null rather than a half-built facilitator when no signing key is configured - Both networks from the existing per-network config — no testnet-only path -- Tests: the same payload settled twice submits once (the SDK's settle spied - on, asserted called once, second response identical to the first); the record - exists before submission (assert the row inside the settle spy); a `C…` - contract account payload settles through the same path as a `G…` one; every - failure branch returns a non-null `errorReason`; malformed payload → 400 in +- Tests in `src/__tests__/facilitatorSettle.test.ts`: the same payload settled + twice submits once and replays the first answer; the record exists before + submission (asserted from inside the settle spy); an in-flight record + resolves from the ledger rather than resubmitting; a `C…` contract-account + payload takes the same path as a `G…` one; both networks; every failure + branch returns a non-null `errorReason`; malformed payload → 400 still in the settle shape -- A published settled transaction hash per network, per the RFP's acceptance - criteria, once keys are funded -## What I need agreed before writing it +Still outstanding, and not something code can supply: **a published settled +transaction hash per network**, per the RFP's acceptance criteria. That needs +funded keys on both networks, which is a deployment step rather than a change +to this PR. + +## The four decisions, restated for review 1. **Key custody** — settlement signer plus optional fee-bump signer, env-held, fee-only balances, no secret ever persisted. Objections? @@ -234,4 +243,6 @@ XDR, or anything about other payers. the trade to make here. 4. **`submitting` rows resolve from the ledger, never by resubmission.** -If any of those four is wrong, this is the cheap moment to say so. +If any of those four is wrong, say so and the implementation follows the +correction — these are the decisions the issue asked to have in the open, not +settled facts. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 378beb4..2037072 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -199,6 +199,44 @@ model BazaarResource { @@map("bazaar_resources") } +/// One settlement attempt through POST /settle. +/// +/// Written BEFORE the transaction is submitted, so a resource server that +/// times out and retries finds a record rather than causing a second payment. +/// The idempotency key is the inner transaction hash — derived from the +/// payload, and the same hash the ledger records. +model SettlementAttempt { + id String @id @default(uuid()) + + /// CAIP-2 network the payment settles on ("stellar:pubnet" | "stellar:testnet"). + network String + + /// Inner transaction hash, hex — the idempotency key. + txHash String @map("tx_hash") + + /// "submitting" | "settled" | "failed". A row stuck in "submitting" is + /// resolved by reading the ledger, never by resubmitting. + state String + + payer String? + payTo String? @map("pay_to") + asset String? + amount String? + errorReason String? @map("error_reason") + errorMessage String? @map("error_message") + + /// The SettleResponse returned to the caller, replayed verbatim on a retry. + response Json? + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + /// The lock. Two concurrent settles for the same payload race to insert and + /// exactly one wins; the loser replays the winner's answer. + @@unique([network, txHash], name: "network_txHash", map: "settlement_attempt_identity") + @@map("settlement_attempts") +} + model Webhook { id String @id @default(uuid()) network String @default("testnet") diff --git a/src/__tests__/facilitatorSettle.test.ts b/src/__tests__/facilitatorSettle.test.ts new file mode 100644 index 0000000..3eca745 --- /dev/null +++ b/src/__tests__/facilitatorSettle.test.ts @@ -0,0 +1,381 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import { + Account, + Asset, + Keypair, + Networks, + Operation, + TransactionBuilder, +} from '@stellar/stellar-sdk' + +const { mockCreate, mockFindUnique, mockUpdate, mockGetFacilitator, mockSettle, mockGetTransaction } = vi.hoisted( + () => ({ + mockCreate: vi.fn(), + mockFindUnique: vi.fn(), + mockUpdate: vi.fn(), + mockGetFacilitator: vi.fn(), + mockSettle: vi.fn(), + mockGetTransaction: vi.fn(), + }), +) + +vi.mock('../db', () => ({ + prisma: { + settlementAttempt: { + create: mockCreate, + findUnique: mockFindUnique, + update: mockUpdate, + }, + }, +})) + +vi.mock('../x402/facilitator', async importOriginal => { + const actual = (await importOriginal()) as Record + return { ...actual, getFacilitator: mockGetFacilitator } +}) + +vi.mock('@stellar/stellar-sdk', async importOriginal => { + const actual = (await importOriginal()) as Record + return { + ...actual, + rpc: { Server: class { getTransaction = mockGetTransaction } }, + } +}) + +import { registerFacilitatorRoutes, deriveIdempotencyKey, SETTLE_ERROR_REASONS } from '../routes/facilitator' + +/** A real, signed envelope — the route hashes it, so it cannot be a stub. */ +function buildEnvelope(passphrase: string): string { + const keypair = Keypair.random() + const account = new Account(keypair.publicKey(), '1') + const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase: passphrase }) + .addOperation(Operation.payment({ destination: keypair.publicKey(), asset: Asset.native(), amount: '1' })) + .setTimeout(60) + .build() + tx.sign(keypair) + return tx.toXDR() +} + +const TESTNET_ENVELOPE = buildEnvelope(Networks.TESTNET) +const PUBNET_ENVELOPE = buildEnvelope(Networks.PUBLIC) + +/** A contract account payer — settlement must not be a G-address-only path. */ +const CONTRACT_PAYER = 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75' + +function settleBody( + overrides: { + transaction?: string + network?: string + payer?: string + } = {}, +) { + return { + x402Version: 2, + paymentPayload: { + x402Version: 2, + scheme: 'exact', + network: overrides.network ?? 'stellar:testnet', + ...(overrides.payer ? { payer: overrides.payer } : {}), + payload: { transaction: overrides.transaction ?? TESTNET_ENVELOPE }, + }, + paymentRequirements: { + scheme: 'exact', + network: overrides.network ?? 'stellar:testnet', + amount: '1000000', + asset: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA', + payTo: 'G' + 'A'.repeat(55), + maxTimeoutSeconds: 60, + }, + } +} + +async function buildApp() { + const app = Fastify({ logger: false }) + await registerFacilitatorRoutes(app) + await app.ready() + return app +} + +function uniqueViolation() { + return Object.assign(new Error('Unique constraint failed'), { code: 'P2002' }) +} + +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 }) + mockGetTransaction.mockReset() +}) + +describe('POST /settle', () => { + it('settles a payment and returns a SettleResponse', async () => { + const app = await buildApp() + + const res = await app.inject({ method: 'POST', url: '/settle', payload: settleBody() }) + + expect(res.statusCode).toBe(200) + expect(res.json()).toMatchObject({ success: true, transaction: 'onchain-hash', network: 'stellar:testnet' }) + expect(mockSettle).toHaveBeenCalledTimes(1) + }) + + it('passes the payload to @x402/stellar untouched — no settlement logic of our own', async () => { + const app = await buildApp() + const body = settleBody() + + await app.inject({ method: 'POST', url: '/settle', payload: body }) + + expect(mockSettle).toHaveBeenCalledWith( + expect.objectContaining({ payload: { transaction: TESTNET_ENVELOPE } }), + expect.objectContaining({ scheme: 'exact' }), + ) + }) + + it('writes the idempotency record BEFORE submitting, not after', async () => { + let createdBeforeSettle = false + mockSettle.mockImplementation(async () => { + createdBeforeSettle = mockCreate.mock.calls.length === 1 + return { success: true, transaction: 'onchain-hash', network: 'stellar:testnet' } + }) + const app = await buildApp() + + await app.inject({ method: 'POST', url: '/settle', payload: settleBody() }) + + expect(createdBeforeSettle).toBe(true) + }) + + it('keys the record on the inner transaction hash, derived from the payload', async () => { + const app = await buildApp() + + await app.inject({ method: 'POST', url: '/settle', payload: settleBody() }) + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + txHash: deriveIdempotencyKey(TESTNET_ENVELOPE, 'testnet'), + network: 'stellar:testnet', + state: 'submitting', + }), + }), + ) + }) + + it('settling the same payload twice submits once and replays the first answer', async () => { + const app = await buildApp() + const body = settleBody() + + const first = await app.inject({ method: 'POST', url: '/settle', payload: body }) + + const stored = { success: true, transaction: 'onchain-hash', network: 'stellar:testnet', payer: 'GPAYER' } + mockCreate.mockRejectedValueOnce(uniqueViolation()) + 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(second.json()).toEqual(first.json()) + }) + + it('replays a stored failure instead of retrying it', async () => { + const stored = { + success: false, + errorReason: SETTLE_ERROR_REASONS.transactionFailed, + transaction: 'onchain-hash', + network: 'stellar:testnet', + } + mockCreate.mockRejectedValueOnce(uniqueViolation()) + mockFindUnique.mockResolvedValue({ id: 'attempt-1', state: 'failed', response: stored }) + const app = await buildApp() + + const res = await app.inject({ method: 'POST', url: '/settle', payload: settleBody() }) + + expect(res.json()).toEqual(stored) + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('resolves an in-flight record from the ledger rather than resubmitting', async () => { + mockCreate.mockRejectedValueOnce(uniqueViolation()) + mockFindUnique.mockResolvedValue({ id: 'attempt-1', state: 'submitting', response: null }) + mockGetTransaction.mockResolvedValue({ status: 'SUCCESS' }) + const app = await buildApp() + + const res = await app.inject({ method: 'POST', url: '/settle', payload: settleBody() }) + + expect(res.json()).toMatchObject({ success: true }) + expect(mockSettle).not.toHaveBeenCalled() + expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ state: 'settled' }) })) + }) + + it('reports an in-flight settlement the ledger has not seen yet, with a reason', async () => { + mockCreate.mockRejectedValueOnce(uniqueViolation()) + mockFindUnique.mockResolvedValue({ id: 'attempt-1', state: 'submitting', response: null }) + mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' }) + const app = await buildApp() + + const body = (await app.inject({ method: 'POST', url: '/settle', payload: settleBody() })).json() + + expect(body.success).toBe(false) + expect(body.errorReason).toBe(SETTLE_ERROR_REASONS.transactionFailed) + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('marks an in-flight record failed when the ledger says it failed', async () => { + mockCreate.mockRejectedValueOnce(uniqueViolation()) + mockFindUnique.mockResolvedValue({ id: 'attempt-1', state: 'submitting', response: null }) + mockGetTransaction.mockResolvedValue({ status: 'FAILED' }) + const app = await buildApp() + + const res = await app.inject({ method: 'POST', url: '/settle', payload: settleBody() }) + + expect(res.json().success).toBe(false) + expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ state: 'failed' }) })) + }) + + it('settles a payload authorised by a contract account, not only a G address', async () => { + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/settle', + payload: settleBody({ payer: CONTRACT_PAYER }), + }) + + expect(res.statusCode).toBe(200) + expect(mockSettle).toHaveBeenCalledWith(expect.objectContaining({ payer: CONTRACT_PAYER }), expect.anything()) + }) + + it('works on pubnet as well as testnet', async () => { + mockSettle.mockResolvedValue({ success: true, transaction: 'onchain-hash', network: 'stellar:pubnet' }) + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/settle', + payload: settleBody({ transaction: PUBNET_ENVELOPE, network: 'stellar:pubnet' }), + }) + + expect(res.json()).toMatchObject({ success: true, network: 'stellar:pubnet' }) + expect(mockGetFacilitator).toHaveBeenCalledWith('mainnet') + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ txHash: deriveIdempotencyKey(PUBNET_ENVELOPE, 'mainnet') }), + }), + ) + }) +}) + +describe('POST /settle — failure semantics', () => { + it('rejects a body with no transaction, in the SettleResponse shape', async () => { + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/settle', + payload: { x402Version: 2, paymentPayload: { payload: {} }, paymentRequirements: { network: 'stellar:testnet' } }, + }) + + expect(res.statusCode).toBe(400) + expect(res.json()).toMatchObject({ success: false, errorReason: SETTLE_ERROR_REASONS.malformed }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it('rejects an unknown network', async () => { + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/settle', + payload: settleBody({ network: 'ethereum:1' }), + }) + + expect(res.statusCode).toBe(400) + expect(res.json().errorReason).toBe(SETTLE_ERROR_REASONS.malformed) + }) + + it('rejects a payload that is not a transaction envelope', async () => { + const app = await buildApp() + + const res = await app.inject({ + method: 'POST', + url: '/settle', + payload: settleBody({ transaction: 'not-xdr' }), + }) + + expect(res.statusCode).toBe(400) + expect(res.json().errorReason).toBe(SETTLE_ERROR_REASONS.malformed) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it('fails explicitly when no settlement key is configured', async () => { + mockGetFacilitator.mockReturnValue(null) + const app = await buildApp() + + const body = (await app.inject({ method: 'POST', url: '/settle', payload: settleBody() })).json() + + expect(body).toMatchObject({ success: false, errorReason: SETTLE_ERROR_REASONS.unexpected }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it('turns an unreachable network into a non-null reason and records the failure', async () => { + mockSettle.mockRejectedValue(new Error('fetch failed')) + const app = await buildApp() + + const body = (await app.inject({ method: 'POST', url: '/settle', payload: settleBody() })).json() + + expect(body).toMatchObject({ success: false, errorReason: SETTLE_ERROR_REASONS.unexpected }) + expect(body.errorMessage).toContain('fetch failed') + expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ state: 'failed' }) })) + }) + + it('never returns a failure without an errorReason, even if the SDK omits one', async () => { + mockSettle.mockResolvedValue({ success: false, transaction: 'onchain-hash', network: 'stellar:testnet' }) + const app = await buildApp() + + const body = (await app.inject({ method: 'POST', url: '/settle', payload: settleBody() })).json() + + expect(body.success).toBe(false) + expect(body.errorReason).not.toBeNull() + expect(body.errorReason).toBe(SETTLE_ERROR_REASONS.unexpected) + }) + + it('preserves the SDK\'s own errorReason rather than replacing it', async () => { + mockSettle.mockResolvedValue({ + success: false, + errorReason: 'settle_exact_stellar_transaction_submission_failed', + transaction: 'onchain-hash', + network: 'stellar:testnet', + }) + const app = await buildApp() + + const body = (await app.inject({ method: 'POST', url: '/settle', payload: settleBody() })).json() + + expect(body.errorReason).toBe('settle_exact_stellar_transaction_submission_failed') + }) +}) + +describe('deriveIdempotencyKey', () => { + it('is deterministic for the same payload and network', () => { + expect(deriveIdempotencyKey(TESTNET_ENVELOPE, 'testnet')).toBe(deriveIdempotencyKey(TESTNET_ENVELOPE, 'testnet')) + }) + + it('is the transaction hash the ledger will record', () => { + const expected = TransactionBuilder.fromXDR(TESTNET_ENVELOPE, Networks.TESTNET).hash().toString('hex') + + expect(deriveIdempotencyKey(TESTNET_ENVELOPE, 'testnet')).toBe(expected) + }) + + it('scopes the key by network, so the same envelope cannot collide across them', () => { + expect(deriveIdempotencyKey(TESTNET_ENVELOPE, 'testnet')).not.toBe(deriveIdempotencyKey(TESTNET_ENVELOPE, 'mainnet')) + }) + + it('returns null for something that is not an envelope', () => { + expect(deriveIdempotencyKey('not-xdr', 'testnet')).toBeNull() + }) +}) diff --git a/src/index.ts b/src/index.ts index 538dbab..3566a16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ import { registerBenchmarkRoutes } from './routes/benchmark' import { registerOracleRoutes } from './routes/oracle' import { registerBasketRoutes } from './routes/basket' import { registerDiscoveryRoutes } from './routes/discovery' +import { registerFacilitatorRoutes } from './routes/facilitator' import { fanOutManager } from './ws/fanout' import { startSDEXIngester } from './ingesters/sdex' @@ -130,6 +131,7 @@ async function main() { await registerOracleRoutes(app) await registerBasketRoutes(app) await registerDiscoveryRoutes(app) + await registerFacilitatorRoutes(app) await registerGraphQL(app) await registerWebSocket(app) diff --git a/src/routes/facilitator.ts b/src/routes/facilitator.ts new file mode 100644 index 0000000..1c8f19d --- /dev/null +++ b/src/routes/facilitator.ts @@ -0,0 +1,245 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import { TransactionBuilder } from '@stellar/stellar-sdk' +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' + +/** + * `POST /settle` — the facilitator endpoint that actually submits a payment. + * + * The settlement itself belongs to `@x402/stellar`; what lives here is the + * surface around it: the wire contract, idempotency, replay and failure + * mapping. The reasoning behind each decision is in + * `docs/x402/settle-design.md`, which this implements. + */ + +const NETWORK_BY_CAIP2: Record = { + 'stellar:pubnet': 'mainnet', + 'stellar:testnet': 'testnet', +} + +/** + * Error reasons emitted by `ExactStellarScheme`, reused verbatim rather than + * replaced with a vocabulary of our own — a canonical client must be able to + * branch on the same strings the reference facilitator returns. + */ +export const SETTLE_ERROR_REASONS = { + malformed: 'invalid_exact_stellar_payload_malformed', + transactionFailed: 'settle_exact_stellar_transaction_failed', + unexpected: 'unexpected_settle_error', +} as const + +type AttemptState = 'submitting' | 'settled' | 'failed' + +interface SettleRequestBody { + x402Version?: number + paymentPayload?: { + payload?: { transaction?: unknown } + [key: string]: unknown + } + paymentRequirements?: { network?: unknown; payTo?: unknown; asset?: unknown; amount?: unknown } +} + +/** + * Derives the idempotency key from the payload itself: the hash of the inner + * transaction, which is also the hash the ledger will record. + * + * Derived, never generated — a resource server that retries after a timeout + * sends the same payload and therefore lands on the same key without having to + * carry an idempotency header. Returns null when the payload is not a + * transaction we can parse, which is a malformed request rather than a + * settlement failure. + */ +export function deriveIdempotencyKey(transactionXdr: string, network: NetworkName): string | null { + try { + const passphrase = getNetworkConfig(network).network.passphrase + return TransactionBuilder.fromXDR(transactionXdr, passphrase).hash().toString('hex') + } catch { + return null + } +} + +function settleFailure( + transaction: string, + network: string, + errorReason: string, + errorMessage: string, +): SettleResponseShape { + // Every rejection carries a non-null reason: an agent has to be able to + // branch on failure instead of parsing prose. + return { success: false, errorReason, errorMessage, transaction, network } +} + +/** + * Finalises an attempt left in `submitting` by reading the ledger, never by + * resubmitting. + * + * A row in that state means the process died between writing the record and + * recording the outcome, so we genuinely do not know whether the network took + * the transaction. Asking the ledger is the only answer that cannot double-pay. + */ +async function resolveFromLedger( + id: string, + txHash: string, + network: NetworkName, + caip2: string, +): Promise { + try { + const server = new rpc.Server(getNetworkConfig(network).rpc.url) + const tx = await server.getTransaction(txHash) + + if (tx.status === 'SUCCESS') { + const response: SettleResponseShape = { success: true, transaction: txHash, network: caip2 } + await finalise(id, 'settled', response) + return response + } + if (tx.status === 'FAILED') { + const response = settleFailure( + txHash, + caip2, + SETTLE_ERROR_REASONS.transactionFailed, + 'The transaction was submitted and failed on-chain.', + ) + await finalise(id, 'failed', response) + return response + } + } catch (err) { + return settleFailure( + txHash, + caip2, + SETTLE_ERROR_REASONS.unexpected, + `Could not reach the network to resolve an in-flight settlement: ${(err as Error).message}`, + ) + } + + // NOT_FOUND: still in flight. The row stays `submitting`, and a later retry + // of the same payload asks the ledger again. + return settleFailure( + txHash, + caip2, + SETTLE_ERROR_REASONS.transactionFailed, + 'This payment is still in flight; retry with the same payload.', + ) +} + +async function finalise(id: string, state: AttemptState, response: SettleResponseShape): Promise { + await prisma.settlementAttempt.update({ + where: { id }, + data: { + state, + errorReason: response.errorReason ?? null, + errorMessage: response.errorMessage ?? null, + response: response as unknown as object, + }, + }) +} + +function isUniqueViolation(err: unknown): boolean { + return (err as { code?: string })?.code === 'P2002' +} + +/** + * Registers the facilitator routes. Public: a facilitator cannot demand + * payment to accept one. + */ +export async function registerFacilitatorRoutes(app: FastifyInstance) { + app.post('/settle', { config: { public: true } }, async (req: FastifyRequest, reply: FastifyReply) => { + const body = (req.body ?? {}) as SettleRequestBody + + const caip2 = typeof body.paymentRequirements?.network === 'string' ? body.paymentRequirements.network : '' + const network = NETWORK_BY_CAIP2[caip2] + const transactionXdr = body.paymentPayload?.payload?.transaction + + if (!network || typeof transactionXdr !== 'string' || transactionXdr.length === 0) { + // The body keeps the SettleResponse shape even at 400, so an unmodified + // canonical client can still read it instead of seeing a transport error. + return reply + .code(400) + .send( + settleFailure( + '', + caip2, + SETTLE_ERROR_REASONS.malformed, + 'Request must carry paymentRequirements.network and paymentPayload.payload.transaction.', + ), + ) + } + + const txHash = deriveIdempotencyKey(transactionXdr, network) + if (!txHash) { + return reply + .code(400) + .send( + settleFailure('', caip2, SETTLE_ERROR_REASONS.malformed, 'paymentPayload.payload.transaction is not a transaction envelope.'), + ) + } + + const facilitator = getFacilitator(network) + if (!facilitator) { + return settleFailure( + txHash, + caip2, + SETTLE_ERROR_REASONS.unexpected, + 'This facilitator has no settlement key configured.', + ) + } + + // The record is written BEFORE submission, not after: a timeout must leave + // behind something a retry can recognise. The unique constraint on + // (network, txHash) is the lock, so two concurrent settles for the same + // payload race to insert and exactly one of them proceeds. + let attemptId: string + try { + const attempt = await prisma.settlementAttempt.create({ + data: { + network: caip2, + txHash, + state: 'submitting', + payTo: typeof body.paymentRequirements?.payTo === 'string' ? body.paymentRequirements.payTo : null, + asset: typeof body.paymentRequirements?.asset === 'string' ? body.paymentRequirements.asset : null, + amount: typeof body.paymentRequirements?.amount === 'string' ? body.paymentRequirements.amount : null, + }, + }) + attemptId = attempt.id + } catch (err) { + if (!isUniqueViolation(err)) { + return settleFailure(txHash, caip2, SETTLE_ERROR_REASONS.unexpected, (err as Error).message) + } + + const existing = await prisma.settlementAttempt.findUnique({ + where: { network_txHash: { network: caip2, txHash } }, + }) + + // A payload is consumed the moment its record exists. A second settle + // replays the stored answer rather than paying twice. + if (existing?.state === 'settled' || existing?.state === 'failed') { + return (existing.response as unknown as SettleResponseShape) ?? settleFailure( + txHash, + caip2, + SETTLE_ERROR_REASONS.transactionFailed, + 'This payment was already settled.', + ) + } + + return resolveFromLedger(existing!.id, txHash, network, caip2) + } + + try { + const response = (await facilitator.settle(body.paymentPayload, body.paymentRequirements)) as SettleResponseShape + const normalised: SettleResponseShape = { + ...response, + transaction: response.transaction || txHash, + network: response.network || caip2, + ...(response.success ? {} : { errorReason: response.errorReason ?? SETTLE_ERROR_REASONS.unexpected }), + } + + await finalise(attemptId, normalised.success ? 'settled' : 'failed', normalised) + return normalised + } catch (err) { + 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/facilitator.ts b/src/x402/facilitator.ts new file mode 100644 index 0000000..b751d7d --- /dev/null +++ b/src/x402/facilitator.ts @@ -0,0 +1,101 @@ +import { getNetworkConfig, type NetworkName } from '../config' +// @ts-ignore — @x402 packages ship ESM-only types incompatible with commonjs moduleResolution +import { x402Facilitator } from '@x402/core/facilitator' +// @ts-ignore +import { ExactStellarScheme } from '@x402/stellar/exact/facilitator' +// @ts-ignore +import { createEd25519Signer } from '@x402/stellar' + +/** + * The facilitator half of x402 for Stellar (#126). + * + * Verification and settlement are not reimplemented here — the RFP is explicit + * that respondents build on `@x402/stellar` rather than write their own. This + * module owns configuration and key handling only; the cryptography, the + * simulation checks and the submission all belong to `ExactStellarScheme`. + * + * See `docs/x402/settle-design.md` for the custody, idempotency and failure + * decisions this implements. + */ + +/** CAIP-2 ids, matching what `accepts[].network` carries. */ +export const CAIP2_BY_NETWORK: Record = { + mainnet: 'stellar:pubnet', + testnet: 'stellar:testnet', +} + +/** The one method the settle route needs, so tests can substitute a double. */ +export interface SettlementFacilitator { + settle(payload: unknown, requirements: unknown): Promise +} + +/** `SettleResponse` from `@x402/core`, restated so we don't import ESM types. */ +export interface SettleResponseShape { + success: boolean + errorReason?: string + errorMessage?: string + payer?: string + transaction: string + network: string + amount?: string + extensions?: Record +} + +function signerSecrets(): string[] { + return (process.env.FACILITATOR_SIGNER_SECRETS ?? '') + .split(',') + .map(secret => secret.trim()) + .filter(secret => secret.length > 0) +} + +function maxTransactionFeeStroops(): number { + const raw = Number(process.env.FACILITATOR_MAX_FEE_STROOPS) + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 50_000 +} + +const cache = new Map() + +/** + * Builds (and memoises) the facilitator for a network, or returns null when no + * settlement key is configured. + * + * Null is a deliberate outcome rather than a thrown error: a deployment with + * no keys can still serve `/supported` and the Bazaar, and `/settle` answers + * every request with an explicit failure instead of half-working. + * + * The keys held here are fee and sequence-number keys only. The payer's + * authorisation travels inside the payload as a signed Soroban auth entry, so + * no payer secret exists in this process, and `ExactStellarScheme` refuses a + * payload in which a facilitator address participates in the transfer. + */ +export function getFacilitator(network: NetworkName): SettlementFacilitator | null { + const cached = cache.get(network) + if (cached !== undefined) return cached + + const secrets = signerSecrets() + if (secrets.length === 0) { + cache.set(network, null) + return null + } + + const caip2 = CAIP2_BY_NETWORK[network] + const signers = secrets.map(secret => createEd25519Signer(secret, caip2)) + const feeBumpSecret = process.env.FACILITATOR_FEE_BUMP_SECRET?.trim() + + const scheme = new ExactStellarScheme(signers, { + rpcConfig: { rpcUrl: getNetworkConfig(network).rpc.url }, + areFeesSponsored: true, + maxTransactionFeeStroops: maxTransactionFeeStroops(), + ...(feeBumpSecret ? { feeBumpSigner: createEd25519Signer(feeBumpSecret, caip2) } : {}), + }) + + const facilitator = new x402Facilitator().register(caip2, scheme) as SettlementFacilitator + + cache.set(network, facilitator) + return facilitator +} + +/** Drops the memoised facilitators. Used by tests that re-read the env. */ +export function resetFacilitatorCache(): void { + cache.clear() +} From 251ee2d6a95a3064d7fb95284860a3368eb11cc0 Mon Sep 17 00:00:00 2001 From: Miracle656 Date: Tue, 1 Sep 2026 17:28:20 +0100 Subject: [PATCH 3/3] Merge main and resolve the /settle collision in favour of this implementation #142 landed a POST /settle in src/api/facilitator.ts hours before this PR. Both files exported registerFacilitatorRoutes and both registered POST /settle, which would not compile and would have thrown FST_ERR_DUPLICATED_ROUTE if it had. Resolved by keeping this implementation, which is the better of the two: it writes a SettlementAttempt row keyed on the inner transaction hash BEFORE submitting, so a resource server that times out and retries replays the stored answer instead of paying twice, and an attempt left in-flight is resolved by reading the ledger rather than by resubmitting. The handler in api/facilitator.ts had no idempotency at all. - api/facilitator.ts keeps /supported and /verify; its /settle is removed, with a comment saying where the route went and why. - routes/facilitator.ts exports registerSettleRoute, so the two modules no longer collide on a name. - x402/facilitator.ts now sources keys from getNetworkConfig(network) .facilitator, the same place /supported and /verify read them, instead of a second FACILITATOR_SIGNER_SECRETS variable. One answer to which account settles on a network. - SettlementAttempt.network stores the NetworkName, not the CAIP-2 id. Every other model in this schema uses testnet/mainnet, and a row tagged 'stellar:testnet' would be invisible to a network filter written the way the rest of the schema expects. CAIP-2 stays on the wire. - .env.example and settle-design.md now name the variables that are actually read; the ones they documented had become unreachable. - The three /settle cases in facilitator.test.ts covered the removed handler and are superseded by facilitatorSettle.test.ts, which also covers replay, in-flight resolution and malformed payloads. tsc clean; full suite 366 passed / 1 skipped. --- .env.example | 20 ++++++----- docs/x402/settle-design.md | 10 +++--- prisma/schema.prisma | 8 +++-- src/__tests__/facilitator.test.ts | 42 ++++------------------ src/__tests__/facilitatorSettle.test.ts | 9 +++-- src/api/facilitator.ts | 46 ++++--------------------- src/index.ts | 4 +-- src/routes/facilitator.ts | 7 ++-- src/x402/facilitator.ts | 35 +++++++------------ 9 files changed, 61 insertions(+), 120 deletions(-) diff --git a/.env.example b/.env.example index 76123da..deb4849 100644 --- a/.env.example +++ b/.env.example @@ -153,13 +153,17 @@ ALERT_BOT_NOTIFY_URL= ALERT_BOT_NOTIFY_SECRET=alert-bot # --- x402 Facilitator (POST /settle) --- -# Comma-separated Stellar secret keys the facilitator signs settlement -# transactions with. These are fee and sequence-number keys only: the payer -# authorises inside the payload, so no payer key is ever held here. Leave -# unset to run without settlement — /settle then fails explicitly. -FACILITATOR_SIGNER_SECRETS= -# Optional separate fee source, wrapping settlement in a fee-bump transaction. -FACILITATOR_FEE_BUMP_SECRET= +# The Stellar secret key the facilitator signs settlement transactions with, +# per network. This is a fee and sequence-number key only: the payer authorises +# inside the payload as a signed Soroban auth entry, so no payer key is ever +# held by this process. Leave unset to run without settlement — /settle then +# fails explicitly rather than half-working, and /supported and the Bazaar +# still serve. +FACILITATOR_SECRET_KEY_TESTNET= +FACILITATOR_SECRET_KEY_MAINNET= +# Unsuffixed form, applied to testnet only. +FACILITATOR_SECRET_KEY= # Maximum fee in stroops the facilitator will pay (default 50000). -FACILITATOR_MAX_FEE_STROOPS=50000 +FACILITATOR_FEE_STROOPS_TESTNET=50000 +FACILITATOR_FEE_STROOPS_MAINNET=50000 diff --git a/docs/x402/settle-design.md b/docs/x402/settle-design.md index 092be6d..b3a2905 100644 --- a/docs/x402/settle-design.md +++ b/docs/x402/settle-design.md @@ -87,9 +87,11 @@ XLM for fees and nothing else — no USDC, no user balances — so the blast rad of a compromised settlement key is "someone burns our fee budget", not "someone drains a payer". -**Where the secret lives.** `FACILITATOR_SIGNER_SECRETS` (comma-separated, to -match the `signers` array `ExactStellarScheme` already takes) and -`FACILITATOR_FEE_BUMP_SECRET`, read once at plugin init like every other secret +**Where the secret lives.** `FACILITATOR_SECRET_KEY_TESTNET` / +`FACILITATOR_SECRET_KEY_MAINNET` (with the unsuffixed `FACILITATOR_SECRET_KEY` +applying to testnet), resolved through `getNetworkConfig(network).facilitator` +— the same place `/supported` and `/verify` read their keys, so there is one +answer to "which account settles on this network". Read once at plugin init like every other secret in this repo, held in process memory, never written to Postgres, never logged, and never returned by any route. `/supported` continues to advertise only the public addresses, which #124 already does via `FACILITATOR_SIGNER_ADDRESSES`. @@ -98,7 +100,7 @@ With no secrets configured the route registers but answers every request with misconfigured deploy fails loudly and safely instead of half-working. Fee ceiling stays configurable rather than hard-wired, per the RFP: -`maxTransactionFeeStroops` from `FACILITATOR_MAX_FEE_STROOPS` (SDK default +`maxTransactionFeeStroops` from `FACILITATOR_FEE_STROOPS_` (default 50,000), so a self-hoster can change it. ## 2. Idempotency diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2037072..2918972 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -208,8 +208,12 @@ model BazaarResource { model SettlementAttempt { id String @id @default(uuid()) - /// CAIP-2 network the payment settles on ("stellar:pubnet" | "stellar:testnet"). - network String + /// Network the payment settles on, as "testnet" | "mainnet" — the same + /// vocabulary every other model in this schema uses, so a network filter + /// works uniformly. The CAIP-2 id ("stellar:testnet") is a wire format and + /// stays on the wire; storing it here would silently exclude these rows + /// from any query that filters network the way the rest of the schema does. + network String @default("testnet") /// Inner transaction hash, hex — the idempotency key. txHash String @map("tx_hash") diff --git a/src/__tests__/facilitator.test.ts b/src/__tests__/facilitator.test.ts index 80a703e..5c9e709 100644 --- a/src/__tests__/facilitator.test.ts +++ b/src/__tests__/facilitator.test.ts @@ -95,40 +95,10 @@ describe('Facilitator endpoints', () => { expect(res.json()).toEqual({ isValid: false, invalidReason: 'expired' }) }) - it('POST /settle returns settle response', async () => { - mockSettle.mockResolvedValue({ success: true, transaction: 'txhash' }) - const res = await app.inject({ - method: 'POST', - url: '/settle', - payload: { paymentPayload: {}, paymentRequirements: {} } - }) - expect(res.statusCode).toBe(200) - expect(res.json()).toEqual({ success: true, transaction: 'txhash' }) - }) - - it('POST /settle returns 400 if settle returns success: false', async () => { - mockSettle.mockResolvedValue({ success: false, errorReason: 'failed' }) - const res = await app.inject({ - method: 'POST', - url: '/settle', - payload: { paymentPayload: {}, paymentRequirements: {} } - }) - expect(res.statusCode).toBe(400) - expect(res.json()).toEqual({ success: false, errorReason: 'failed' }) - }) - - it('POST /settle passes caught structured error response', async () => { - const error: any = new Error('Settlement failed') - error.statusCode = 400 - error.response = { success: false, errorReason: 'tx_failed' } - mockSettle.mockRejectedValue(error) - - const res = await app.inject({ - method: 'POST', - url: '/settle', - payload: { paymentPayload: {}, paymentRequirements: {} } - }) - expect(res.statusCode).toBe(400) - expect(res.json()).toEqual({ success: false, errorReason: 'tx_failed' }) - }) + // POST /settle is no longer registered by this plugin — it moved to + // src/routes/facilitator.ts with #149, which made it idempotent. The three + // settle cases that used to live here (success passthrough, success:false + // mapping, structured thrown error) are covered by facilitatorSettle.test.ts + // alongside the replay, in-flight-resolution and malformed-payload cases the + // old handler had no behaviour for. }) diff --git a/src/__tests__/facilitatorSettle.test.ts b/src/__tests__/facilitatorSettle.test.ts index 3eca745..3d449fa 100644 --- a/src/__tests__/facilitatorSettle.test.ts +++ b/src/__tests__/facilitatorSettle.test.ts @@ -43,7 +43,7 @@ vi.mock('@stellar/stellar-sdk', async importOriginal => { } }) -import { registerFacilitatorRoutes, deriveIdempotencyKey, SETTLE_ERROR_REASONS } from '../routes/facilitator' +import { registerSettleRoute, deriveIdempotencyKey, SETTLE_ERROR_REASONS } from '../routes/facilitator' /** A real, signed envelope — the route hashes it, so it cannot be a stub. */ function buildEnvelope(passphrase: string): string { @@ -92,7 +92,7 @@ function settleBody( async function buildApp() { const app = Fastify({ logger: false }) - await registerFacilitatorRoutes(app) + await registerSettleRoute(app) await app.ready() return app } @@ -160,7 +160,10 @@ describe('POST /settle', () => { expect.objectContaining({ data: expect.objectContaining({ txHash: deriveIdempotencyKey(TESTNET_ENVELOPE, 'testnet'), - network: 'stellar:testnet', + // Stored as the NetworkName, not the CAIP-2 wire id — every other + // model in the schema uses this vocabulary, and a row tagged + // 'stellar:testnet' would be invisible to a network filter. + network: 'testnet', state: 'submitting', }), }), diff --git a/src/api/facilitator.ts b/src/api/facilitator.ts index a54eae8..1028e47 100644 --- a/src/api/facilitator.ts +++ b/src/api/facilitator.ts @@ -89,45 +89,13 @@ async function facilitatorPlugin(app: FastifyInstance) { } }) - app.post('/settle', { config: { public: true } }, async (req: any, reply) => { - try { - if (!req.body || typeof req.body !== 'object') { - return reply.status(400).send({ - success: false, - errorReason: 'bad_request', - errorMessage: 'Missing request body' - }) - } - const { x402Version, paymentPayload, paymentRequirements } = req.body - if (!paymentPayload || !paymentRequirements) { - return reply.status(400).send({ - success: false, - errorReason: 'bad_request', - errorMessage: 'Missing paymentPayload or paymentRequirements' - }) - } - - const payloadWithVersion = { ...paymentPayload, x402Version: x402Version ?? paymentPayload.x402Version } - - const result = await facilitator.settle(payloadWithVersion, paymentRequirements) - - if (!result.success) { - return reply.status(400).send(result) - } - - return reply.send(result) - } catch (err: any) { - if (err && typeof err === 'object' && err.response && 'success' in err.response) { - return reply.status(err.statusCode || 400).send(err.response) - } - req.log.error(err, 'Settlement failed internally') - return reply.status(500).send({ - success: false, - errorReason: 'internal_error', - errorMessage: 'Internal server error during settlement' - }) - } - }) + // NOTE: POST /settle lives in src/routes/facilitator.ts, not here. + // That implementation is idempotent — it writes a SettlementAttempt row + // keyed on the inner transaction hash BEFORE submitting, so a resource + // server that times out and retries replays the stored answer instead of + // paying a second time. Registering a second /settle here would be a + // duplicate Fastify route; the non-idempotent handler that used to sit at + // this spot was replaced by it (#149). } export const registerFacilitatorRoutes = fp(facilitatorPlugin, { name: 'facilitator' }) diff --git a/src/index.ts b/src/index.ts index b62b21c..99a0f53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,7 +33,7 @@ import { registerBenchmarkRoutes } from './routes/benchmark' import { registerOracleRoutes } from './routes/oracle' import { registerBasketRoutes } from './routes/basket' import { registerDiscoveryRoutes } from './routes/discovery' -import { registerFacilitatorRoutes } from './routes/facilitator' +import { registerSettleRoute } from './routes/facilitator' import { fanOutManager } from './ws/fanout' import { startSDEXIngester } from './ingesters/sdex' @@ -141,7 +141,7 @@ async function main() { await registerOracleRoutes(app) await registerBasketRoutes(app) await registerDiscoveryRoutes(app) - await registerFacilitatorRoutes(app) + await registerSettleRoute(app) await registerGraphQL(app) await registerWebSocket(app) diff --git a/src/routes/facilitator.ts b/src/routes/facilitator.ts index 1c8f19d..7f8ecae 100644 --- a/src/routes/facilitator.ts +++ b/src/routes/facilitator.ts @@ -143,7 +143,7 @@ function isUniqueViolation(err: unknown): boolean { * Registers the facilitator routes. Public: a facilitator cannot demand * payment to accept one. */ -export async function registerFacilitatorRoutes(app: FastifyInstance) { +export async function registerSettleRoute(app: FastifyInstance) { app.post('/settle', { config: { public: true } }, async (req: FastifyRequest, reply: FastifyReply) => { const body = (req.body ?? {}) as SettleRequestBody @@ -193,7 +193,8 @@ export async function registerFacilitatorRoutes(app: FastifyInstance) { try { const attempt = await prisma.settlementAttempt.create({ data: { - network: caip2, + // The DB stores the NetworkName; caip2 is the wire form and stays on the wire. + network, txHash, state: 'submitting', payTo: typeof body.paymentRequirements?.payTo === 'string' ? body.paymentRequirements.payTo : null, @@ -208,7 +209,7 @@ export async function registerFacilitatorRoutes(app: FastifyInstance) { } const existing = await prisma.settlementAttempt.findUnique({ - where: { network_txHash: { network: caip2, txHash } }, + where: { network_txHash: { network, txHash } }, }) // A payload is consumed the moment its record exists. A second settle diff --git a/src/x402/facilitator.ts b/src/x402/facilitator.ts index b751d7d..7f0bd23 100644 --- a/src/x402/facilitator.ts +++ b/src/x402/facilitator.ts @@ -15,7 +15,7 @@ import { createEd25519Signer } from '@x402/stellar' * simulation checks and the submission all belong to `ExactStellarScheme`. * * See `docs/x402/settle-design.md` for the custody, idempotency and failure - * decisions this implements. + * decisions the `/settle` route implements on top of this. */ /** CAIP-2 ids, matching what `accepts[].network` carries. */ @@ -41,23 +41,15 @@ export interface SettleResponseShape { extensions?: Record } -function signerSecrets(): string[] { - return (process.env.FACILITATOR_SIGNER_SECRETS ?? '') - .split(',') - .map(secret => secret.trim()) - .filter(secret => secret.length > 0) -} - -function maxTransactionFeeStroops(): number { - const raw = Number(process.env.FACILITATOR_MAX_FEE_STROOPS) - return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 50_000 -} - const cache = new Map() /** * Builds (and memoises) the facilitator for a network, or returns null when no - * settlement key is configured. + * settlement key is configured for it. + * + * Keys come from `getNetworkConfig(network).facilitator`, the same place + * `/supported` and `/verify` read them, so there is one answer to "which + * account settles on this network". * * Null is a deliberate outcome rather than a thrown error: a deployment with * no keys can still serve `/supported` and the Bazaar, and `/settle` answers @@ -72,21 +64,18 @@ export function getFacilitator(network: NetworkName): SettlementFacilitator | nu const cached = cache.get(network) if (cached !== undefined) return cached - const secrets = signerSecrets() - if (secrets.length === 0) { + const netConfig = getNetworkConfig(network) + const secret = netConfig.facilitator.secretKey?.trim() + if (!secret) { cache.set(network, null) return null } const caip2 = CAIP2_BY_NETWORK[network] - const signers = secrets.map(secret => createEd25519Signer(secret, caip2)) - const feeBumpSecret = process.env.FACILITATOR_FEE_BUMP_SECRET?.trim() - - const scheme = new ExactStellarScheme(signers, { - rpcConfig: { rpcUrl: getNetworkConfig(network).rpc.url }, + const scheme = new ExactStellarScheme([createEd25519Signer(secret, caip2)], { + rpcConfig: { url: netConfig.rpc.url }, areFeesSponsored: true, - maxTransactionFeeStroops: maxTransactionFeeStroops(), - ...(feeBumpSecret ? { feeBumpSigner: createEd25519Signer(feeBumpSecret, caip2) } : {}), + maxTransactionFeeStroops: netConfig.facilitator.feeStroops, }) const facilitator = new x402Facilitator().register(caip2, scheme) as SettlementFacilitator