local402-facilitator 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,44 +1,44 @@
1
- # local402-facilitator
2
-
3
- An [x402](https://x402.org) facilitator for Stellar that you run yourself. It verifies and settles
4
- `exact` payments (USDC and any Stellar token) and `exact-fx` payments (the payer spends XLM or EURC, the
5
- seller receives exact USDC through the FxPay contract), and pays the network fees from its own accounts.
6
-
7
- ```bash
8
- npx local402-facilitator keys # two fresh fee-paying testnet accounts, funded, written to .env
9
- npx local402-facilitator check # config, RPC, balances and FxPay: exits 0 when it can settle
10
- npx local402-facilitator # listens on :4022
11
- ```
12
-
13
- Then point a seller at it: `local402Server("http://localhost:4022")` in `local402-server`, or any x402
14
- seller that takes a facilitator URL.
15
-
16
- Node 20.12 or later. A `Dockerfile` that installs this package from npm is in the
17
- [repository](https://github.com/DiegoPoveda01/local402/blob/main/packages/facilitator/Dockerfile).
18
-
19
- ## What a public instance needs
20
-
21
- - **A seller allowlist and a minimum amount.** Every settlement spends this instance's XLM.
22
- `PAY_TO_ALLOWLIST` limits it to the sellers you name; on `stellar:pubnet` it refuses to start without one.
23
- - **`TRUST_PROXY` set to the proxies really in front of it** (0 by default), so the limits count real clients.
24
- - **Separate limits for verify and settle**, per client per minute: verify only simulates, settle spends fees.
25
- - **Its own fee-paying keys.** Two instances sharing an account collide on its sequence number.
26
- - **`/health` for liveness and `/ready` for readiness.** `/ready` answers 503, saying which check failed,
27
- when the RPC is down, a fee payer is below `MIN_FEE_BALANCE_XLM`, or FxPay is gone from the ledger.
28
-
29
- Every variable, the endpoints and the mainnet configuration:
30
- [docs/facilitator.md](https://github.com/DiegoPoveda01/local402/blob/main/docs/facilitator.md).
31
- Keys, funding, top-ups and rotation:
32
- [docs/facilitator-runbook.md](https://github.com/DiegoPoveda01/local402/blob/main/docs/facilitator-runbook.md).
33
-
34
- ## As a library
35
-
36
- ```ts
37
- import { createFacilitator, facilitatorConfig } from "local402-facilitator";
38
-
39
- const config = facilitatorConfig(process.env); // throws INVALID_CONFIG naming the variable, never a key
40
- const { app } = createFacilitator(config); // an Express app
41
- app.listen(config.port);
42
- ```
43
-
44
- MIT
1
+ # local402-facilitator
2
+
3
+ An [x402](https://x402.org) facilitator for Stellar that you run yourself. It verifies and settles
4
+ `exact` payments (USDC and any Stellar token) and `exact-fx` payments (the payer spends XLM or EURC, the
5
+ seller receives exact USDC through the FxPay contract), and pays the network fees from its own accounts.
6
+
7
+ ```bash
8
+ npx local402-facilitator keys # two fresh fee-paying testnet accounts, funded, written to .env
9
+ npx local402-facilitator check # config, RPC, balances and FxPay: exits 0 when it can settle
10
+ npx local402-facilitator # listens on :4022
11
+ ```
12
+
13
+ Then point a seller at it: `local402Server("http://localhost:4022")` in `local402-server`, or any x402
14
+ seller that takes a facilitator URL.
15
+
16
+ Node 20.12 or later. A `Dockerfile` that installs this package from npm is in the
17
+ [repository](https://github.com/DiegoPoveda01/local402/blob/main/packages/facilitator/Dockerfile).
18
+
19
+ ## What a public instance needs
20
+
21
+ - **A seller allowlist and a minimum amount.** Every settlement spends this instance's XLM.
22
+ `PAY_TO_ALLOWLIST` limits it to the sellers you name; on `stellar:pubnet` it refuses to start without one.
23
+ - **`TRUST_PROXY` set to the proxies really in front of it** (0 by default), so the limits count real clients.
24
+ - **Separate limits for verify and settle**, per client per minute: verify only simulates, settle spends fees.
25
+ - **Its own fee-paying keys.** Two instances sharing an account collide on its sequence number.
26
+ - **`/health` for liveness and `/ready` for readiness.** `/ready` answers 503, saying which check failed,
27
+ when the RPC is down, a fee payer is below `MIN_FEE_BALANCE_XLM`, or FxPay is gone from the ledger.
28
+
29
+ Every variable, the endpoints and the mainnet configuration:
30
+ [docs/facilitator.md](https://github.com/DiegoPoveda01/local402/blob/main/docs/facilitator.md).
31
+ Keys, funding, top-ups and rotation:
32
+ [docs/facilitator-runbook.md](https://github.com/DiegoPoveda01/local402/blob/main/docs/facilitator-runbook.md).
33
+
34
+ ## As a library
35
+
36
+ ```ts
37
+ import { createFacilitator, facilitatorConfig } from "local402-facilitator";
38
+
39
+ const config = facilitatorConfig(process.env); // throws INVALID_CONFIG naming the variable, never a key
40
+ const { app } = createFacilitator(config); // an Express app
41
+ app.listen(config.port);
42
+ ```
43
+
44
+ MIT
package/dist/config.d.ts CHANGED
@@ -23,6 +23,11 @@ export interface FacilitatorConfig {
23
23
  minFeeBalanceXlm: number;
24
24
  /** Proxies in front of the instance whose `X-Forwarded-For` it believes, for per-client limits. */
25
25
  trustProxy: number;
26
+ /** Upstash Redis over REST, where the Bazaar catalog survives restarts. Without it the catalog lives in memory. */
27
+ catalogRedis?: {
28
+ url: string;
29
+ token: string;
30
+ };
26
31
  }
27
32
  type Env = Record<string, string | undefined>;
28
33
  /**
package/dist/config.js CHANGED
@@ -84,6 +84,9 @@ export function facilitatorConfig(env = process.env) {
84
84
  const minFeeBalanceXlm = Number(env.MIN_FEE_BALANCE_XLM?.trim() || "2");
85
85
  if (!Number.isFinite(minFeeBalanceXlm) || minFeeBalanceXlm < 0)
86
86
  invalid(`MIN_FEE_BALANCE_XLM must be a number of XLM, got ${shown(env.MIN_FEE_BALANCE_XLM ?? "")}.`);
87
+ // Vercel's Upstash integration names them KV_REST_API_*; Upstash itself, UPSTASH_REDIS_REST_*.
88
+ const redisUrl = env.KV_REST_API_URL?.trim() || env.UPSTASH_REDIS_REST_URL?.trim();
89
+ const redisToken = env.KV_REST_API_TOKEN?.trim() || env.UPSTASH_REDIS_REST_TOKEN?.trim();
87
90
  return {
88
91
  network,
89
92
  secrets,
@@ -97,5 +100,6 @@ export function facilitatorConfig(env = process.env) {
97
100
  limits: { verify: whole(env, "VERIFY_RATE_LIMIT", 60, 1), settle: whole(env, "SETTLE_RATE_LIMIT", 20, 1) },
98
101
  minFeeBalanceXlm,
99
102
  trustProxy: whole(env, "TRUST_PROXY", 0, 0, 10),
103
+ catalogRedis: redisUrl && redisToken ? { url: redisUrl, token: redisToken } : undefined,
100
104
  };
101
105
  }
@@ -69,8 +69,22 @@ export function refusal(body, config) {
69
69
  export function createFacilitator(config, options = {}) {
70
70
  const { network } = config;
71
71
  const rpcConfig = config.rpcUrl ? { url: config.rpcUrl } : undefined;
72
- // Bazaar catalog: every resource that settles here and declares discovery info, keyed by URL.
72
+ // Bazaar catalog: every resource that settles here and declares discovery info, keyed by URL. Serverless instances
73
+ // start empty, so with Redis configured the catalog lives there; memory is the fallback.
73
74
  const catalog = new Map();
75
+ const catalogKey = `local402:bazaar:${network}`;
76
+ const redisConfig = config.catalogRedis;
77
+ /** One command over Upstash's REST API. */
78
+ const redis = async (...command) => {
79
+ const response = await fetch(redisConfig.url, {
80
+ method: "POST",
81
+ headers: { Authorization: `Bearer ${redisConfig.token}` },
82
+ body: JSON.stringify(command),
83
+ });
84
+ if (!response.ok)
85
+ throw new Error(`Redis ${response.status}`);
86
+ return (await response.json()).result;
87
+ };
74
88
  // Several fee-paying accounts ("channels") let settlements run in parallel without sequence number clashes.
75
89
  const signers = config.secrets.map((secret) => createEd25519Signer(secret, network));
76
90
  const feePayers = signers.map((signer) => signer.address);
@@ -88,15 +102,25 @@ export function createFacilitator(config, options = {}) {
88
102
  if (!discovered)
89
103
  return;
90
104
  const { resourceUrl, x402Version, discoveryInfo, ...metadata } = discovered;
91
- const accepts = (catalog.get(resourceUrl)?.accepts ?? []).filter((a) => a.scheme !== requirements.scheme);
92
- catalog.set(resourceUrl, {
105
+ // Another instance may have stored the route's other scheme; keep it.
106
+ let previous = catalog.get(resourceUrl);
107
+ if (!previous && redisConfig) {
108
+ previous = await redis("HGET", catalogKey, resourceUrl).then((value) => (typeof value === "string" ? JSON.parse(value) : undefined), () => undefined);
109
+ }
110
+ const accepts = (previous?.accepts ?? []).filter((a) => a.scheme !== requirements.scheme);
111
+ const entry = {
93
112
  ...metadata,
94
113
  resource: resourceUrl,
95
114
  type: discoveryInfo.input.type,
96
115
  x402Version,
97
116
  accepts: [...accepts, requirements],
98
117
  lastUpdated: new Date().toISOString(),
99
- });
118
+ };
119
+ catalog.set(resourceUrl, entry);
120
+ // The payment has settled by now: a Redis failure only costs the catalog entry, never the result.
121
+ if (redisConfig) {
122
+ await redis("HSET", catalogKey, resourceUrl, JSON.stringify(entry)).catch(() => console.warn("bazaar: could not save the catalog entry to Redis"));
123
+ }
100
124
  });
101
125
  // Verification simulates on RPC and settlement spends fees, so both are bounded, each with its own cap.
102
126
  const limiter = new RateLimiter(config.limits);
@@ -123,8 +147,17 @@ export function createFacilitator(config, options = {}) {
123
147
  app.get("/supported", (_req, res) => {
124
148
  res.json(facilitator.getSupported());
125
149
  });
126
- app.get("/discovery/resources", (req, res) => {
127
- const items = [...catalog.values()].filter((item) => !req.query.type || item.type === req.query.type);
150
+ app.get("/discovery/resources", async (req, res) => {
151
+ let all = [...catalog.values()];
152
+ if (redisConfig) {
153
+ try {
154
+ all = (await redis("HVALS", catalogKey)).map((value) => JSON.parse(value));
155
+ }
156
+ catch {
157
+ console.warn("bazaar: could not read the catalog from Redis, serving this instance's copy");
158
+ }
159
+ }
160
+ const items = all.filter((item) => !req.query.type || item.type === req.query.type);
128
161
  const limit = Number(req.query.limit ?? 100);
129
162
  const offset = Number(req.query.offset ?? 0);
130
163
  res.json({ x402Version: 2, items: items.slice(offset, offset + limit), pagination: { limit, offset, total: items.length } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local402-facilitator",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Deployable x402 facilitator for Stellar: settles exact and exact-fx, with a seller allowlist, separate verify and settle limits, health and readiness",
5
5
  "keywords": [
6
6
  "x402",
@@ -35,8 +35,8 @@
35
35
  "node": ">=20"
36
36
  },
37
37
  "dependencies": {
38
- "local402-fx": "^1.0.0",
39
- "@stellar/stellar-sdk": "16.3.0",
38
+ "local402-fx": "^1.0.1",
39
+ "@stellar/stellar-sdk": "16.4.1",
40
40
  "@x402/core": "2.25.0",
41
41
  "@x402/extensions": "2.25.0",
42
42
  "@x402/stellar": "2.25.0",