xytara 3.1.2 → 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
@@ -10,6 +10,9 @@ XYTARA_X402_FACILITATOR_URL=https://x402.org/facilitator
10
10
  XYTARA_X402_FACILITATOR_HEADERS_JSON={}
11
11
  XYTARA_X402_FACILITATOR_TIMEOUT_MS=15000
12
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
13
16
  XYTARA_PAYMENT_MAX_AGE_SECONDS=300
14
17
  XYTARA_WALLET_REGISTRY_JSON=[]
15
18
  XYTARA_TREASURY_DESTINATION_ID=
@@ -45,6 +45,26 @@ is identical to the one in `services/mcp-execution-target/buyer-demo/` (an EIP-3
45
45
  authorization in a `PAYMENT-SIGNATURE` header); only the request body differs, since the main
46
46
  runtime takes a command-execute body rather than an MCP JSON-RPC call.
47
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
+
48
68
  ## Minimum Production-Oriented Configuration
49
69
 
50
70
  Set these environment variables:
package/RELEASE_NOTES.md CHANGED
@@ -1,14 +1,16 @@
1
- `xytara` 3.1.2 adds trustless, on-chain, pay-per-call settlement to the main runtime.
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 brings the facilitator verify-and-settle mechanism already proven on the public MCP executor to the runtime itself, so every x402 route can settle a real payment on-chain before any work runs, not just the standalone tool server.
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
- - adds a third payment-acceptance mode, `x402_facilitator`, enabled with `XYTARA_PAYMENT_VERIFICATION_MODE=x402_facilitator`
8
- - when enabled, `/x402/commands/execute` and `/x402/mcp/tools/invoke` return a `402` that advertises the configured `accepts` (network, asset, amount, payTo) in the `PAYMENT-REQUIRED` header
9
- - a buyer signs a gasless USDC authorization (EIP-3009); on the paid retry a facilitator verifies and settles it on-chain before any work runs, and the response carries a `PAYMENT-RESPONSE` settlement receipt
10
- - fails closed: if the facilitator is unreachable or the payment does not settle, nothing runs and no result is returned
11
- - the mode governs only the x402 routes; native and instant lanes fall back to `local_signed`, so no existing paid path is disabled
12
- - keeps `local_signed` as the default and `presence` as the local-development opt-in
13
- - adds `npm run verify:x402-facilitator`, an end-to-end check against a mock facilitator covering the advertised challenge, malformed and mismatched payments, facilitator rejection, failed settlement, and a settled success with receipt
14
- - documents the mode and its environment in `REAL_PAYMENT_SETUP.md` and the architecture in `ARCHITECTURE.md`
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.
@@ -6,9 +6,9 @@
6
6
  "source_ref": "spine.job.commitment",
7
7
  "committed_ref": "transaction.job.commitment",
8
8
  "precommit_pack_ref": "precommit.job.commitment.readiness",
9
- "transaction_id": "txn_7f5d7cd15658",
10
- "receipt_id": "rcpt_2684569e5241",
11
- "proof_ref": "proof_rcpt_2684569e5241",
9
+ "transaction_id": "txn_c3db568e78ec",
10
+ "receipt_id": "rcpt_fc14a1bfa4b5",
11
+ "proof_ref": "proof_rcpt_fc14a1bfa4b5",
12
12
  "payment_protocol": "x402",
13
13
  "settlement_mode": "evm_payment",
14
14
  "treasury_destination_id": "treasury_acct_demo",
@@ -171,7 +171,10 @@ function loadX402FacilitatorConfig() {
171
171
  facilitator_url: String(process.env.XYTARA_X402_FACILITATOR_URL || "https://x402.org/facilitator").trim().replace(/\/$/, ""),
172
172
  facilitator_headers: headers,
173
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"
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)
175
178
  };
176
179
  }
177
180
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xytara",
3
- "version": "3.1.2",
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": {
@@ -158,6 +158,26 @@ async function main() {
158
158
  });
159
159
  assert.notStrictEqual(nativeAttempt.json && nativeAttempt.json.reason, "payment_verification_mode_unsupported", "native route must fall back to local_signed under x402_facilitator");
160
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
+
161
181
  console.log("xytara verify:x402-facilitator passed");
162
182
  } finally {
163
183
  await new Promise((resolve) => server.close(resolve));
package/server.js CHANGED
@@ -50,6 +50,45 @@ function buildPaymentRequiredHeaders(paymentRequiredPayload) {
50
50
  };
51
51
  }
52
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
+
53
92
  // The public resource URL of the current request, used as the x402 payment resource.
54
93
  function buildRequestResourceUrl(req, url) {
55
94
  const headers = (req && req.headers) || {};
@@ -8938,6 +8977,76 @@ async function routeRequest(req, res) {
8938
8977
  return;
8939
8978
  }
8940
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" });
9047
+ return;
9048
+ }
9049
+
8941
9050
  if (req.method === "POST" && url.pathname === "/l402/commands/execute") {
8942
9051
  const body = await readJsonBody(req);
8943
9052
  const payload = {