An x402 payment facilitator for Stellar with Bazaar discovery — verify and settle HTTP-402 payments for any seller, and let agents find payable resources without hardcoded integrations.
Status: working on testnet, pre-production. The full loop is live-proven (see
docs/decisions.mdfor transaction hashes): a policy-governed Soroban smart account paid a Bazaar-discoverable resource, this facilitator verified and settled it on-chain, and the resource became searchable automatically.The pre-mainnet security review is complete — see
docs/closing-state.mdfor every finding with its final status, anddocs/security-audit.mdfor the detail. Running it is documented indocs/operator-runbook.md.
Both are properties of this deployment, not of the code, and both are more surprising to find by experiment than to be told.
1. First request after idle takes ~45 seconds, at any hour. Render spins a free service down after 15 minutes without traffic and the container is replaced, not paused; measured cold start 44.76 seconds. You pay it once — the service then stays warm for 15 minutes past your last request.
The warm-window story, honestly: the original */5 keep-alive cron was retired
2026-08-15 on delivery measurement — GitHub's scheduler ran it at 18–52 minute
gaps against a 15-minute idle timeout, so it spent pool hours on a coin flip.
Since 2026-08-21 a narrower best-effort keep-warm cron
(.github/workflows/keep-warm.yml) pings every 10 minutes during reviewer
hours only (07:00–21:00 UTC, weekdays — the widest window that fits the
shared free-tier pool for two services with real margin, see the workflow's
own header for the math) — margin against the timeout, not a
guarantee, for exactly the measured reason above; outside those hours, and
whenever a ping slips, the cold start applies. The instance remains on the
free tier (a paid always-on move was approved and rescinded for budget the
same day; render.yaml carries the ready-to-apply config behind a loud billing
warning). So: still assume the ~45s cold start unless you are inside the
warmed window. History: using-it.md § About the cold
start.
The catalog survives either way: it lives in libSQL/Turso rather than on the container, so your listings and ownership bindings are there when it wakes.
2. THE TRUST LAYER IS INERT HERE. Every verification badge reads "unknown",
and ?verified_only=true is refused with an explicit 400. Not a bug and not
misconfiguration:
"trust": { "verification": "unknown", "acceptsVerification": ["unknown"], … }The verdicts come from a verification API that is deployed nowhere, and this is a deliberate deferral with a known dependency chain, not an oversight or a missing config value:
trust badges ← a verdict source
← worker-service deployed (needs a Docker host)
← ATTESTOR_SECRET_KEY (needs an attestor)
← M5 multisig design (unresolved)
Each link is blocked by the one below it, and the bottom one is a design decision in the wallet repository, not a deployment task here. Nothing about this is switching on soon, and it will not be switched on to produce testnet badges — turning it on for a demo would mean standing up a Docker host and minting an attestor key ahead of the multisig design that is supposed to govern it, which inverts the thing the attestor exists for.
So VERIFICATION_API_URL is deliberately unset, and every verdict degrades to
"unknown" — the honest default, since the alternative is asserting a trust
level nothing backs. Tracked as F4-ts.
Two prerequisites are being done in the wallet repo regardless, because they are defects in the API's design rather than in its deployment: per-record timestamps (the current response has none, so "newest verdict" falls back to array order) and endpoint authentication (it is unauthenticated, which would make it a trust root anyone could impersonate). Neither produces a badge here; both must be true before one would be worth trusting.
What this does NOT mean. Two different things in that block are called "verified", and only one of them is inert:
| Field | State here | What it is |
|---|---|---|
verification, acceptsVerification |
inert — always "unknown" |
Third-party attestation of the payee, from the absent API |
ownerVerified |
working, proven live | Whether the resource's own 402 challenge names its bound payTo — fetched by this facilitator, no external service involved |
So ownership verification works and is enforced; reputation verification is
switched off. If you filter on verified_only, you are asking about the inert
one, and the API now says so instead of pretending: a 400 with
verified_only_unavailable and a pointer at ownerVerified.
That refusal replaced an older, worse behaviour: a silent items: [] — a
correct-looking answer to a question this deployment cannot answer, which read
as "nothing here is verified" when the truth was "nothing is being checked".
| Endpoint | Purpose |
|---|---|
POST /verify |
Verify a payment by re-simulation on Soroban RPC (runs the payer's __check_auth, including any on-chain spending policy) |
POST /settle |
Submit the payment on-chain, sponsoring the network fee — buyers hold only the payment asset, no XLM |
GET /supported |
Advertise scheme/network/extensions/signers to sellers |
GET /discovery/resources |
List cataloged x402 resources (filters: type, payTo, scheme, network, extensions, verified_only†; limit/offset) |
GET /discovery/search |
Keyword search over the catalog — tokenized and relevance-scored, not semantic (query, same filters, cursor-paginated) |
GET /health |
Liveness, plus the deployed commit, uptimeSeconds, catalogSize, catalogFrozen when bindings are frozen, and unverifiableEntries when any seller advertises an address the facilitator cannot fetch |
† verified_only is refused with a 400 (verified_only_unavailable) on
this deployment. The verdicts it filters on come from a service that is deployed
nowhere — see Two things to know above — so an empty list would describe the
deployment, not the resources. ownerVerified is the field that works.
Smart accounts welcome. Policy-governed smart-account payments cost ~130k
stroops of simulation fee (the policy contract runs inside __check_auth);
hosted facilitators defaulting to a 50k ceiling reject them. This facilitator
defaults to 500,000 (MAX_TX_FEE_STROOPS) — the exact bug that motivated
this project, fixed from day one. That default is sized from evidence that
carries its provenance (docs/decision-fee-thresholds.md):
the most expensive verified reading is 140,331 stroops simulated for a
freshly provisioned policy-governed wallet (28,711 is the worst hash-verifiable
charge on-chain), so 500,000 clears it by ~3.6x while bounding worst-case
sponsor drain per settle at 0.05 XLM.
Bazaar catalogs itself. When a payment settles and its payload carries the
official bazaar discovery extension,
the resource is upserted into the catalog automatically — no registration
step. Catalog-on-settle keeps unpaid spam out; route templates and service
metadata are validated/sanitized by the official extractor (catalog-poisoning
guard).
Discovery responses carry a trust block. Alongside the standard resource
fields, each entry reports settlements, uniquePayers and lastSettled, plus
verification / acceptsVerification / ownerVerified. Two things to know
before you build on it:
observedSettlementsandstatsSourcedisclose provenance.settlementsmay include a base loaded from durable storage, which has no independent source of truth — a settlement count cannot be re-derived from the chain.statsSourcereads"persisted"when any part of it was inherited rather than witnessed, andobservedSettlementscounts only what this process saw. If you need a number you can rely on, use that one.- If you are a seller, advertise your PUBLIC address. The
resource.urlin your 402 is what gets cataloged and what ownership verification re-fetches. Ahttp://localhost:…value (the default inexamples/seller.mjs) can never be verified — it is not https and not reachable — so your entry is served permanently unverified.PUBLIC_BASE_URLis how the example declares its real address;/healthreportsunverifiableEntrieswhen any entry is in this state. - Verification verdicts read
"unknown"on this deployment and?verified_only=trueis refused with a 400 — see Two things to know at the top.ownerVerifiedis the field that does work here.
Worth knowing before you point a client at it:
| Limit | Value |
|---|---|
| Rate limit | 60 requests/min per IP (/health exempt) |
| Request body | 32 KiB, applied to every route |
/settle refusals |
503 settlement_refused with a reason — sponsor_balance_low, spend_ceiling, rate_limited_payto, rate_limited_url, unbound_pool_exhausted |
Refusals are deliberately loud and carry a reason. Spend controls are log-only on testnet and enforced on pubnet, so a testnet client will see them in logs before it ever sees a 503.
The catalog survives a restart. It used to not: the container's filesystem went with every spin-down, taking listings and ownership bindings. Since 2026-08-11 the catalog is in libSQL/Turso, verified live across a 42-second cold start with the binding intact. An empty catalog now means an empty catalog, not a restart — if yours is missing, something else is wrong.
The keep-alive workflow's full arc, honestly: disabled at first for workspace
pool-budget reasons, re-enabled 2026-08-11 against measured headroom,
retired 2026-08-15 when measurement showed GitHub's cron delivery (18–52
minute gaps) could never beat the 15-minute idle timeout — hours spent, warmth
not delivered — and partially revived 2026-08-21 as keep-warm.yml: every
10 minutes, reviewer hours only, documented as best-effort margin rather than
a warm-window promise, since GitHub's delivery is the same scheduler that
failed the measurement. The durable catalog had already removed half the
reason to want it; the real fix remains a paid always-on instance, which is
budgeted-not-bought as of 2026-08-15 (render.yaml holds the one-line change
and its price).
Resource-URL ownership is trust-on-first-use. The first settlement for a
canonical URL (origin + pathname) binds it to that payment's payTo; later
settlements with a different payTo are refused from the catalog, though the
payment itself still settles. If you are a seller, be the first to settle for
your own URL. Changing that address afterwards currently needs an operator —
see runbook §1.
npm install
cp .env.example .env # set SPONSOR_SECRET_KEY (funded testnet account)
npm run dev # http://localhost:4100One-time setup, if you will be pushing branches:
git config core.hooksPath .githooks # refuses pushes to a merged PR's branchTests and typecheck:
npm test # includes wire-conformance tests using the unmodified
# canonical HTTPFacilitatorClient + withBazaar client
npm run typechecksrc/mcp.ts exposes the catalog as MCP tools (x402_list_resources,
x402_search_resources) over stdio, backed by the same HTTP API via the
official withBazaar client. Point any MCP client at it:
{
"mcpServers": {
"vellar-x402-discovery": {
"command": "npx",
"args": ["tsx", "src/mcp.ts"],
"cwd": "/path/to/vellar-facilitator",
"env": { "FACILITATOR_URL": "http://localhost:4100" }
}
}
}docs/using-it.md — how to point at a running
facilitator, split by role:
- Merchants — what to change in your resource server, what your 402 must declare, the five requirements for ownership verification, and what the silent unverified case looks like.
- Buyers/agents — what you need on your side (and what the sponsor covers, so you know what you do not need to hold), discovery, and paying.
Plus what will bite you on the hosted instance, and who this is ready for.
docs/guide.md is the different thing: running a facilitator
locally and walking the loop with both example scripts.
examples/ contains the full loop, live-verified on testnet:
seller.mjs— an Express API with one paid route that declares the bazaar discovery extension (price, input schema, output example).buyer-classic.mjs— a plain Stellar keypair pays it, built entirely on the official x402 client (@x402/stellar/exact/client+@x402/core/client). ~12 lines of payment logic: no hand-assembled transaction, no hand-signed auth entry, no second funded account, and the discovery extension is echoed for you.buyer.mjs— an agent pays it from a Vellar smart account with an ed25519 session key (V1 credentials), echoing the discovery extension so the facilitator catalogs the resource on settlement. Hand-rolled by necessity: the official client cannot sign for a smart account (docs/upstream-issue-smart-accounts.md).
See docs/guide.md for the walkthrough.
Composes the official Coinbase x402 packages rather than reimplementing the
protocol: x402Facilitator (@x402/core) orchestrates; ExactStellarScheme
(@x402/stellar) implements Stellar verify/settle; @x402/extensions/bazaar
supplies the discovery data model, validation, and canonical client. This
repo adds the HTTP service, correct Stellar configuration, the Bazaar catalog
(storage, filtering, search, persistence), auto-cataloging ingestion, the MCP
server, and the test suite. Full spec: technical-doc.md.
Separate infrastructure from the Vellar wallet and vellar-sdk (the x402 payer side). Any x402 client can use this facilitator — Vellar wallets and non-Vellar wallets alike. Shared expertise, not shared code.
Apache-2.0