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/2] 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/2] 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() +}