local402-facilitator 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +44 -3
- package/dist/app.d.ts +1 -0
- package/dist/app.js +8 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +81 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.js +105 -0
- package/dist/facilitator.d.ts +29 -0
- package/dist/facilitator.js +226 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/keys.d.ts +10 -0
- package/dist/keys.js +37 -0
- package/dist/ready.d.ts +35 -0
- package/dist/ready.js +60 -0
- package/package.json +43 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Diego Poveda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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/app.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const app: import("express").Express, feePayers: string[];
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The facilitator configured from this process's environment, for the Local402 deployment
|
|
2
|
+
// (scripts/vercel/entry.ts) and the mainnet scripts. Other deployments run the `local402-facilitator` command.
|
|
3
|
+
import { facilitatorConfig } from "./config.js";
|
|
4
|
+
import { createFacilitator, describe } from "./facilitator.js";
|
|
5
|
+
// Behind Vercel's edge: one proxy writes X-Forwarded-For, so the per-client limits see the real address.
|
|
6
|
+
const config = facilitatorConfig({ ...process.env, TRUST_PROXY: process.env.TRUST_PROXY || "1" });
|
|
7
|
+
export const { app, feePayers } = createFacilitator(config);
|
|
8
|
+
console.log(describe(config, feePayers));
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// local402-facilitator: run a Local402 facilitator from environment variables (or a .env file in the
|
|
3
|
+
// current directory). See docs/facilitator.md for every variable.
|
|
4
|
+
//
|
|
5
|
+
// local402-facilitator start it
|
|
6
|
+
// local402-facilitator check check the config and the network, then exit (0 = ready)
|
|
7
|
+
// local402-facilitator keys [n] [--out f] make n fee-paying accounts (default 2) into f (default .env),
|
|
8
|
+
// funded with friendbot on testnet. Prints only public keys.
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { facilitatorConfig } from "./config.js";
|
|
11
|
+
import { createFacilitator, describe } from "./facilitator.js";
|
|
12
|
+
import { fundOnTestnet, newKeys, writeKeys } from "./keys.js";
|
|
13
|
+
if (existsSync(".env"))
|
|
14
|
+
process.loadEnvFile(".env");
|
|
15
|
+
const [command = "start", ...args] = process.argv.slice(2);
|
|
16
|
+
function config() {
|
|
17
|
+
try {
|
|
18
|
+
return facilitatorConfig();
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
console.error(error instanceof Error ? error.message : error);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (command === "start") {
|
|
26
|
+
const settings = config();
|
|
27
|
+
const { app, feePayers } = createFacilitator(settings);
|
|
28
|
+
console.log(describe(settings, feePayers));
|
|
29
|
+
const server = app.listen(settings.port, (error) => {
|
|
30
|
+
if (error)
|
|
31
|
+
throw error;
|
|
32
|
+
console.log(`Local402 facilitator on http://localhost:${settings.port} (fees paid by ${feePayers.join(", ")})`);
|
|
33
|
+
});
|
|
34
|
+
// Hosts stop an instance with SIGTERM: stop taking requests and let settlements in flight finish.
|
|
35
|
+
process.once("SIGTERM", () => server.close(() => process.exit(0)));
|
|
36
|
+
}
|
|
37
|
+
else if (command === "check") {
|
|
38
|
+
const settings = config();
|
|
39
|
+
const { feePayers, ready } = createFacilitator(settings);
|
|
40
|
+
console.log(describe(settings, feePayers));
|
|
41
|
+
const result = await ready();
|
|
42
|
+
const mark = (ok) => (ok ? "ok " : "FAIL");
|
|
43
|
+
console.log(`${mark(result.rpc.ok)} RPC${result.rpc.error ? `: ${result.rpc.error}` : ""}`);
|
|
44
|
+
for (const payer of result.feePayers) {
|
|
45
|
+
console.log(`${mark(payer.ok)} fee payer ${payer.address}${payer.xlm ? ` ${payer.xlm} XLM` : ""}${payer.error ? `: ${payer.error}` : ""}`);
|
|
46
|
+
}
|
|
47
|
+
console.log(`${mark(result.fxContract.ok)} FxPay ${result.fxContract.address}${result.fxContract.error ? `: ${result.fxContract.error}` : ""}`);
|
|
48
|
+
console.log(result.ready ? "ready" : "not ready");
|
|
49
|
+
process.exit(result.ready ? 0 : 1);
|
|
50
|
+
}
|
|
51
|
+
else if (command === "keys") {
|
|
52
|
+
const out = args.includes("--out") ? args[args.indexOf("--out") + 1] : ".env";
|
|
53
|
+
if (!out || out.startsWith("-")) {
|
|
54
|
+
console.error("--out needs a file name, e.g. local402-facilitator keys 2 --out .env");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
const count = Number(args.find((arg, i) => /^\d+$/.test(arg) && args[i - 1] !== "--out") ?? 2);
|
|
58
|
+
const network = process.env.NETWORK?.trim() || "stellar:testnet";
|
|
59
|
+
try {
|
|
60
|
+
const keys = newKeys(count);
|
|
61
|
+
writeKeys(out, keys.map((key) => key.secret()));
|
|
62
|
+
console.log(`Wrote ${count} fee-paying key(s) to ${out} as FACILITATOR_PRIVATE_KEYS (secrets are not printed).`);
|
|
63
|
+
for (const key of keys) {
|
|
64
|
+
if (network === "stellar:testnet") {
|
|
65
|
+
await fundOnTestnet(key.publicKey());
|
|
66
|
+
console.log(`${key.publicKey()} funded with friendbot`);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
console.log(`${key.publicKey()} send it XLM to create it (see the runbook for how much)`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
console.error(error instanceof Error ? error.message : error);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
console.error(`Unknown command ${command}. Use start, check or keys.`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Network } from "@x402/core/types";
|
|
2
|
+
/** Everything a facilitator instance runs on, read once from the environment by `facilitatorConfig`. */
|
|
3
|
+
export interface FacilitatorConfig {
|
|
4
|
+
network: Network;
|
|
5
|
+
/** Fee-paying accounts ("channels"), one per concurrent settlement. Never shared with another instance. */
|
|
6
|
+
secrets: string[];
|
|
7
|
+
fxContract: string;
|
|
8
|
+
sendAssets: string[];
|
|
9
|
+
rpcUrl?: string;
|
|
10
|
+
port: number;
|
|
11
|
+
/** Sellers this instance settles for. Empty means any seller, which only testnet allows. */
|
|
12
|
+
payToAllowlist: string[];
|
|
13
|
+
/** Tokens (C…) a seller may be paid in. Empty means any token, which only testnet allows. */
|
|
14
|
+
assetAllowlist: string[];
|
|
15
|
+
/** Smallest payment it settles, in token units (7 decimals: 100000 is 0.01 USDC). */
|
|
16
|
+
minAmount: bigint;
|
|
17
|
+
/** Requests per client per minute. Verify simulates on RPC; settle spends this instance's fees. */
|
|
18
|
+
limits: {
|
|
19
|
+
verify: number;
|
|
20
|
+
settle: number;
|
|
21
|
+
};
|
|
22
|
+
/** Below this many XLM a fee payer makes `/ready` fail, so the host stops routing to it before it runs dry. */
|
|
23
|
+
minFeeBalanceXlm: number;
|
|
24
|
+
/** Proxies in front of the instance whose `X-Forwarded-For` it believes, for per-client limits. */
|
|
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
|
+
};
|
|
31
|
+
}
|
|
32
|
+
type Env = Record<string, string | undefined>;
|
|
33
|
+
/**
|
|
34
|
+
* Reads and checks a facilitator's environment. Every mistake throws `INVALID_CONFIG` naming the variable and what
|
|
35
|
+
* it accepts, so a bad deploy fails at startup instead of on the first payment. Secret keys are never repeated.
|
|
36
|
+
*/
|
|
37
|
+
export declare function facilitatorConfig(env?: Env): FacilitatorConfig;
|
|
38
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { StrKey } from "@stellar/stellar-sdk";
|
|
2
|
+
import { FX_MAINNET, fxNetwork, Local402Error } from "local402-fx";
|
|
3
|
+
const SECRET_KEY_SHAPE = /^S[A-Z2-7]{55}$/;
|
|
4
|
+
function invalid(message) {
|
|
5
|
+
throw new Local402Error("INVALID_CONFIG", message);
|
|
6
|
+
}
|
|
7
|
+
/** A value for an error message, never a secret key: these messages end up in logs. */
|
|
8
|
+
const shown = (value) => (SECRET_KEY_SHAPE.test(value) ? "a secret key (not shown)" : value);
|
|
9
|
+
function list(value) {
|
|
10
|
+
return (value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
function whole(env, name, fallback, min, max = Number.MAX_SAFE_INTEGER) {
|
|
13
|
+
const raw = env[name]?.trim();
|
|
14
|
+
if (!raw)
|
|
15
|
+
return fallback;
|
|
16
|
+
const value = Number(raw);
|
|
17
|
+
if (!/^\d+$/.test(raw) || value < min || value > max) {
|
|
18
|
+
const range = max === Number.MAX_SAFE_INTEGER ? `at least ${min}` : `from ${min} to ${max}`;
|
|
19
|
+
invalid(`${name} must be a whole number ${range}, got ${shown(raw)}.`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Reads and checks a facilitator's environment. Every mistake throws `INVALID_CONFIG` naming the variable and what
|
|
25
|
+
* it accepts, so a bad deploy fails at startup instead of on the first payment. Secret keys are never repeated.
|
|
26
|
+
*/
|
|
27
|
+
export function facilitatorConfig(env = process.env) {
|
|
28
|
+
const network = (env.NETWORK?.trim() || "stellar:testnet");
|
|
29
|
+
if (network !== "stellar:testnet" && network !== "stellar:pubnet") {
|
|
30
|
+
invalid(`NETWORK must be stellar:testnet or stellar:pubnet, got ${shown(network)}.`);
|
|
31
|
+
}
|
|
32
|
+
const mainnet = network === "stellar:pubnet";
|
|
33
|
+
const secrets = list(env.FACILITATOR_PRIVATE_KEYS || env.FACILITATOR_PRIVATE_KEY);
|
|
34
|
+
if (!secrets.length) {
|
|
35
|
+
invalid("FACILITATOR_PRIVATE_KEYS is required: one or more fee-paying Stellar secret keys (S…), comma-separated. `local402-facilitator keys` makes them.");
|
|
36
|
+
}
|
|
37
|
+
secrets.forEach((secret, i) => {
|
|
38
|
+
if (!StrKey.isValidEd25519SecretSeed(secret)) {
|
|
39
|
+
const what = StrKey.isValidEd25519PublicKey(secret) ? "a public key; it needs the secret (S…)" : "not a Stellar secret key (S…)";
|
|
40
|
+
invalid(`FACILITATOR_PRIVATE_KEYS entry ${i + 1} is ${what}.`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
if (new Set(secrets).size !== secrets.length) {
|
|
44
|
+
invalid("FACILITATOR_PRIVATE_KEYS repeats a key: two channels on one account race for its sequence number.");
|
|
45
|
+
}
|
|
46
|
+
const fxContract = env.FX_CONTRACT?.trim() || undefined;
|
|
47
|
+
if (fxContract && !StrKey.isValidContract(fxContract))
|
|
48
|
+
invalid(`FX_CONTRACT must be a contract address (C…), got ${shown(fxContract)}.`);
|
|
49
|
+
const fx = fxNetwork(network, fxContract);
|
|
50
|
+
if (!fx.fxContract) {
|
|
51
|
+
invalid(`FX_CONTRACT (the FxPay deployment) is required on ${network}: Local402's verified one is CA6Z4E55YN6LZEXBQPFCWUIMUJGAV42RLHYWWZ6SNIP4E73R2I2PUGHD, or deploy your own (docs/facilitator.md).`);
|
|
52
|
+
}
|
|
53
|
+
const sendAssets = list(env.SEND_ASSETS);
|
|
54
|
+
for (const asset of sendAssets) {
|
|
55
|
+
if (!StrKey.isValidContract(asset))
|
|
56
|
+
invalid(`SEND_ASSETS must list token contract addresses (C…), got ${shown(asset)}.`);
|
|
57
|
+
}
|
|
58
|
+
const rpcUrl = env.RPC_URL?.trim() || fx.rpcUrl;
|
|
59
|
+
// Not repeated: an RPC URL can carry an API key.
|
|
60
|
+
if (rpcUrl && !/^https?:\/\/[^\s]+$/.test(rpcUrl))
|
|
61
|
+
invalid("RPC_URL must be an http(s) URL.");
|
|
62
|
+
// On mainnet every settlement spends real fees, so this instance only settles for sellers it names.
|
|
63
|
+
// `PAY_TO` is the demo seller that shares one process with the facilitator on the Local402 deployment.
|
|
64
|
+
// An empty PAY_TO_ALLOWLIST (as `.env.example` ships it) counts as unset, not as "any seller".
|
|
65
|
+
const payToAllowlist = list(env.PAY_TO_ALLOWLIST?.trim() || (mainnet ? env.PAY_TO : ""));
|
|
66
|
+
for (const payTo of payToAllowlist) {
|
|
67
|
+
if (!StrKey.isValidEd25519PublicKey(payTo) && !StrKey.isValidContract(payTo)) {
|
|
68
|
+
invalid(`PAY_TO_ALLOWLIST must list seller addresses (G… or C…), got ${shown(payTo)}.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (mainnet && !payToAllowlist.length) {
|
|
72
|
+
invalid("PAY_TO_ALLOWLIST is required on stellar:pubnet: every settlement spends this facilitator's XLM, so it only settles for the sellers you list (G…, comma-separated).");
|
|
73
|
+
}
|
|
74
|
+
// The caller writes the requirements, token included, so without this list a token anyone can mint for free
|
|
75
|
+
// would meet MIN_AMOUNT and an allowlisted seller's "payments" in it would spend this instance's fees.
|
|
76
|
+
const assetAllowlist = list(env.ASSET_ALLOWLIST?.trim() || (mainnet ? [FX_MAINNET.usdc.contract, FX_MAINNET.xlm, FX_MAINNET.eurc.contract].join(",") : ""));
|
|
77
|
+
for (const asset of assetAllowlist) {
|
|
78
|
+
if (!StrKey.isValidContract(asset))
|
|
79
|
+
invalid(`ASSET_ALLOWLIST must list token contract addresses (C…), got ${shown(asset)}.`);
|
|
80
|
+
}
|
|
81
|
+
const minAmountRaw = env.MIN_AMOUNT?.trim() || (mainnet ? "100000" : "0");
|
|
82
|
+
if (!/^\d+$/.test(minAmountRaw))
|
|
83
|
+
invalid(`MIN_AMOUNT must be a whole number of token units (100000 is 0.01 USDC), got ${shown(minAmountRaw)}.`);
|
|
84
|
+
const minFeeBalanceXlm = Number(env.MIN_FEE_BALANCE_XLM?.trim() || "2");
|
|
85
|
+
if (!Number.isFinite(minFeeBalanceXlm) || minFeeBalanceXlm < 0)
|
|
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();
|
|
90
|
+
return {
|
|
91
|
+
network,
|
|
92
|
+
secrets,
|
|
93
|
+
fxContract: fx.fxContract,
|
|
94
|
+
sendAssets: sendAssets.length ? sendAssets : [fx.xlm, fx.eurc],
|
|
95
|
+
rpcUrl,
|
|
96
|
+
port: whole(env, "PORT", 4022, 1, 65_535),
|
|
97
|
+
payToAllowlist,
|
|
98
|
+
assetAllowlist,
|
|
99
|
+
minAmount: BigInt(minAmountRaw),
|
|
100
|
+
limits: { verify: whole(env, "VERIFY_RATE_LIMIT", 60, 1), settle: whole(env, "SETTLE_RATE_LIMIT", 20, 1) },
|
|
101
|
+
minFeeBalanceXlm,
|
|
102
|
+
trustProxy: whole(env, "TRUST_PROXY", 0, 0, 10),
|
|
103
|
+
catalogRedis: redisUrl && redisToken ? { url: redisUrl, token: redisToken } : undefined,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Express } from "express";
|
|
2
|
+
import type { FacilitatorConfig } from "./config.js";
|
|
3
|
+
import { type Readiness, type ReadinessProbe } from "./ready.js";
|
|
4
|
+
/** Per-client request caps per minute, counted separately for each route. */
|
|
5
|
+
export declare class RateLimiter<Route extends string> {
|
|
6
|
+
private readonly limits;
|
|
7
|
+
private readonly now;
|
|
8
|
+
private readonly windows;
|
|
9
|
+
constructor(limits: Record<Route, number>, now?: () => number);
|
|
10
|
+
allow(route: Route, client: string | undefined): boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Why this facilitator refuses to handle the requirements, if it does. */
|
|
13
|
+
export declare function refusal(body: unknown, config: Pick<FacilitatorConfig, "network" | "payToAllowlist" | "assetAllowlist" | "minAmount">): string | undefined;
|
|
14
|
+
export interface Facilitator {
|
|
15
|
+
app: Express;
|
|
16
|
+
/** The fee-paying accounts' public keys, in the order of `FACILITATOR_PRIVATE_KEYS`. */
|
|
17
|
+
feePayers: string[];
|
|
18
|
+
/** Runs the `/ready` checks now, without the cache. */
|
|
19
|
+
ready(): Promise<Readiness>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The Local402 facilitator as an Express app: `exact` and `exact-fx` on one Stellar network, the x402 Bazaar
|
|
23
|
+
* catalog, `/health` and `/ready`. Build the config with `facilitatorConfig`; `probe` replaces the RPC in tests.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createFacilitator(config: FacilitatorConfig, options?: {
|
|
26
|
+
probe?: ReadinessProbe;
|
|
27
|
+
}): Facilitator;
|
|
28
|
+
/** The startup line: what this instance settles, without any key. */
|
|
29
|
+
export declare function describe(config: FacilitatorConfig, feePayers: string[]): string;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { x402Facilitator } from "@x402/core/facilitator";
|
|
3
|
+
import { BAZAAR, extractDiscoveryInfo } from "@x402/extensions/bazaar";
|
|
4
|
+
import { createEd25519Signer, getRpcClient } from "@x402/stellar";
|
|
5
|
+
import { ExactStellarScheme } from "@x402/stellar/exact/facilitator";
|
|
6
|
+
import { ChannelPool, ExactFxFacilitatorScheme, feeBumpSigner } from "local402-fx";
|
|
7
|
+
import { readiness, rpcProbe } from "./ready.js";
|
|
8
|
+
/** How long `/ready` reuses its last answer, so a host polling it every few seconds does not hammer the RPC. */
|
|
9
|
+
const READY_CACHE_MS = 15_000;
|
|
10
|
+
/** Per-client request caps per minute, counted separately for each route. */
|
|
11
|
+
export class RateLimiter {
|
|
12
|
+
limits;
|
|
13
|
+
now;
|
|
14
|
+
windows = new Map();
|
|
15
|
+
constructor(limits, now = Date.now) {
|
|
16
|
+
this.limits = limits;
|
|
17
|
+
this.now = now;
|
|
18
|
+
}
|
|
19
|
+
allow(route, client) {
|
|
20
|
+
const minute = Math.floor(this.now() / 60_000);
|
|
21
|
+
const key = `${route}:${client}`;
|
|
22
|
+
const window = this.windows.get(key);
|
|
23
|
+
if (window?.minute === minute) {
|
|
24
|
+
window.count++;
|
|
25
|
+
return window.count <= this.limits[route];
|
|
26
|
+
}
|
|
27
|
+
// Drop the entries that expired, not the whole map: clearing it handed every client already over the
|
|
28
|
+
// cap a fresh allowance, which is exactly what an attacker filling the map with fake IPs would want.
|
|
29
|
+
if (this.windows.size > 1_000) {
|
|
30
|
+
for (const [other, seen] of this.windows)
|
|
31
|
+
if (seen.minute !== minute)
|
|
32
|
+
this.windows.delete(other);
|
|
33
|
+
}
|
|
34
|
+
this.windows.set(key, { minute, count: 1 });
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Why this facilitator refuses to handle the requirements, if it does. */
|
|
39
|
+
export function refusal(body, config) {
|
|
40
|
+
// `x402Facilitator.verify/settle` does not validate the body's shape, and neither did this: a payload
|
|
41
|
+
// of the wrong type reached the scheme and came back as an opaque crash instead of a reason.
|
|
42
|
+
if (!body || typeof body !== "object")
|
|
43
|
+
return "invalid_request_body";
|
|
44
|
+
const { paymentPayload, paymentRequirements } = body;
|
|
45
|
+
if (!paymentPayload || typeof paymentPayload !== "object")
|
|
46
|
+
return "invalid_payment_payload";
|
|
47
|
+
if (!paymentRequirements || typeof paymentRequirements !== "object")
|
|
48
|
+
return "invalid_payment_requirements";
|
|
49
|
+
const { scheme, network, payTo, asset, amount } = paymentRequirements;
|
|
50
|
+
if (typeof scheme !== "string" || typeof network !== "string" || typeof payTo !== "string" || typeof asset !== "string") {
|
|
51
|
+
return "invalid_payment_requirements";
|
|
52
|
+
}
|
|
53
|
+
if (network !== config.network)
|
|
54
|
+
return "unsupported_network";
|
|
55
|
+
if (config.payToAllowlist.length && !config.payToAllowlist.includes(payTo))
|
|
56
|
+
return "pay_to_not_allowed";
|
|
57
|
+
if (config.assetAllowlist.length && !config.assetAllowlist.includes(asset))
|
|
58
|
+
return "asset_not_allowed";
|
|
59
|
+
if (!/^\d+$/.test(String(amount)))
|
|
60
|
+
return "invalid_payment_requirements";
|
|
61
|
+
if (config.minAmount && BigInt(String(amount)) < config.minAmount)
|
|
62
|
+
return "amount_below_minimum";
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The Local402 facilitator as an Express app: `exact` and `exact-fx` on one Stellar network, the x402 Bazaar
|
|
67
|
+
* catalog, `/health` and `/ready`. Build the config with `facilitatorConfig`; `probe` replaces the RPC in tests.
|
|
68
|
+
*/
|
|
69
|
+
export function createFacilitator(config, options = {}) {
|
|
70
|
+
const { network } = config;
|
|
71
|
+
const rpcConfig = config.rpcUrl ? { url: config.rpcUrl } : undefined;
|
|
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.
|
|
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
|
+
};
|
|
88
|
+
// Several fee-paying accounts ("channels") let settlements run in parallel without sequence number clashes.
|
|
89
|
+
const signers = config.secrets.map((secret) => createEd25519Signer(secret, network));
|
|
90
|
+
const feePayers = signers.map((signer) => signer.address);
|
|
91
|
+
const channels = new ChannelPool(feePayers);
|
|
92
|
+
const facilitator = new x402Facilitator()
|
|
93
|
+
.register(network, new ExactStellarScheme(signers, {
|
|
94
|
+
rpcConfig,
|
|
95
|
+
selectSigner: channels.select,
|
|
96
|
+
feeBumpSigner: feeBumpSigner(signers[0], { network, rpcConfig }),
|
|
97
|
+
}))
|
|
98
|
+
.register(network, new ExactFxFacilitatorScheme(signers, { fxContract: config.fxContract, sendAssets: config.sendAssets, rpcConfig, selectSigner: channels.select }))
|
|
99
|
+
.registerExtension(BAZAAR)
|
|
100
|
+
.onAfterSettle(async ({ paymentPayload, requirements, result }) => {
|
|
101
|
+
const discovered = result.success && extractDiscoveryInfo(paymentPayload, requirements);
|
|
102
|
+
if (!discovered)
|
|
103
|
+
return;
|
|
104
|
+
const { resourceUrl, x402Version, discoveryInfo, ...metadata } = discovered;
|
|
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 = {
|
|
112
|
+
...metadata,
|
|
113
|
+
resource: resourceUrl,
|
|
114
|
+
type: discoveryInfo.input.type,
|
|
115
|
+
x402Version,
|
|
116
|
+
accepts: [...accepts, requirements],
|
|
117
|
+
lastUpdated: new Date().toISOString(),
|
|
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
|
+
}
|
|
124
|
+
});
|
|
125
|
+
// Verification simulates on RPC and settlement spends fees, so both are bounded, each with its own cap.
|
|
126
|
+
const limiter = new RateLimiter(config.limits);
|
|
127
|
+
const probe = options.probe ?? rpcProbe(getRpcClient(network, rpcConfig));
|
|
128
|
+
const ready = () => readiness(probe, { network, feePayers, fxContract: config.fxContract, minFeeBalanceXlm: config.minFeeBalanceXlm });
|
|
129
|
+
let lastReady;
|
|
130
|
+
const app = express();
|
|
131
|
+
// By default exactly one proxy (Vercel's edge, or a host's load balancer). With `true`, express takes the
|
|
132
|
+
// leftmost `X-Forwarded-For` entry, which the client writes itself: every caller could pick its own
|
|
133
|
+
// `req.ip` and get an unlimited number of rate-limit buckets.
|
|
134
|
+
app.set("trust proxy", config.trustProxy);
|
|
135
|
+
app.use(express.json({ limit: "64kb" }));
|
|
136
|
+
// Liveness: the process is up and its configuration was valid. Says nothing about the network.
|
|
137
|
+
app.get("/health", (_req, res) => {
|
|
138
|
+
res.json({ status: "ok", network });
|
|
139
|
+
});
|
|
140
|
+
// Readiness: this instance can settle now. 503 tells the host to stop routing payments here.
|
|
141
|
+
app.get("/ready", async (_req, res) => {
|
|
142
|
+
if (!lastReady || Date.now() - lastReady.at > READY_CACHE_MS)
|
|
143
|
+
lastReady = { at: Date.now(), result: ready() };
|
|
144
|
+
const result = await lastReady.result;
|
|
145
|
+
res.status(result.ready ? 200 : 503).json(result);
|
|
146
|
+
});
|
|
147
|
+
app.get("/supported", (_req, res) => {
|
|
148
|
+
res.json(facilitator.getSupported());
|
|
149
|
+
});
|
|
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);
|
|
161
|
+
const limit = Number(req.query.limit ?? 100);
|
|
162
|
+
const offset = Number(req.query.offset ?? 0);
|
|
163
|
+
res.json({ x402Version: 2, items: items.slice(offset, offset + limit), pagination: { limit, offset, total: items.length } });
|
|
164
|
+
});
|
|
165
|
+
app.post("/verify", async (req, res) => {
|
|
166
|
+
if (!limiter.allow("verify", req.ip)) {
|
|
167
|
+
res.status(429).json({ isValid: false, invalidReason: "rate_limited" });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const { paymentPayload, paymentRequirements } = req.body ?? {};
|
|
171
|
+
const refused = refusal(req.body, config);
|
|
172
|
+
if (refused) {
|
|
173
|
+
res.json({ isValid: false, invalidReason: refused });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
let result;
|
|
177
|
+
try {
|
|
178
|
+
result = await facilitator.verify(paymentPayload, paymentRequirements);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
// Same as settle: a reason the seller can log, not Express's HTML error page with a stack trace.
|
|
182
|
+
res.status(503).json({ isValid: false, invalidReason: String(error) });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
console.log(`verify ${paymentRequirements?.scheme}: ${result.isValid ? "valid" : result.invalidReason}`);
|
|
186
|
+
res.json(result);
|
|
187
|
+
});
|
|
188
|
+
app.post("/settle", async (req, res) => {
|
|
189
|
+
const { paymentPayload, paymentRequirements } = req.body ?? {};
|
|
190
|
+
if (!limiter.allow("settle", req.ip)) {
|
|
191
|
+
res.status(429).json({ success: false, network: paymentRequirements?.network, transaction: "", errorReason: "rate_limited" });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const refused = refusal(req.body, config);
|
|
195
|
+
if (refused) {
|
|
196
|
+
res.json({ success: false, network: paymentRequirements?.network, transaction: "", errorReason: refused });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const settle = () => channels.run(() => facilitator.settle(paymentPayload, paymentRequirements));
|
|
200
|
+
let result;
|
|
201
|
+
try {
|
|
202
|
+
result = await settle();
|
|
203
|
+
// Rejected before reaching the ledger (e.g. another server instance used the same account's sequence number):
|
|
204
|
+
// nothing was spent, and the payer's authorization is still valid, so try once more on the next account.
|
|
205
|
+
if (!result.success && !result.transaction && /submission_failed/.test(result.errorReason ?? "")) {
|
|
206
|
+
result = await settle();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
res.status(503).json({ success: false, network: paymentRequirements?.network, transaction: "", errorReason: String(error) });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
console.log(`settle ${paymentRequirements?.scheme}: ${result.success ? result.transaction : result.errorReason}`);
|
|
214
|
+
res.json(result);
|
|
215
|
+
});
|
|
216
|
+
return { app, feePayers, ready };
|
|
217
|
+
}
|
|
218
|
+
/** The startup line: what this instance settles, without any key. */
|
|
219
|
+
export function describe(config, feePayers) {
|
|
220
|
+
const { network, payToAllowlist, assetAllowlist, minAmount, limits } = config;
|
|
221
|
+
return (`facilitator on ${network}: ${feePayers.length} fee payer(s), ` +
|
|
222
|
+
`payTo ${payToAllowlist.length ? `restricted to ${payToAllowlist.length} seller(s)` : "unrestricted"}, ` +
|
|
223
|
+
`asset ${assetAllowlist.length ? `restricted to ${assetAllowlist.length} token(s)` : "unrestricted"}, ` +
|
|
224
|
+
`minimum amount ${minAmount || "none"}, ` +
|
|
225
|
+
`limits ${limits.verify} verify and ${limits.settle} settle per client per minute`);
|
|
226
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { facilitatorConfig, type FacilitatorConfig } from "./config.js";
|
|
2
|
+
export { createFacilitator, describe, RateLimiter, refusal, type Facilitator } from "./facilitator.js";
|
|
3
|
+
export { readiness, rpcProbe, type Check, type Readiness, type ReadinessProbe } from "./ready.js";
|
|
4
|
+
export { fundOnTestnet, newKeys, writeKeys } from "./keys.js";
|
|
5
|
+
export { Local402Error, type Local402ErrorCode } from "local402-fx";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { facilitatorConfig } from "./config.js";
|
|
2
|
+
export { createFacilitator, describe, RateLimiter, refusal } from "./facilitator.js";
|
|
3
|
+
export { readiness, rpcProbe } from "./ready.js";
|
|
4
|
+
export { fundOnTestnet, newKeys, writeKeys } from "./keys.js";
|
|
5
|
+
export { Local402Error } from "local402-fx";
|
package/dist/keys.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Keypair } from "@stellar/stellar-sdk";
|
|
2
|
+
/** `count` fresh fee-paying accounts for one instance. Each instance gets its own: shared keys collide on sequence numbers. */
|
|
3
|
+
export declare function newKeys(count: number): Keypair[];
|
|
4
|
+
/**
|
|
5
|
+
* Writes `FACILITATOR_PRIVATE_KEYS` into an env file, creating it if needed. It never replaces keys already there,
|
|
6
|
+
* since they may hold funds; an empty `FACILITATOR_PRIVATE_KEYS=` line (as `.env.example` ships it) is filled in.
|
|
7
|
+
*/
|
|
8
|
+
export declare function writeKeys(file: string, secrets: string[]): void;
|
|
9
|
+
/** Creates and funds `address` on testnet with friendbot's 10,000 test XLM. An account that already exists is fine. */
|
|
10
|
+
export declare function fundOnTestnet(address: string, fetchFn?: typeof fetch): Promise<void>;
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { Keypair, StrKey } from "@stellar/stellar-sdk";
|
|
3
|
+
import { Local402Error } from "local402-fx";
|
|
4
|
+
/** `count` fresh fee-paying accounts for one instance. Each instance gets its own: shared keys collide on sequence numbers. */
|
|
5
|
+
export function newKeys(count) {
|
|
6
|
+
if (!Number.isInteger(count) || count < 1 || count > 20) {
|
|
7
|
+
throw new Local402Error("INVALID_CONFIG", `The number of keys must be a whole number from 1 to 20, got ${count}.`);
|
|
8
|
+
}
|
|
9
|
+
return Array.from({ length: count }, () => Keypair.random());
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Writes `FACILITATOR_PRIVATE_KEYS` into an env file, creating it if needed. It never replaces keys already there,
|
|
13
|
+
* since they may hold funds; an empty `FACILITATOR_PRIVATE_KEYS=` line (as `.env.example` ships it) is filled in.
|
|
14
|
+
*/
|
|
15
|
+
export function writeKeys(file, secrets) {
|
|
16
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
17
|
+
const existing = /^FACILITATOR_PRIVATE_KEYS?=(.*)$/gm;
|
|
18
|
+
for (const [, value] of text.matchAll(existing)) {
|
|
19
|
+
if (value.split(",").some((key) => StrKey.isValidEd25519SecretSeed(key.trim()))) {
|
|
20
|
+
throw new Local402Error("INVALID_CONFIG", `${file} already has facilitator keys. Move them out first: they may hold funds.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const entry = `FACILITATOR_PRIVATE_KEYS=${secrets.join(",")}`;
|
|
24
|
+
const line = /^FACILITATOR_PRIVATE_KEYS=.*$/m;
|
|
25
|
+
const next = line.test(text) ? text.replace(line, entry) : `${text}${text && !text.endsWith("\n") ? "\n" : ""}${entry}\n`;
|
|
26
|
+
writeFileSync(file, next, { mode: 0o600 });
|
|
27
|
+
}
|
|
28
|
+
/** Creates and funds `address` on testnet with friendbot's 10,000 test XLM. An account that already exists is fine. */
|
|
29
|
+
export async function fundOnTestnet(address, fetchFn = fetch) {
|
|
30
|
+
const response = await fetchFn(`https://friendbot.stellar.org/?addr=${address}`, { signal: AbortSignal.timeout(30_000) });
|
|
31
|
+
if (response.ok)
|
|
32
|
+
return;
|
|
33
|
+
const body = await response.text();
|
|
34
|
+
if (/already.?exist/i.test(body))
|
|
35
|
+
return;
|
|
36
|
+
throw new Error(`friendbot answered ${response.status} for ${address}`);
|
|
37
|
+
}
|
package/dist/ready.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { rpc } from "@stellar/stellar-sdk";
|
|
2
|
+
/** What `/ready` asks the network. `rpcProbe` answers it over Soroban RPC; tests pass their own. */
|
|
3
|
+
export interface ReadinessProbe {
|
|
4
|
+
rpcHealthy(): Promise<boolean>;
|
|
5
|
+
/** Native balance in stroops, or undefined when the account does not exist on the network. */
|
|
6
|
+
balanceStroops(address: string): Promise<bigint | undefined>;
|
|
7
|
+
contractExists(contract: string): Promise<boolean>;
|
|
8
|
+
}
|
|
9
|
+
export interface Check {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Readiness {
|
|
14
|
+
ready: boolean;
|
|
15
|
+
network: string;
|
|
16
|
+
rpc: Check;
|
|
17
|
+
feePayers: (Check & {
|
|
18
|
+
address: string;
|
|
19
|
+
xlm?: string;
|
|
20
|
+
})[];
|
|
21
|
+
fxContract: Check & {
|
|
22
|
+
address: string;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export declare function rpcProbe(server: rpc.Server): ReadinessProbe;
|
|
26
|
+
/**
|
|
27
|
+
* Whether this instance can settle right now: the RPC answers, every fee payer exists and holds at least
|
|
28
|
+
* `minFeeBalanceXlm`, and the FxPay contract is still on the ledger (its instance expires unless its TTL is extended).
|
|
29
|
+
*/
|
|
30
|
+
export declare function readiness(probe: ReadinessProbe, options: {
|
|
31
|
+
network: string;
|
|
32
|
+
feePayers: string[];
|
|
33
|
+
fxContract: string;
|
|
34
|
+
minFeeBalanceXlm: number;
|
|
35
|
+
}): Promise<Readiness>;
|
package/dist/ready.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { rpc, xdr } from "@stellar/stellar-sdk";
|
|
2
|
+
export function rpcProbe(server) {
|
|
3
|
+
return {
|
|
4
|
+
rpcHealthy: async () => (await server.getHealth()).status === "healthy",
|
|
5
|
+
async balanceStroops(address) {
|
|
6
|
+
try {
|
|
7
|
+
return BigInt((await server.getAccountEntry(address)).balance().toString());
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
if (/not found/i.test(String(error)))
|
|
11
|
+
return undefined;
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
async contractExists(contract) {
|
|
16
|
+
try {
|
|
17
|
+
await server.getContractData(contract, xdr.ScVal.scvLedgerKeyContractInstance(), rpc.Durability.Persistent);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (/not found/i.test(String(error)))
|
|
22
|
+
return false;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const STROOPS_PER_XLM = 10000000n;
|
|
29
|
+
const xlm = (stroops) => `${stroops / STROOPS_PER_XLM}.${String(stroops % STROOPS_PER_XLM).padStart(7, "0")}`.replace(/\.?0+$/, "");
|
|
30
|
+
const reason = (error) => (error instanceof Error ? error.message : String(error)).split("\n")[0].slice(0, 200);
|
|
31
|
+
/**
|
|
32
|
+
* Whether this instance can settle right now: the RPC answers, every fee payer exists and holds at least
|
|
33
|
+
* `minFeeBalanceXlm`, and the FxPay contract is still on the ledger (its instance expires unless its TTL is extended).
|
|
34
|
+
*/
|
|
35
|
+
export async function readiness(probe, options) {
|
|
36
|
+
const min = BigInt(Math.round(options.minFeeBalanceXlm * 1e7));
|
|
37
|
+
const [rpcCheck, feePayers, fxContract] = await Promise.all([
|
|
38
|
+
probe.rpcHealthy().then((ok) => (ok ? { ok } : { ok, error: "RPC reports it is not healthy" }), (error) => ({ ok: false, error: reason(error) })),
|
|
39
|
+
Promise.all(options.feePayers.map(async (address) => {
|
|
40
|
+
try {
|
|
41
|
+
const balance = await probe.balanceStroops(address);
|
|
42
|
+
if (balance === undefined)
|
|
43
|
+
return { address, ok: false, error: "account does not exist: fund it with XLM" };
|
|
44
|
+
const ok = balance >= min;
|
|
45
|
+
return { address, ok, xlm: xlm(balance), ...(ok ? {} : { error: `below the ${options.minFeeBalanceXlm} XLM minimum: top it up` }) };
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
return { address, ok: false, error: reason(error) };
|
|
49
|
+
}
|
|
50
|
+
})),
|
|
51
|
+
probe.contractExists(options.fxContract).then((ok) => ({ address: options.fxContract, ok, ...(ok ? {} : { error: "FxPay contract not found: wrong FX_CONTRACT or its TTL expired" }) }), (error) => ({ address: options.fxContract, ok: false, error: reason(error) })),
|
|
52
|
+
]);
|
|
53
|
+
return {
|
|
54
|
+
ready: rpcCheck.ok && feePayers.every((payer) => payer.ok) && fxContract.ok,
|
|
55
|
+
network: options.network,
|
|
56
|
+
rpc: rpcCheck,
|
|
57
|
+
feePayers,
|
|
58
|
+
fxContract,
|
|
59
|
+
};
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "local402-facilitator",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"x402",
|
|
7
|
+
"stellar",
|
|
8
|
+
"soroban",
|
|
9
|
+
"payments",
|
|
10
|
+
"usdc",
|
|
11
|
+
"oracle",
|
|
12
|
+
"mcp"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/DiegoPoveda01/local402.git",
|
|
18
|
+
"directory": "packages/facilitator"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"bin": {
|
|
29
|
+
"local402-facilitator": "dist/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"local402-fx": "^1.0.1",
|
|
39
|
+
"@stellar/stellar-sdk": "16.4.1",
|
|
40
|
+
"@x402/core": "2.25.0",
|
|
41
|
+
"@x402/extensions": "2.25.0",
|
|
42
|
+
"@x402/stellar": "2.25.0",
|
|
43
|
+
"express": "^5.1.0"
|
|
44
|
+
}
|
|
45
|
+
}
|