xytara 3.1.1 → 3.1.3

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/.env.example CHANGED
@@ -1,8 +1,18 @@
1
1
  XYTARA_HOST=127.0.0.1
2
2
  XYTARA_PORT=4320
3
3
  XYTARA_BASE_URL=http://127.0.0.1:4320
4
- # local_signed is the default; set presence only for local demos (accepts any non-empty payment header)
4
+ # local_signed is the default; presence (accepts any non-empty payment header) is for local demos;
5
+ # x402_facilitator is trustless on-chain pay-per-call (see REAL_PAYMENT_SETUP.md and the vars below)
5
6
  XYTARA_PAYMENT_VERIFICATION_MODE=local_signed
7
+ # used only when mode is x402_facilitator:
8
+ XYTARA_X402_ACCEPTS_JSON=[]
9
+ XYTARA_X402_FACILITATOR_URL=https://x402.org/facilitator
10
+ XYTARA_X402_FACILITATOR_HEADERS_JSON={}
11
+ XYTARA_X402_FACILITATOR_TIMEOUT_MS=15000
12
+ XYTARA_X402_SETTLE=true
13
+ # credit units a single self-serve /x402/credits/topup grants (default 1000);
14
+ # an accepts entry may override per-payment via its extra.credits
15
+ XYTARA_X402_TOPUP_UNITS=1000
6
16
  XYTARA_PAYMENT_MAX_AGE_SECONDS=300
7
17
  XYTARA_WALLET_REGISTRY_JSON=[]
8
18
  XYTARA_TREASURY_DESTINATION_ID=
package/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  `xytara` is the runtime and payment layer for the public two-repo stack.
4
4
 
5
+ > New to the code? Read [`ARCHITECTURE.md`](ARCHITECTURE.md) first. It separates
6
+ > the small execution-and-settlement engine (~9,300 lines) from the large
7
+ > machine-readable breadth catalog, so the real runtime is legible in one sitting.
8
+
5
9
  It exists to let agents, developers, and systems:
6
10
 
7
11
  - discover tasks and workflows
@@ -4,7 +4,7 @@ This guide explains how to move `xytara` from local placeholder payment acceptan
4
4
 
5
5
  ## What Changed
6
6
 
7
- `xytara` supports two payment-acceptance modes. `local_signed` is the default; `presence` has to be opted into explicitly.
7
+ `xytara` supports three payment-acceptance modes. `local_signed` is the default; `presence` and `x402_facilitator` are opt-in.
8
8
 
9
9
  - `presence`
10
10
  - development/demo mode only, enabled with `XYTARA_PAYMENT_VERIFICATION_MODE=presence`
@@ -17,6 +17,54 @@ This guide explains how to move `xytara` from local placeholder payment acceptan
17
17
  - quote replay and nonce replay are blocked
18
18
  - accepted payments are routed into your configured merchant treasury destination posture
19
19
 
20
+ - `x402_facilitator`
21
+ - trustless, on-chain, pay-per-call, the same mechanism proven live on the MCP executor
22
+ - the `/x402/commands/execute` and `/x402/mcp/tools/invoke` routes return a `402` that
23
+ advertises the configured `accepts` (network, asset, amount, payTo) in the
24
+ `PAYMENT-REQUIRED` header; a buyer signs a gasless x402 USDC authorization (EIP-3009)
25
+ - on the paid retry, a facilitator verifies the payment and settles it on-chain before any
26
+ work runs; the response carries a `PAYMENT-RESPONSE` settlement receipt
27
+ - fails closed: if the facilitator is unreachable or the payment does not settle, nothing runs
28
+ - the buyer needs no native gas token; the facilitator broadcasts the transfer
29
+ - enable with `XYTARA_PAYMENT_VERIFICATION_MODE=x402_facilitator` and the x402 env below:
30
+
31
+ ```env
32
+ XYTARA_PAYMENT_VERIFICATION_MODE=x402_facilitator
33
+ # what buyers pay, advertised in the 402 (Base Sepolia test USDC shown):
34
+ XYTARA_X402_ACCEPTS_JSON=[{"scheme":"exact","network":"eip155:84532","asset":"0x036CbD53842c5426634e7929541eC2318f3dCF7e","amount":"1000","payTo":"<your-evm-address>","extra":{"name":"USDC","version":"2"},"maxTimeoutSeconds":60}]
35
+ XYTARA_X402_FACILITATOR_URL=https://x402.org/facilitator
36
+ # optional:
37
+ XYTARA_X402_FACILITATOR_HEADERS_JSON={}
38
+ XYTARA_X402_FACILITATOR_TIMEOUT_MS=15000
39
+ XYTARA_X402_SETTLE=true
40
+ ```
41
+
42
+ Confirm the facilitator settles every network you advertise, and verify the mode end to end
43
+ against a mock facilitator with `npm run verify:x402-facilitator`. The payment a buyer signs
44
+ is identical to the one in `services/mcp-execution-target/buyer-demo/` (an EIP-3009 USDC
45
+ authorization in a `PAYMENT-SIGNATURE` header); only the request body differs, since the main
46
+ runtime takes a command-execute body rather than an MCP JSON-RPC call.
47
+
48
+ ### Self-serve credits top-up
49
+
50
+ Beyond paying per call, a buyer can convert a single x402 payment into spendable
51
+ runtime credits with no operator grant, via `POST /x402/credits/topup`:
52
+
53
+ - without a `PAYMENT-SIGNATURE`, it returns `402` advertising the same `accepts` plus
54
+ `credits_per_topup` (how many credit units one payment buys)
55
+ - with a valid payment, the facilitator settles it on-chain and the runtime mints the
56
+ credits to the account, returning the grant, the new balance, and a `PAYMENT-RESPONSE`
57
+ settlement receipt
58
+ - credits land on `account_id` from the body, falling back to the paying wallet address
59
+ - replays are idempotent: the payment nonce keys the grant, so re-sending the same settled
60
+ payment never mints twice
61
+
62
+ Each top-up grants `XYTARA_X402_TOPUP_UNITS` credit units (default `1000`), unless the matched
63
+ `accepts` entry sets its own `extra.credits`. The route is live only in `x402_facilitator`
64
+ mode with `XYTARA_X402_ACCEPTS_JSON` set, and credits persist only when runtime state
65
+ persistence (`XYTARA_SUPABASE_*` or `XYTARA_STATE_FILE`) is configured. A runnable buyer is in
66
+ `services/mcp-execution-target/buyer-demo/x402-credits-topup-demo.js`.
67
+
20
68
  ## Minimum Production-Oriented Configuration
21
69
 
22
70
  Set these environment variables:
package/RELEASE_NOTES.md CHANGED
@@ -1,17 +1,16 @@
1
- `xytara` 3.1.1 is the synchronized security and stability release for the live runtime, payment, execution, delivery, and public first-contact line.
1
+ `xytara` 3.1.3 makes runtime credits self-serve: a buyer can turn a single x402 payment into spendable credit with no operator grant.
2
2
 
3
- This release closes the authorization and payment-verification gaps found in the post-launch review while keeping the public runtime truth models, catalog, first-run path, and naxytra contract surfaces unchanged.
3
+ This builds on the `x402_facilitator` on-chain settlement mode. Until now, credits could only be minted by an operator; a paying agent still needed someone to grant it a balance. This release closes that gap so the pay-and-use loop is fully autonomous.
4
4
 
5
5
  Highlights:
6
6
 
7
- - operator write surfaces now fail closed: credit minting, allocation reversal, rail-credit application, budgets, agents, economics receipts, job operator actions, participant admission, the operator-shell admin pack, and agent-account issuance all require `Authorization: Bearer` with `XYTARA_ACCOUNT_AUTH_BEARER_TOKEN`, and return `operator_auth_not_configured` instead of opening up when the token is unset
8
- - credit consumption requires either the operator bearer or a valid delegated spend credential
9
- - bearer and credit-bridge token comparisons use constant-time comparison, and the business cockpit no longer accepts query-string tokens
10
- - `XYTARA_PAYMENT_VERIFICATION_MODE` now defaults to `local_signed`; `presence` must be opted into explicitly and logs a startup warning
11
- - the MCP execution target verifies and settles x402 payments through a facilitator before serving paid methods, returns `PAYMENT-RESPONSE` on paid responses, rejects malformed or unadvertised payment payloads, and fails closed when the facilitator is unreachable; `initialize`, `ping`, and `tools/list` stay free so MCP clients can connect
12
- - the credit allocation ledger is no longer truncated, so account balances derived from allocation events stay correct beyond 200 events
13
- - the client binds every paid quote to its account and uses collision-free payment nonces, which strict verification previously rejected as account mismatches or replayed nonces
14
- - `/health` reports restore status honestly and a new `/ready` route returns `503` when runtime state restore or the last snapshot flush failed
15
- - the service verifier now covers unauthenticated mint, wrong operator tokens, credit overdraw, forged and unsigned checkout webhooks, replayed payment nonces, forged payment signatures, unregistered wallets, and query-token rejection; the MCP executor verifier runs against a mock facilitator
16
- - the MCP executor publish workflow skips publishing when the registry already carries the current version
17
- - bundled examples configure a local demo wallet and demo operator tokens explicitly instead of relying on open defaults
7
+ - adds `POST /x402/credits/topup`: pay once with x402 and the facilitator settles the payment on-chain, then the runtime mints the credits to the account and returns the grant, the new balance, and a `PAYMENT-RESPONSE` settlement receipt
8
+ - without a payment the route returns a `402` advertising the same `accepts` plus `credits_per_topup`, so a buyer discovers the price and the credit it buys before paying
9
+ - credits land on `account_id` from the request body, falling back to the paying wallet address
10
+ - idempotent by the payment nonce: re-sending the same settled payment never mints credits twice
11
+ - each top-up grants `XYTARA_X402_TOPUP_UNITS` credit units (default `1000`), unless the matched `accepts` entry sets its own `extra.credits`
12
+ - live only in `x402_facilitator` mode with `accepts` configured; fails closed if the facilitator is unreachable or the payment does not settle
13
+ - extends `npm run verify:x402-facilitator` with the unpaid-challenge, paid-mint, and replay-idempotency cases for the new route
14
+ - adds a runnable buyer, `services/mcp-execution-target/buyer-demo/x402-credits-topup-demo.js`, and documents the route in `REAL_PAYMENT_SETUP.md`
15
+
16
+ No existing path changes: the per-call x402 routes, native lanes, and operator credit grants all behave exactly as before.
@@ -0,0 +1,30 @@
1
+ {
2
+ "handoff_bundle": {
3
+ "ok": true,
4
+ "handoff_kind": "interaction_result_package",
5
+ "result_package_ref": "result.spine.job.commitment",
6
+ "source_ref": "spine.job.commitment",
7
+ "committed_ref": "transaction.job.commitment",
8
+ "precommit_pack_ref": "precommit.job.commitment.readiness",
9
+ "transaction_id": "txn_c3db568e78ec",
10
+ "receipt_id": "rcpt_fc14a1bfa4b5",
11
+ "proof_ref": "proof_rcpt_fc14a1bfa4b5",
12
+ "payment_protocol": "x402",
13
+ "settlement_mode": "evm_payment",
14
+ "treasury_destination_id": "treasury_acct_demo",
15
+ "task_refs": [
16
+ "jobs.quote",
17
+ "admission.preview",
18
+ "jobs.reserve",
19
+ "jobs.commit"
20
+ ],
21
+ "delivery_count": 1,
22
+ "payment_ledger_count": 1,
23
+ "economic_consequence_handoff_bundle": null,
24
+ "seam_layers": [
25
+ "execution_consequence",
26
+ "payment_settlement_treasury",
27
+ "proof_handoff"
28
+ ]
29
+ }
30
+ }
@@ -311,9 +311,10 @@ async function executeCommandRequest(state, payload, headers, options) {
311
311
  };
312
312
  }
313
313
 
314
- const paymentVerification = verifyPaymentAcceptance(state, headers || {}, requiredHeaders, quote, {
314
+ const paymentVerification = await verifyPaymentAcceptance(state, headers || {}, requiredHeaders, quote, {
315
315
  payment_protocol: opts.paymentProtocol || (payload && payload.payment_protocol) || null,
316
- authorization_scheme: opts.authorizationScheme || null
316
+ authorization_scheme: opts.authorizationScheme || null,
317
+ resource_url: opts.resourceUrl || null
317
318
  });
318
319
  if (!paymentVerification.ok) {
319
320
  return {
@@ -321,6 +322,7 @@ async function executeCommandRequest(state, payload, headers, options) {
321
322
  payload: {
322
323
  ok: false,
323
324
  reason: paymentVerification.reason,
325
+ ...(paymentVerification.detail ? { detail: paymentVerification.detail } : {}),
324
326
  quote
325
327
  }
326
328
  };
@@ -345,7 +347,10 @@ async function executeCommandRequest(state, payload, headers, options) {
345
347
  payload: createCommandResult(state, payload, quote, {
346
348
  paymentVerification,
347
349
  externalExecution: externalExecutionOptions.externalExecution || null
348
- })
350
+ }),
351
+ ...(paymentVerification.payment_response_base64
352
+ ? { paymentResponse: paymentVerification.payment_response_base64 }
353
+ : {})
349
354
  };
350
355
  }
351
356
 
@@ -121,6 +121,7 @@ function loadPaymentConfig() {
121
121
  verification_mode_input: verificationModeInput,
122
122
  payment_max_age_seconds: Number.isFinite(maxAgeSeconds) && maxAgeSeconds > 0 ? maxAgeSeconds : 300,
123
123
  wallet_registry: loadWalletRegistry(),
124
+ x402: loadX402FacilitatorConfig(),
124
125
  treasury_destination_id: String(process.env.XYTARA_TREASURY_DESTINATION_ID || "").trim() || null,
125
126
  treasury_destination_kind: String(process.env.XYTARA_TREASURY_DESTINATION_KIND || "").trim() || null,
126
127
  treasury_account_id: String(process.env.XYTARA_TREASURY_ACCOUNT_ID || "").trim() || null,
@@ -129,6 +130,54 @@ function loadPaymentConfig() {
129
130
  };
130
131
  }
131
132
 
133
+ // Configuration for the x402_facilitator verification mode: the on-chain, trustless
134
+ // pay-per-call path. The advertised accepts tell a buyer how to pay; the facilitator
135
+ // verifies and settles the signed payment before any work runs.
136
+ function loadX402FacilitatorConfig() {
137
+ let accepts = [];
138
+ const rawAccepts = String(process.env.XYTARA_X402_ACCEPTS_JSON || "").trim();
139
+ if (rawAccepts) {
140
+ try {
141
+ const parsed = JSON.parse(rawAccepts);
142
+ if (Array.isArray(parsed)) {
143
+ accepts = parsed
144
+ .map((entry) => ({
145
+ scheme: String((entry && entry.scheme) || "exact").trim(),
146
+ network: String((entry && entry.network) || "").trim(),
147
+ asset: String((entry && entry.asset) || "").trim(),
148
+ amount: String((entry && entry.amount) || "").trim(),
149
+ payTo: String((entry && entry.payTo) || "").trim(),
150
+ ...(entry && entry.extra && typeof entry.extra === "object" ? { extra: entry.extra } : {}),
151
+ ...(Number(entry && entry.maxTimeoutSeconds) > 0 ? { maxTimeoutSeconds: Number(entry.maxTimeoutSeconds) } : {})
152
+ }))
153
+ .filter((entry) => entry.network && entry.asset && entry.amount && entry.payTo);
154
+ }
155
+ } catch (_) {
156
+ accepts = [];
157
+ }
158
+ }
159
+ let headers = {};
160
+ const rawHeaders = String(process.env.XYTARA_X402_FACILITATOR_HEADERS_JSON || "").trim();
161
+ if (rawHeaders) {
162
+ try {
163
+ const parsed = JSON.parse(rawHeaders);
164
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) headers = parsed;
165
+ } catch (_) {
166
+ headers = {};
167
+ }
168
+ }
169
+ return {
170
+ accepts,
171
+ facilitator_url: String(process.env.XYTARA_X402_FACILITATOR_URL || "https://x402.org/facilitator").trim().replace(/\/$/, ""),
172
+ facilitator_headers: headers,
173
+ facilitator_timeout_ms: Math.max(1000, Number(process.env.XYTARA_X402_FACILITATOR_TIMEOUT_MS || 15000) || 15000),
174
+ settle: String(process.env.XYTARA_X402_SETTLE || "true").trim().toLowerCase() !== "false",
175
+ // Credit units a single self-serve x402 top-up grants, unless the matched accepts
176
+ // entry carries its own `extra.credits`.
177
+ topup_units_default: Math.max(0, Number(process.env.XYTARA_X402_TOPUP_UNITS || 1000) || 0)
178
+ };
179
+ }
180
+
132
181
  function buildConfiguredTreasuryDestination(accountId, settlementMode) {
133
182
  const config = loadPaymentConfig();
134
183
  const normalizedSettlementMode = normalizeSettlementMode(settlementMode);
@@ -2,6 +2,12 @@
2
2
 
3
3
  const crypto = require("crypto");
4
4
  const { loadPaymentConfig } = require("./payment_config");
5
+ const {
6
+ createFacilitatorClient,
7
+ verifyX402Payment,
8
+ settleX402Payment,
9
+ encodePaymentResponse
10
+ } = require("./x402_facilitator");
5
11
  const { parseL402Authorization, verifyL402AccessToken } = require("./l402_payment_front");
6
12
  const { parseBoltAuthorization, verifyBoltAccessToken } = require("./bolt_payment_front");
7
13
  const { parseStripeMppAuthorization, verifyStripeMppAccessToken } = require("./stripe_mpp_payment_front");
@@ -157,10 +163,48 @@ function verifyPaymentPayloadAgainstQuote(paymentPayload, quote, walletId, maxAg
157
163
  return { ok: true, age_seconds: ageSeconds };
158
164
  }
159
165
 
160
- function verifyPaymentAcceptance(state, headers, requiredHeaders, quote, options) {
166
+ async function verifyPaymentAcceptance(state, headers, requiredHeaders, quote, options) {
161
167
  const config = loadPaymentConfig();
162
168
  const required = Array.isArray(requiredHeaders) ? requiredHeaders : [];
163
169
  const opts = options || {};
170
+ // Trustless on-chain pay-per-call: the PAYMENT-SIGNATURE carries an x402 v2 payment,
171
+ // which a facilitator verifies and settles on-chain before any work runs. This path is
172
+ // opt-in (XYTARA_PAYMENT_VERIFICATION_MODE=x402_facilitator) and fails closed if the
173
+ // facilitator is unreachable or the payment does not settle.
174
+ if (config.verification_mode === "x402_facilitator" && required.includes("payment-signature")) {
175
+ const h = headers || {};
176
+ const facilitator = createFacilitatorClient({
177
+ url: config.x402.facilitator_url,
178
+ headers: config.x402.facilitator_headers,
179
+ timeoutMs: config.x402.facilitator_timeout_ms
180
+ });
181
+ const auth = await verifyX402Payment({
182
+ paymentHeader: h["payment-signature"],
183
+ accepts: config.x402.accepts,
184
+ resourceUrl: opts.resource_url || null,
185
+ facilitator
186
+ });
187
+ if (!auth.ok) {
188
+ return { ok: false, status: auth.status || 402, reason: auth.reason, detail: auth.detail || null };
189
+ }
190
+ const settled = await settleX402Payment({ authorization: auth.authorization, facilitator, settle: config.x402.settle });
191
+ if (!settled.ok) {
192
+ return { ok: false, status: settled.status || 402, reason: settled.reason, detail: settled.detail || null };
193
+ }
194
+ return {
195
+ ok: true,
196
+ verification_mode: "x402_facilitator",
197
+ wallet_id: auth.authorization.payer,
198
+ payment_payload: {
199
+ x402_payer: auth.authorization.payer,
200
+ x402_network: auth.authorization.paymentRequirements.network,
201
+ x402_asset: auth.authorization.paymentRequirements.asset,
202
+ x402_amount: auth.authorization.paymentRequirements.amount
203
+ },
204
+ settlement: settled.settlement,
205
+ payment_response_base64: encodePaymentResponse(settled.settlement)
206
+ };
207
+ }
164
208
  if (required.includes("authorization")) {
165
209
  if (opts.authorization_scheme === "mpp" || opts.payment_protocol === "mpp") {
166
210
  return verifyStripeMppAccessToken(headers || {}, quote);
@@ -171,7 +215,11 @@ function verifyPaymentAcceptance(state, headers, requiredHeaders, quote, options
171
215
  return verifyL402AccessToken(headers || {}, quote);
172
216
  }
173
217
  const payment = parsePayment(headers || {}, requiredHeaders, opts);
174
- if (config.verification_mode === "presence") {
218
+ // x402_facilitator only governs the x402 (payment-signature) routes, handled above.
219
+ // On the native and instant routes it falls back to local_signed, so enabling the mode
220
+ // adds trustless on-chain settlement without disabling the existing paid lanes.
221
+ const nativeMode = config.verification_mode === "x402_facilitator" ? "local_signed" : config.verification_mode;
222
+ if (nativeMode === "presence") {
175
223
  return {
176
224
  ok: true,
177
225
  verification_mode: "presence",
@@ -180,7 +228,7 @@ function verifyPaymentAcceptance(state, headers, requiredHeaders, quote, options
180
228
  };
181
229
  }
182
230
 
183
- if (config.verification_mode !== "local_signed") {
231
+ if (nativeMode !== "local_signed") {
184
232
  return {
185
233
  ok: false,
186
234
  status: 400,
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+
3
+ // Self-contained x402 v2 facilitator client for the main runtime.
4
+ //
5
+ // This mirrors the proven logic in services/mcp-execution-target/server.js but takes
6
+ // all configuration as explicit arguments so it has no dependency on any global state
7
+ // and can be reused by any paid route. It is intentionally duplicated rather than shared
8
+ // with the standalone MCP executor, which deploys from its own subdirectory.
9
+ //
10
+ // The trust model is the same one proven live: this process never verifies balances or
11
+ // broadcasts transfers itself. It asks an x402 facilitator to verify the buyer's signed
12
+ // EIP-3009 authorization and to settle it on-chain, and refuses to serve work unless the
13
+ // facilitator confirms both. If the facilitator is unreachable, access fails closed.
14
+
15
+ function normalizeString(value) {
16
+ return typeof value === "string" ? value.trim() : "";
17
+ }
18
+
19
+ function normalizeAddress(value) {
20
+ return normalizeString(value).toLowerCase();
21
+ }
22
+
23
+ // Decode a PAYMENT-SIGNATURE header into an x402 v2 payment payload.
24
+ function decodePaymentSignatureHeader(value) {
25
+ const raw = normalizeString(value);
26
+ if (!raw) return null;
27
+ for (const encoding of ["base64url", "base64"]) {
28
+ try {
29
+ const decoded = Buffer.from(raw, encoding).toString("utf8");
30
+ const parsed = JSON.parse(decoded);
31
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
32
+ if (Number(parsed.x402Version) !== 2) continue;
33
+ if (!parsed.accepted || typeof parsed.accepted !== "object") continue;
34
+ if (!parsed.payload || typeof parsed.payload !== "object") continue;
35
+ return parsed;
36
+ } catch (_) {
37
+ // try the next encoding
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ // Find the advertised requirements entry the buyer claims to be paying.
44
+ function matchAcceptedRequirements(paymentPayload, accepts) {
45
+ const accepted = paymentPayload && paymentPayload.accepted ? paymentPayload.accepted : null;
46
+ if (!accepted || !Array.isArray(accepts)) return null;
47
+ return (
48
+ accepts.find(
49
+ (entry) =>
50
+ entry.scheme === normalizeString(accepted.scheme) &&
51
+ entry.network === normalizeString(accepted.network) &&
52
+ normalizeAddress(entry.asset) === normalizeAddress(accepted.asset) &&
53
+ normalizeAddress(entry.payTo) === normalizeAddress(accepted.payTo) &&
54
+ String(entry.amount) === String(accepted.amount)
55
+ ) || null
56
+ );
57
+ }
58
+
59
+ function createFacilitatorClient(config) {
60
+ const url = normalizeString(config && config.url).replace(/\/$/, "");
61
+ const headers = config && config.headers && typeof config.headers === "object" ? config.headers : {};
62
+ const timeoutMs = Math.max(1000, Number(config && config.timeoutMs) || 15000);
63
+
64
+ async function post(path, body) {
65
+ const controller = new AbortController();
66
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
67
+ try {
68
+ const response = await fetch(`${url}${path}`, {
69
+ method: "POST",
70
+ headers: { "content-type": "application/json", accept: "application/json", ...headers },
71
+ body: JSON.stringify(body),
72
+ signal: controller.signal
73
+ });
74
+ const text = await response.text();
75
+ let json = null;
76
+ try {
77
+ json = text ? JSON.parse(text) : null;
78
+ } catch (_) {
79
+ json = null;
80
+ }
81
+ return { ok: response.ok, status: response.status, json };
82
+ } catch (error) {
83
+ return { ok: false, status: 0, json: null, error: error && error.message ? error.message : "facilitator_request_failed" };
84
+ } finally {
85
+ clearTimeout(timer);
86
+ }
87
+ }
88
+
89
+ return {
90
+ verify: (payload, requirements) => post("/verify", { x402Version: 2, paymentPayload: payload, paymentRequirements: requirements }),
91
+ settle: (payload, requirements) => post("/settle", { x402Version: 2, paymentPayload: payload, paymentRequirements: requirements })
92
+ };
93
+ }
94
+
95
+ // Verify a buyer's x402 payment against the advertised requirements via the facilitator.
96
+ // Returns { ok:true, authorization } or { ok:false, status:402, reason, detail }.
97
+ async function verifyX402Payment({ paymentHeader, accepts, resourceUrl, facilitator }) {
98
+ const header = normalizeString(paymentHeader);
99
+ if (!header) {
100
+ return { ok: false, status: 402, reason: "payment_required" };
101
+ }
102
+ const paymentPayload = decodePaymentSignatureHeader(header);
103
+ if (!paymentPayload) {
104
+ return { ok: false, status: 402, reason: "payment_signature_invalid", detail: "PAYMENT-SIGNATURE must be a base64 JSON x402 v2 payment payload" };
105
+ }
106
+ const paymentRequirements = matchAcceptedRequirements(paymentPayload, accepts);
107
+ if (!paymentRequirements) {
108
+ return { ok: false, status: 402, reason: "payment_requirements_mismatch", detail: "accepted requirements do not match any advertised payment option" };
109
+ }
110
+ const requirementsForFacilitator = { ...paymentRequirements, resource: resourceUrl };
111
+ const verification = await facilitator.verify(paymentPayload, requirementsForFacilitator);
112
+ if (!verification.ok || !verification.json) {
113
+ return { ok: false, status: 402, reason: "facilitator_unreachable", detail: verification.error || `facilitator verify returned HTTP ${verification.status}` };
114
+ }
115
+ if (verification.json.isValid !== true) {
116
+ return { ok: false, status: 402, reason: "payment_verification_failed", detail: normalizeString(verification.json.invalidReason) || "facilitator rejected the payment" };
117
+ }
118
+ return {
119
+ ok: true,
120
+ authorization: {
121
+ paymentPayload,
122
+ paymentRequirements: requirementsForFacilitator,
123
+ payer: normalizeString(verification.json.payer) || null
124
+ }
125
+ };
126
+ }
127
+
128
+ // Settle a verified payment on-chain via the facilitator (unless settlement is disabled).
129
+ // Returns { ok:true, settlement } or { ok:false, status:402, reason, detail }.
130
+ async function settleX402Payment({ authorization, facilitator, settle }) {
131
+ if (settle === false) {
132
+ return { ok: true, settlement: { success: true, skipped: true, reason: "settlement_disabled_by_operator", payer: authorization.payer } };
133
+ }
134
+ const settlement = await facilitator.settle(authorization.paymentPayload, authorization.paymentRequirements);
135
+ if (!settlement.ok || !settlement.json) {
136
+ return { ok: false, status: 402, reason: "facilitator_unreachable", detail: settlement.error || `facilitator settle returned HTTP ${settlement.status}` };
137
+ }
138
+ if (settlement.json.success !== true) {
139
+ return { ok: false, status: 402, reason: "payment_settlement_failed", detail: normalizeString(settlement.json.errorReason) || "facilitator could not settle the payment" };
140
+ }
141
+ return { ok: true, settlement: settlement.json };
142
+ }
143
+
144
+ function encodePaymentResponse(settlement) {
145
+ return Buffer.from(JSON.stringify(settlement), "utf8").toString("base64");
146
+ }
147
+
148
+ module.exports = {
149
+ decodePaymentSignatureHeader,
150
+ matchAcceptedRequirements,
151
+ createFacilitatorClient,
152
+ verifyX402Payment,
153
+ settleX402Payment,
154
+ encodePaymentResponse
155
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xytara",
3
- "version": "3.1.1",
3
+ "version": "3.1.3",
4
4
  "description": "Agent-commerce runtime for quote, pay, execute, deliver, meter, and integrate.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -99,7 +99,8 @@
99
99
  "verify:release-candidate": "node scripts/verify_release_candidate.js",
100
100
  "verify:production-readiness": "node scripts/verify_production_readiness.js",
101
101
  "verify:final-activation": "node scripts/verify_final_activation.js",
102
- "verify:all": "node scripts/verify_all.js && node scripts/verify_integrations.js && node scripts/verify_adapters.js && node scripts/verify_control_plane_contracts.js && node scripts/verify_control_plane_routes.js && node scripts/verify_framework_provider_promotion.js && node scripts/verify_operator_observability_boundary.js && node scripts/verify_pricing_experiment_plan.js && node scripts/verify_treasury_public_boundary.js && node scripts/verify_market_lanes.js && node scripts/verify_market_lane_depth.js && node scripts/verify_market_lane_traces.js && node scripts/verify_market_lane_promotion.js && node scripts/verify_provider_runtime_breadth.js && node scripts/verify_provider_runtime_journeys.js && node scripts/verify_lifecycle_continuity.js && node scripts/verify_first_run_conversions.js && node scripts/verify_delivery_summary.js && node scripts/verify_runtime_durability.js && node scripts/verify_revenue_launch_paths.js && node scripts/verify_full_catalog.js && node scripts/verify_tooling.js && node scripts/verify_examples.js && node scripts/verify_service.js",
102
+ "verify:x402-facilitator": "node scripts/verify_x402_facilitator.js",
103
+ "verify:all": "node scripts/verify_all.js && node scripts/verify_integrations.js && node scripts/verify_adapters.js && node scripts/verify_control_plane_contracts.js && node scripts/verify_control_plane_routes.js && node scripts/verify_framework_provider_promotion.js && node scripts/verify_operator_observability_boundary.js && node scripts/verify_pricing_experiment_plan.js && node scripts/verify_treasury_public_boundary.js && node scripts/verify_market_lanes.js && node scripts/verify_market_lane_depth.js && node scripts/verify_market_lane_traces.js && node scripts/verify_market_lane_promotion.js && node scripts/verify_provider_runtime_breadth.js && node scripts/verify_provider_runtime_journeys.js && node scripts/verify_lifecycle_continuity.js && node scripts/verify_first_run_conversions.js && node scripts/verify_delivery_summary.js && node scripts/verify_runtime_durability.js && node scripts/verify_revenue_launch_paths.js && node scripts/verify_full_catalog.js && node scripts/verify_tooling.js && node scripts/verify_examples.js && node scripts/verify_service.js && node scripts/verify_x402_facilitator.js",
103
104
  "prepublishOnly": "npm run verify:release-candidate"
104
105
  }
105
106
  }
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+
3
+ // Verifies the x402_facilitator payment mode end to end against a mock facilitator:
4
+ // - an unpaid x402 execute returns 402 and advertises the configured accepts
5
+ // - a well-formed payment is verified and settled, the command runs, and the
6
+ // response carries a PAYMENT-RESPONSE settlement receipt
7
+ // - malformed, mismatched, facilitator-rejected, and unsettled payments all 402
8
+ //
9
+ // No real network or chain is used; the mock facilitator stands in for x402.org.
10
+
11
+ const assert = require("assert");
12
+ const http = require("http");
13
+
14
+ const ACCEPTS = [
15
+ {
16
+ scheme: "exact",
17
+ network: "eip155:84532",
18
+ asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
19
+ amount: "1000",
20
+ payTo: "0x7cd2E0Ed2721319B20FAac4109774d030833F84D",
21
+ extra: { name: "USDC", version: "2" },
22
+ maxTimeoutSeconds: 60
23
+ }
24
+ ];
25
+
26
+ function startMockFacilitator() {
27
+ const calls = { verify: 0, settle: 0 };
28
+ const server = http.createServer((req, res) => {
29
+ const chunks = [];
30
+ req.on("data", (c) => chunks.push(c));
31
+ req.on("end", () => {
32
+ let body = {};
33
+ try { body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); } catch (_) { body = {}; }
34
+ const sig = body && body.paymentPayload && body.paymentPayload.payload ? String(body.paymentPayload.payload.signature || "") : "";
35
+ const reply = (payload) => {
36
+ const text = JSON.stringify(payload);
37
+ res.writeHead(200, { "content-type": "application/json", "content-length": Buffer.byteLength(text) });
38
+ res.end(text);
39
+ };
40
+ if (req.method === "POST" && req.url === "/verify") {
41
+ calls.verify += 1;
42
+ if (sig === "reject-me") return reply({ isValid: false, invalidReason: "invalid_signature" });
43
+ return reply({ isValid: true, payer: "0x1111111111111111111111111111111111111111" });
44
+ }
45
+ if (req.method === "POST" && req.url === "/settle") {
46
+ calls.settle += 1;
47
+ if (sig === "settle-fails") return reply({ success: false, errorReason: "insufficient_funds" });
48
+ return reply({ success: true, transaction: "0xmocktx", network: "eip155:84532", payer: "0x1111111111111111111111111111111111111111" });
49
+ }
50
+ res.writeHead(404); res.end();
51
+ });
52
+ });
53
+ return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, calls, port: server.address().port })));
54
+ }
55
+
56
+ function paymentHeader(signature, overrides = {}) {
57
+ const accepted = ACCEPTS[0];
58
+ return Buffer.from(JSON.stringify({
59
+ x402Version: 2,
60
+ accepted: { ...accepted, ...overrides.accepted },
61
+ payload: {
62
+ signature,
63
+ authorization: {
64
+ from: "0x1111111111111111111111111111111111111111",
65
+ to: accepted.payTo,
66
+ value: accepted.amount,
67
+ validAfter: "0",
68
+ validBefore: String(Math.floor(Date.now() / 1000) + 600),
69
+ nonce: `0x${"ab".repeat(32)}`
70
+ }
71
+ }
72
+ }), "utf8").toString("base64");
73
+ }
74
+
75
+ async function post(baseUrl, path, body, headers = {}) {
76
+ const res = await fetch(`${baseUrl}${path}`, {
77
+ method: "POST",
78
+ headers: { "content-type": "application/json", ...headers },
79
+ body: JSON.stringify(body)
80
+ });
81
+ let json = null;
82
+ try { json = await res.json(); } catch (_) { json = null; }
83
+ return { status: res.status, json, headers: res.headers };
84
+ }
85
+
86
+ async function main() {
87
+ const facilitator = await startMockFacilitator();
88
+ const previous = {
89
+ mode: process.env.XYTARA_PAYMENT_VERIFICATION_MODE,
90
+ accepts: process.env.XYTARA_X402_ACCEPTS_JSON,
91
+ facUrl: process.env.XYTARA_X402_FACILITATOR_URL
92
+ };
93
+ process.env.XYTARA_PAYMENT_VERIFICATION_MODE = "x402_facilitator";
94
+ process.env.XYTARA_X402_ACCEPTS_JSON = JSON.stringify(ACCEPTS);
95
+ process.env.XYTARA_X402_FACILITATOR_URL = `http://127.0.0.1:${facilitator.port}`;
96
+
97
+ delete require.cache[require.resolve("../server")];
98
+ const { createServer } = require("../server");
99
+ const server = createServer();
100
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
101
+ const baseUrl = `http://127.0.0.1:${server.address().port}`;
102
+ const execBody = { account_id: "acct_x402", command: "x402 facilitator demo", task_ref: "trust.verify", body: { subject_id: "s-1" } };
103
+
104
+ try {
105
+ // 1. Unpaid -> 402 advertising accepts.
106
+ const unpaid = await post(baseUrl, "/x402/commands/execute", execBody);
107
+ assert.strictEqual(unpaid.status, 402, "unpaid x402 execute must return 402");
108
+ const required = unpaid.json && unpaid.json.payment_required;
109
+ assert.ok(required, "402 must carry payment_required");
110
+ assert.strictEqual(required.x402Version, 2, "challenge must be x402 v2");
111
+ assert.ok(Array.isArray(required.accepts) && required.accepts.length === 1, "challenge must advertise accepts");
112
+ assert.strictEqual(required.accepts[0].network, "eip155:84532", "advertised network mismatch");
113
+ assert.strictEqual(typeof unpaid.headers.get("payment-required"), "string", "PAYMENT-REQUIRED header missing");
114
+
115
+ // 2. Bogus header -> 402 payment_signature_invalid.
116
+ const bogus = await post(baseUrl, "/x402/commands/execute", execBody, { "payment-signature": "not-a-payment" });
117
+ assert.strictEqual(bogus.status, 402, "bogus payment header must 402");
118
+ assert.strictEqual(bogus.json.reason, "payment_signature_invalid", "bogus reason mismatch");
119
+
120
+ // 3. Mismatched requirements -> 402 before the facilitator.
121
+ const verifyBefore = facilitator.calls.verify;
122
+ const mismatch = await post(baseUrl, "/x402/commands/execute", execBody, { "payment-signature": paymentHeader("ok", { accepted: { amount: "9" } }) });
123
+ assert.strictEqual(mismatch.status, 402, "mismatched requirements must 402");
124
+ assert.strictEqual(mismatch.json.reason, "payment_requirements_mismatch", "mismatch reason");
125
+ assert.strictEqual(facilitator.calls.verify, verifyBefore, "facilitator must not be called on a requirements mismatch");
126
+
127
+ // 4. Facilitator rejects -> 402 payment_verification_failed.
128
+ const rejected = await post(baseUrl, "/x402/commands/execute", execBody, { "payment-signature": paymentHeader("reject-me") });
129
+ assert.strictEqual(rejected.status, 402, "facilitator-rejected payment must 402");
130
+ assert.strictEqual(rejected.json.reason, "payment_verification_failed", "rejection reason");
131
+ assert.strictEqual(rejected.json.detail, "invalid_signature", "rejection detail");
132
+
133
+ // 5. Settlement fails -> 402, no tool output.
134
+ const settleFails = await post(baseUrl, "/x402/commands/execute", execBody, { "payment-signature": paymentHeader("settle-fails") });
135
+ assert.strictEqual(settleFails.status, 402, "failed settlement must 402");
136
+ assert.strictEqual(settleFails.json.reason, "payment_settlement_failed", "settlement failure reason");
137
+
138
+ // 6. Verified + settled -> 200 with a result and a PAYMENT-RESPONSE receipt.
139
+ const settleBefore = facilitator.calls.settle;
140
+ const paid = await post(baseUrl, "/x402/commands/execute", execBody, { "payment-signature": paymentHeader("ok") });
141
+ assert.strictEqual(paid.status, 200, "verified+settled payment must 200");
142
+ assert.strictEqual(typeof paid.json.transaction.transaction_id, "string", "paid result missing transaction");
143
+ assert.strictEqual(paid.json.transaction.status, "completed", "paid transaction must be completed");
144
+ assert.strictEqual(paid.json.transaction.payment.verification_mode, "x402_facilitator", "verification mode not recorded");
145
+ assert.strictEqual(facilitator.calls.settle, settleBefore + 1, "facilitator settle must be called once");
146
+ const receiptHeader = paid.headers.get("payment-response");
147
+ assert.strictEqual(typeof receiptHeader, "string", "PAYMENT-RESPONSE header missing on a settled 200");
148
+ const receipt = JSON.parse(Buffer.from(receiptHeader, "base64").toString("utf8"));
149
+ assert.strictEqual(receipt.success, true, "settlement receipt must report success");
150
+ assert.strictEqual(receipt.transaction, "0xmocktx", "settlement receipt transaction mismatch");
151
+
152
+ // 7. Native routes must not be disabled by x402_facilitator: a native paid attempt
153
+ // falls back to local_signed semantics, never payment_verification_mode_unsupported.
154
+ const nativeAttempt = await post(baseUrl, "/v1/commands/execute", execBody, {
155
+ "x-wallet-id": "wallet-not-registered",
156
+ "x-payment": "encoded",
157
+ "x-payment-signature": "sig"
158
+ });
159
+ assert.notStrictEqual(nativeAttempt.json && nativeAttempt.json.reason, "payment_verification_mode_unsupported", "native route must fall back to local_signed under x402_facilitator");
160
+
161
+ // 8. Self-serve credits top-up: unpaid -> 402 advertising accepts + credits_per_topup.
162
+ const topupUnpaid = await post(baseUrl, "/x402/credits/topup", { account_id: "acct_topup" });
163
+ assert.strictEqual(topupUnpaid.status, 402, "unpaid topup must 402");
164
+ assert.ok(Array.isArray(topupUnpaid.json.payment_required.accepts), "topup 402 must advertise accepts");
165
+ assert.strictEqual(typeof topupUnpaid.json.payment_required.credits_per_topup, "number", "topup 402 must state credits_per_topup");
166
+
167
+ // 9. Paid top-up -> 200, credits minted to the account, with a settlement receipt.
168
+ const topupPaid = await post(baseUrl, "/x402/credits/topup", { account_id: "acct_topup" }, { "payment-signature": paymentHeader("ok") });
169
+ assert.strictEqual(topupPaid.status, 200, "paid topup must 200");
170
+ assert.strictEqual(topupPaid.json.ok, true, "paid topup must be ok");
171
+ assert.strictEqual(typeof topupPaid.json.credit_grant, "object", "paid topup must return a credit grant");
172
+ const grantedUnits = Number(topupPaid.json.balance.available_units);
173
+ assert.ok(grantedUnits > 0, "paid topup must leave a positive credit balance");
174
+ assert.strictEqual(typeof topupPaid.headers.get("payment-response"), "string", "paid topup must carry a settlement receipt");
175
+
176
+ // 10. Replayed top-up (same payment nonce) is idempotent: no double-mint.
177
+ const topupReplay = await post(baseUrl, "/x402/credits/topup", { account_id: "acct_topup" }, { "payment-signature": paymentHeader("ok") });
178
+ assert.strictEqual(topupReplay.status, 200, "replayed topup must still 200");
179
+ assert.strictEqual(Number(topupReplay.json.balance.available_units), grantedUnits, "replayed topup must not mint credits twice");
180
+
181
+ console.log("xytara verify:x402-facilitator passed");
182
+ } finally {
183
+ await new Promise((resolve) => server.close(resolve));
184
+ await new Promise((resolve) => facilitator.server.close(resolve));
185
+ if (previous.mode === undefined) delete process.env.XYTARA_PAYMENT_VERIFICATION_MODE; else process.env.XYTARA_PAYMENT_VERIFICATION_MODE = previous.mode;
186
+ if (previous.accepts === undefined) delete process.env.XYTARA_X402_ACCEPTS_JSON; else process.env.XYTARA_X402_ACCEPTS_JSON = previous.accepts;
187
+ if (previous.facUrl === undefined) delete process.env.XYTARA_X402_FACILITATOR_URL; else process.env.XYTARA_X402_FACILITATOR_URL = previous.facUrl;
188
+ }
189
+ }
190
+
191
+ main().catch((error) => {
192
+ console.error(error && error.message ? error.message : error);
193
+ process.exitCode = 1;
194
+ });
package/server.js CHANGED
@@ -49,6 +49,67 @@ function buildPaymentRequiredHeaders(paymentRequiredPayload) {
49
49
  "Cache-Control": "no-store"
50
50
  };
51
51
  }
52
+
53
+ const {
54
+ createFacilitatorClient: createX402FacilitatorClient,
55
+ verifyX402Payment,
56
+ settleX402Payment,
57
+ encodePaymentResponse: encodeX402PaymentResponse
58
+ } = require("./lib/x402_facilitator");
59
+
60
+ // Verify and settle an x402 payment for a standalone route (not the command flow).
61
+ // Returns { ok:true, settlement, authorization, paymentResponse } or
62
+ // { ok:false, status, reason, detail, challenge } where challenge means a 402 is appropriate.
63
+ async function settleX402ForRoute(req, url) {
64
+ const config = loadPaymentConfig();
65
+ if (config.verification_mode !== "x402_facilitator") {
66
+ return { ok: false, status: 400, reason: "x402_facilitator_mode_not_enabled" };
67
+ }
68
+ if (!config.x402.accepts.length) {
69
+ return { ok: false, status: 400, reason: "x402_accepts_not_configured" };
70
+ }
71
+ const facilitator = createX402FacilitatorClient({
72
+ url: config.x402.facilitator_url,
73
+ headers: config.x402.facilitator_headers,
74
+ timeoutMs: config.x402.facilitator_timeout_ms
75
+ });
76
+ const auth = await verifyX402Payment({
77
+ paymentHeader: (req.headers || {})["payment-signature"],
78
+ accepts: config.x402.accepts,
79
+ resourceUrl: buildRequestResourceUrl(req, url),
80
+ facilitator
81
+ });
82
+ if (!auth.ok) {
83
+ return { ok: false, status: auth.status || 402, reason: auth.reason, detail: auth.detail || null, challenge: true };
84
+ }
85
+ const settled = await settleX402Payment({ authorization: auth.authorization, facilitator, settle: config.x402.settle });
86
+ if (!settled.ok) {
87
+ return { ok: false, status: settled.status || 402, reason: settled.reason, detail: settled.detail || null, challenge: true };
88
+ }
89
+ return { ok: true, settlement: settled.settlement, authorization: auth.authorization, paymentResponse: encodeX402PaymentResponse(settled.settlement) };
90
+ }
91
+
92
+ // The public resource URL of the current request, used as the x402 payment resource.
93
+ function buildRequestResourceUrl(req, url) {
94
+ const headers = (req && req.headers) || {};
95
+ const forwardedProto = String(headers["x-forwarded-proto"] || "").split(",")[0].trim();
96
+ const protocol = forwardedProto || (process.env.RENDER ? "https" : "http");
97
+ const host = String(headers["x-forwarded-host"] || headers.host || "").split(",")[0].trim();
98
+ if (!host) return null;
99
+ return `${protocol}://${host}${url.pathname}`;
100
+ }
101
+
102
+ // Response headers for an x402 route: PAYMENT-REQUIRED on a 402 challenge, or
103
+ // PAYMENT-RESPONSE carrying the settlement receipt on a settled 200.
104
+ function buildX402ResponseHeaders(result) {
105
+ if (result && result.status === 402 && result.payload && result.payload.payment_required) {
106
+ return buildPaymentRequiredHeaders(result.payload.payment_required);
107
+ }
108
+ if (result && result.status === 200 && result.paymentResponse) {
109
+ return { "PAYMENT-RESPONSE": result.paymentResponse, "Cache-Control": "no-store" };
110
+ }
111
+ return undefined;
112
+ }
52
113
  const {
53
114
  completePendingExecution,
54
115
  createCommandResult,
@@ -8898,24 +8959,91 @@ async function routeRequest(req, res) {
8898
8959
  ...(body && typeof body === "object" && !Array.isArray(body) ? body : {}),
8899
8960
  payment_protocol: "x402"
8900
8961
  };
8962
+ const x402Accepts = loadPaymentConfig().x402.accepts;
8901
8963
  const result = await executeCommandRequest(state, payload, req.headers, {
8902
8964
  requiredHeaders: ["payment-signature"],
8965
+ resourceUrl: buildRequestResourceUrl(req, url),
8903
8966
  challengePayload: (quote) => ({
8904
8967
  ok: false,
8905
8968
  payment_required: {
8969
+ x402Version: 2,
8906
8970
  scheme: "exact",
8907
- quote
8971
+ quote,
8972
+ ...(x402Accepts.length > 0 ? { accepts: x402Accepts } : {})
8908
8973
  }
8909
8974
  })
8910
8975
  });
8911
- sendJson(
8912
- res,
8913
- result.status,
8914
- result.payload,
8915
- result.status === 402 && result.payload && result.payload.payment_required
8916
- ? buildPaymentRequiredHeaders(result.payload.payment_required)
8917
- : undefined
8918
- );
8976
+ sendJson(res, result.status, result.payload, buildX402ResponseHeaders(result));
8977
+ return;
8978
+ }
8979
+
8980
+ if (req.method === "POST" && url.pathname === "/x402/credits/topup") {
8981
+ // Self-serve credits: pay once with x402 and credits are minted to the account, with no
8982
+ // operator grant. Only available when the runtime is in x402_facilitator mode with accepts set.
8983
+ const body = await readJsonBody(req);
8984
+ const config = loadPaymentConfig();
8985
+ if (config.verification_mode !== "x402_facilitator") {
8986
+ sendJson(res, 400, { ok: false, reason: "x402_facilitator_mode_not_enabled" });
8987
+ return;
8988
+ }
8989
+ if (!config.x402.accepts.length) {
8990
+ sendJson(res, 400, { ok: false, reason: "x402_accepts_not_configured" });
8991
+ return;
8992
+ }
8993
+ const topupUnitsFor = (accept) => {
8994
+ const perAccept = accept && accept.extra && Number(accept.extra.credits);
8995
+ return Number.isFinite(perAccept) && perAccept > 0 ? perAccept : config.x402.topup_units_default;
8996
+ };
8997
+ const paymentSignatureHeader = ((req.headers || {})["payment-signature"] || "").trim();
8998
+ if (!paymentSignatureHeader) {
8999
+ const paymentRequired = {
9000
+ x402Version: 2,
9001
+ scheme: "exact",
9002
+ accepts: config.x402.accepts,
9003
+ credits_per_topup: topupUnitsFor(config.x402.accepts[0])
9004
+ };
9005
+ sendJson(res, 402, { ok: false, payment_required: paymentRequired }, buildPaymentRequiredHeaders(paymentRequired));
9006
+ return;
9007
+ }
9008
+ const settled = await settleX402ForRoute(req, url);
9009
+ if (!settled.ok) {
9010
+ const payload = { ok: false, reason: settled.reason, ...(settled.detail ? { detail: settled.detail } : {}) };
9011
+ let extraHeaders;
9012
+ if (settled.challenge) {
9013
+ payload.payment_required = { x402Version: 2, scheme: "exact", accepts: config.x402.accepts };
9014
+ extraHeaders = buildPaymentRequiredHeaders(payload.payment_required);
9015
+ }
9016
+ sendJson(res, settled.status, payload, extraHeaders);
9017
+ return;
9018
+ }
9019
+ const matched = settled.authorization.paymentRequirements;
9020
+ const bodyAccountId = body && typeof body.account_id === "string" ? body.account_id.trim() : "";
9021
+ const accountId = bodyAccountId || settled.authorization.payer || "acct_demo";
9022
+ const nonce = settled.authorization.paymentPayload
9023
+ && settled.authorization.paymentPayload.payload
9024
+ && settled.authorization.paymentPayload.payload.authorization
9025
+ ? settled.authorization.paymentPayload.payload.authorization.nonce
9026
+ : null;
9027
+ const grant = createExternalCreditGrant(state, {
9028
+ account_id: accountId,
9029
+ units: topupUnitsFor(matched),
9030
+ source: "x402_settlement",
9031
+ provider_reference: settled.settlement.transaction || null,
9032
+ idempotency_key: nonce ? `x402:${nonce}` : null,
9033
+ metadata: {
9034
+ x402_payer: settled.authorization.payer,
9035
+ x402_network: matched.network,
9036
+ x402_asset: matched.asset,
9037
+ x402_amount: matched.amount
9038
+ }
9039
+ });
9040
+ sendJson(res, 200, {
9041
+ ok: true,
9042
+ account_id: accountId,
9043
+ credit_grant: grant,
9044
+ balance: buildCreditBalanceSummary(state, accountId),
9045
+ settlement: settled.settlement
9046
+ }, { "PAYMENT-RESPONSE": settled.paymentResponse, "Cache-Control": "no-store" });
8919
9047
  return;
8920
9048
  }
8921
9049
 
@@ -10566,24 +10694,21 @@ async function routeRequest(req, res) {
10566
10694
  if (req.method === "POST" && url.pathname === "/x402/mcp/tools/invoke") {
10567
10695
  const body = await readJsonBody(req);
10568
10696
  const payload = buildMcpInvokePayload(body, { paymentProtocol: "x402" });
10697
+ const x402Accepts = loadPaymentConfig().x402.accepts;
10569
10698
  const result = await executeCommandRequest(state, payload, req.headers, {
10570
10699
  requiredHeaders: ["payment-signature"],
10700
+ resourceUrl: buildRequestResourceUrl(req, url),
10571
10701
  challengePayload: (quote) => ({
10572
10702
  ok: false,
10573
10703
  payment_required: {
10704
+ x402Version: 2,
10574
10705
  scheme: "exact",
10575
- quote
10706
+ quote,
10707
+ ...(x402Accepts.length > 0 ? { accepts: x402Accepts } : {})
10576
10708
  }
10577
10709
  })
10578
10710
  });
10579
- sendJson(
10580
- res,
10581
- result.status,
10582
- result.payload,
10583
- result.status === 402 && result.payload && result.payload.payment_required
10584
- ? buildPaymentRequiredHeaders(result.payload.payment_required)
10585
- : undefined
10586
- );
10711
+ sendJson(res, result.status, result.payload, buildX402ResponseHeaders(result));
10587
10712
  return;
10588
10713
  }
10589
10714