xytara 3.1.14 → 3.1.15

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,9 +10,36 @@ 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
+ # self-serve /x402/credits/topup: priced at this multiple of the per-call accepts amount
14
+ # (default 100: $0.01 -> $1.00); credits = amount paid / XYTARA_CREDIT_UNIT_PRICE_ATOMIC
15
+ # (USDC base units per credit; default 2500 = $0.0025). An accepts entry's extra.credits may
16
+ # lower the grant, never raise it. Testnet networks cannot buy credits unless this is 1:
17
+ XYTARA_X402_TOPUP_AMOUNT_MULTIPLIER=100
18
+ XYTARA_CREDIT_UNIT_PRICE_ATOMIC=2500
19
+ XYTARA_X402_TESTNET_TOPUP=0
20
+ # floor for the advertised maxTimeoutSeconds (buyers sign validBefore = now + this)
21
+ XYTARA_X402_MIN_TIMEOUT_SECONDS=300
22
+ # money-safety policies: "strict" by default on a hosted deploy (RENDER set), "permissive" locally.
23
+ # paid execution: only tasks with a real execution target can be quoted/charged
24
+ # credit spend: spending credits needs the account's spend credential (xytara_spend_...)
25
+ # ledger reads: ledger/quote-list/receipts/operations/revenue GETs are operator-only
26
+ XYTARA_PAID_EXECUTION_POLICY=
27
+ XYTARA_CREDIT_SPEND_POLICY=
28
+ XYTARA_LEDGER_READ_POLICY=
29
+ # provider calls: per-attempt timeout floor for registered providers (default 25000 on Render)
30
+ XYTARA_PROVIDER_MIN_TIMEOUT_MS=
31
+ # chain RPC used to check whether an x402 authorization settled after a facilitator timeout
32
+ XYTARA_RPC_URL_EIP155_8453=https://mainnet.base.org
33
+ XYTARA_RPC_URL_EIP155_84532=https://sepolia.base.org
34
+ # buyer event log: ring size, payer-hash salt, and the token the MCP executor uses to forward
35
+ # its events to POST /v1/operator/buyer-events/ingest
36
+ XYTARA_BUYER_EVENT_LOG_MAX=500
37
+ XYTARA_BUYER_EVENT_SALT=
38
+ XYTARA_EVENT_INGEST_TOKEN=
39
+ # Supabase request timeout (ms) for state restore/flush
40
+ XYTARA_SUPABASE_TIMEOUT_MS=10000
41
+ # public base URL advertised in OpenAPI/AsyncAPI
42
+ XYTARA_PUBLIC_BASE_URL=https://xytara.onrender.com
16
43
  # Coinbase CDP auth for the CDP-hosted facilitator (Base mainnet); Ed25519 Secret API Key.
17
44
  # The runtime mints a fresh CDP Bearer JWT per request when both are set.
18
45
  CDP_API_KEY_ID=
@@ -50,17 +50,24 @@ runtime takes a command-execute body rather than an MCP JSON-RPC call.
50
50
  Beyond paying per call, a buyer can convert a single x402 payment into spendable
51
51
  runtime credits with no operator grant, via `POST /x402/credits/topup`:
52
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)
53
+ - without a `PAYMENT-SIGNATURE`, it returns `402` advertising the top-up `accepts` (each
54
+ per-call amount x `XYTARA_X402_TOPUP_AMOUNT_MULTIPLIER`, default 100: $0.01 -> $1.00),
55
+ `credits_per_topup` and a `topup_options` list (network, amount, credits)
55
56
  - 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
57
+ credits to the account, returning the grant, the new balance, a `PAYMENT-RESPONSE`
58
+ settlement receipt and — when the credits went to the paying address — a
59
+ `spend_credential` (`Bearer xytara_spend_...`, shown once) for spending them
60
+ - credits land on the paying wallet address by default; credits sent to another `account_id`
61
+ are a gift and carry no spend credential
59
62
  - replays are idempotent: the payment nonce keys the grant, so re-sending the same settled
60
63
  payment never mints twice
61
64
 
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`
65
+ Credits are derived from what was paid: amount / `XYTARA_CREDIT_UNIT_PRICE_ATOMIC` (default
66
+ 2500 base units = $0.0025 per credit, so $1.00 buys 400 credits; a task costs 1–8 credits by
67
+ tier). A matched `accepts` entry's `extra.credits` may lower that, never raise it. Testnet
68
+ networks (Base Sepolia) cannot buy credits unless `XYTARA_X402_TESTNET_TOPUP=1`. Spend them with
69
+ `payment_preference: "credits_first"` and the spend credential as the bearer — on a hosted
70
+ deploy a bare `account_id` is never authority. The route is live only in `x402_facilitator`
64
71
  mode with `XYTARA_X402_ACCEPTS_JSON` set, and credits persist only when runtime state
65
72
  persistence (`XYTARA_SUPABASE_*` or `XYTARA_STATE_FILE`) is configured. A runnable buyer is in
66
73
  `services/mcp-execution-target/buyer-demo/x402-credits-topup-demo.js`.
package/RELEASE_NOTES.md CHANGED
@@ -1,7 +1,10 @@
1
- `xytara` 3.1.14 makes runtime state durable under backend failures, writes it more lightly, and backs it up off-site.
1
+ `xytara` 3.1.15 makes the buyer path safe end to end: a buyer is never charged for a result that was not produced, every failed attempt is visible to the operator, and standard x402 MCP clients can pay the MCP server directly.
2
2
 
3
- - **Write guard** — if the boot-time restore of durable state fails (e.g. the state backend times out), the runtime never writes: writing would overwrite the good snapshot with near-empty state. It retries the restore in the background, merges the snapshot under anything that happened since boot, and only then resumes writes. Proven in production during a state-backend outage: the full snapshot was restored with nothing lost.
4
- - **Lighter writes** — the snapshot is written only when its content changed (identical rewrites are skipped and counted), the default check interval is 15s, and expired never-paid quotes are pruned after 7 days (`XYTARA_QUOTE_RETENTION_MS`).
5
- - **Rolling history** — in Supabase mode the snapshot is also kept in rotating hourly history rows (24 by default), so a bad write can be rolled back.
6
- - **Off-site encrypted backups** — `scripts/state_backup_offsite.js` (gzip + AES-256-GCM; refuses a near-empty snapshot so a failed scheduled run is an alert; decrypt and guarded restore modes) with a daily workflow.
3
+ - **Pay only for real work** — on a hosted deploy a task is quoted and charged only when it resolves to a real execution target (an operator-pinned target or an active open-network provider). Anything else is refused up front with `501 capability_not_executable` and the list of executable tasks; no payment is requested. The catalog marks every task with `purchase.executable_now` and its per-call price. Post-quantum proof verification (`adapter.xoonya.invoke`) is now a catalog task.
4
+ - **Verify, execute, then settle — everywhere** — paid A2A calls now settle only after the skill succeeds, like the HTTP routes. If the facilitator times out at settlement, the runtime checks the EIP-3009 `authorizationState` on-chain: a used authorization is a settled payment and the result is delivered; otherwise the finished result is held and returned when the buyer resends the same payment (bound to the original request). A resent settled payment returns its stored result instead of failing. The execution deadline stays inside the signed authorization's `validBefore`, and the advertised `maxTimeoutSeconds` has a floor of 300.
5
+ - **Credits are safe and priced from what was paid** — spending credits requires the account's spend credential; a bare `account_id` is never authority. A self-serve top-up is priced at 100x the per-call amount, is mainnet-only by default, grants credits derived from the amount paid (default $0.0025 per credit), and returns a spend credential to the paying address. Provider revenue on credit spends accrues on the credits' purchase value, never on the catalog quote. Credit routes answer `503` with `Retry-After` while durable state is still being restored.
6
+ - **Buyer event log** — every failed buyer attempt is recorded with its stage, reason, fault (ours, upstream, buyer) and a request id the buyer also receives (`X-Request-Id`), with per-day counters for challenges issued and paid calls served. No signatures, request bodies, tokens or full addresses are stored. Operator-only `GET /v1/operator/buyer-events` and `/summary`; the MCP server forwards its events to `POST /v1/operator/buyer-events/ingest`; a daily report job flags money that moved without a delivered result.
7
+ - **MCP server** — speaks the x402 MCP transport natively (payment-required tool result; payment in `_meta["x402/payment"]`, `PAYMENT-SIGNATURE` or `X-PAYMENT`; settlement in `_meta["x402/payment-response"]`), with `/mcp/http402` for fetch-wrapper clients. Bad arguments and unknown tools are rejected before any payment, `GET /mcp` is no longer a paid route, and CORS is enabled. Every paid tool was corrected so that an empty, invalid or unverifiable answer is an error and never charged; `json.validate` validates against a JSON Schema subset, `x402.verify-receipt` confirms only a matching USDC transfer, ABI tools are strict, and hostile inputs are bounded.
8
+ - **Request-bound agent signatures** — the credit lifecycle routes (reserve, release, reverse) honour the agent's signed binding to the exact account, amount and task.
9
+ - **Robustness and privacy** — malformed JSON is a `400` (oversize `413`), internal errors are logged under the request id without leaking details, state-store calls time out, registered providers get a cold-start timeout floor and are woken when a buyer is quoted, and ledger, quote-list, receipt, operations and revenue reads are operator-only on a hosted deploy. x402 challenges show the price actually charged; OpenAPI and AsyncAPI point at the hosted runtime.
7
10
  - Published through npm trusted publishing (GitHub OIDC) — no npm token involved.
@@ -779,6 +779,28 @@ const tasks = [
779
779
  capabilities: ["adapter.mcp.invoke"],
780
780
  adapters: ["mcp"]
781
781
  }),
782
+ makeTask({
783
+ task_ref: "internal.adapter.xoonya.invoke",
784
+ public_task_ref: "adapter.xoonya.invoke",
785
+ public_category: "adapter",
786
+ public_title: "Post-quantum proof verification (xoonya)",
787
+ execution_mode: "direct_http",
788
+ pricing_band: "decision_support",
789
+ public_launch_price: { amount_minor: 1, currency: "USD_CENTS" },
790
+ latency_class: "near_real_time",
791
+ failure_posture: "retry_bounded",
792
+ input_schema: {
793
+ type: "object",
794
+ required: ["tool_name"],
795
+ properties: {
796
+ tool_name: { type: "string", enum: ["proof.verify-signature", "proof.verify-bundle"] },
797
+ arguments: { type: "object" }
798
+ },
799
+ additionalProperties: false
800
+ },
801
+ capabilities: ["adapter.xoonya.invoke"],
802
+ adapters: ["xoonya"]
803
+ }),
782
804
  makeTask({
783
805
  task_ref: "internal.adapter.mcp.catalog",
784
806
  public_task_ref: "adapter.mcp.catalog",
package/lib/a2a_rpc.js CHANGED
@@ -156,6 +156,22 @@ async function handleMessageSend(rpc, ctx) {
156
156
  const contextId = message.contextId || message.taskId || uuid();
157
157
  const { skillTaskRef, body } = resolveSkill(message);
158
158
 
159
+ // Never ask for (or take) payment for a skill nothing can execute.
160
+ const taskPayloadDraft = ctx.deps.buildNamedTaskPayload(skillTaskRef, body, skillTaskRef);
161
+ const executability = typeof ctx.deps.assessExecutability === "function" ? ctx.deps.assessExecutability(taskPayloadDraft) : { ok: true };
162
+ if (!executability.ok) {
163
+ const refusal = executability.payload || {};
164
+ return {
165
+ http: 200,
166
+ body: jsonrpcResult(id, failedTask({
167
+ taskId,
168
+ contextId,
169
+ paymentStatus: "not-required",
170
+ error: `${refusal.reason || "capability_not_executable"}: ${refusal.detail || "this skill cannot run here"}${Array.isArray(refusal.executable_task_refs) ? ` Executable: ${refusal.executable_task_refs.join(", ")}.` : ""}`
171
+ }))
172
+ };
173
+ }
174
+
159
175
  const payload = meta[META_PAYLOAD];
160
176
  if (!payload) {
161
177
  // In-band 402: Task(input-required) carrying the x402 requirements.
@@ -188,14 +204,6 @@ async function handleMessageSend(rpc, ctx) {
188
204
  };
189
205
  }
190
206
 
191
- const settled = await ctx.deps.settleX402Payment({ authorization: auth.authorization, facilitator, settle: x402.settle });
192
- if (!settled.ok) {
193
- return {
194
- http: 200,
195
- body: jsonrpcResult(id, failedTask({ taskId, contextId, paymentStatus: "payment-failed", error: settled.detail || settled.reason }))
196
- };
197
- }
198
-
199
207
  const requirements = (auth.authorization && auth.authorization.paymentRequirements) || {};
200
208
  const requestPayload = {
201
209
  ...ctx.deps.buildNamedTaskPayload(skillTaskRef, body, skillTaskRef),
@@ -212,36 +220,35 @@ async function handleMessageSend(rpc, ctx) {
212
220
  }
213
221
  };
214
222
 
223
+ // Verify -> execute -> settle: the payment is only settled once the skill has produced its
224
+ // result, so a failed skill never charges the paying agent.
215
225
  const exec = await ctx.deps.executeAuthorized(ctx.deps.state, requestPayload, authorizationContext);
216
- const receipt = ctx.deps.encodeReceipt(settled.settlement);
217
226
  const execOk = Boolean(exec) && exec.status === 200 && (!exec.payload || exec.payload.ok !== false);
218
227
  if (!execOk) {
219
- // The payment settled (money moved) but the skill did not execute. Report
220
- // that honestly as a failed task while still carrying the settlement receipt,
221
- // so the paying agent knows it paid and can pursue a refund/retry.
222
- const failed = {
223
- id: taskId,
224
- contextId,
225
- kind: "task",
226
- status: {
227
- state: "failed",
228
- message: agentMessage("Payment settled, but the requested skill failed to execute.", {
229
- [META_STATUS]: "payment-completed",
230
- [META_RECEIPTS]: [settled.settlement],
231
- execution_error: (exec && exec.payload) || "execution_failed",
232
- execution_status: exec && exec.status
233
- }),
234
- timestamp: new Date().toISOString()
235
- },
236
- artifacts: [],
237
- history: []
228
+ const reason = (exec && exec.payload && exec.payload.reason) || "execution_failed";
229
+ return {
230
+ http: 200,
231
+ body: jsonrpcResult(id, failedTask({ taskId, contextId, paymentStatus: "not-charged", error: `${reason}: the skill did not run, so the payment was not settled — retry` }))
238
232
  };
233
+ }
234
+
235
+ let settled = await ctx.deps.settleX402Payment({ authorization: auth.authorization, facilitator, settle: x402.settle });
236
+ if (!settled.ok && settled.reason === "facilitator_unreachable" && typeof ctx.deps.confirmAuthorizationUsed === "function") {
237
+ // The facilitator may have broadcast before timing out: if the authorization is used on-chain, it settled.
238
+ const signed = auth.authorization && auth.authorization.paymentPayload && auth.authorization.paymentPayload.payload
239
+ && auth.authorization.paymentPayload.payload.authorization;
240
+ const used = signed ? await ctx.deps.confirmAuthorizationUsed({ network: requirements.network, asset: requirements.asset, authorizer: signed.from, nonce: signed.nonce }) : null;
241
+ if (used === true) {
242
+ settled = { ok: true, settlement: { success: true, transaction: null, network: requirements.network, payer: signed.from, recovered_from: "authorization_state" } };
243
+ }
244
+ }
245
+ if (!settled.ok) {
239
246
  return {
240
247
  http: 200,
241
- headers: { "PAYMENT-RESPONSE": receipt, "Cache-Control": "no-store" },
242
- body: jsonrpcResult(id, failed)
248
+ body: jsonrpcResult(id, failedTask({ taskId, contextId, paymentStatus: "payment-failed", error: `${settled.detail || settled.reason} (the result was withheld; if this payment later shows as settled on-chain, contact us with the payment nonce)` }))
243
249
  };
244
250
  }
251
+ const receipt = ctx.deps.encodeReceipt(settled.settlement);
245
252
  const task = completedTask({ taskId, contextId, resultPayload: exec.payload, settlement: settled.settlement });
246
253
  return {
247
254
  http: 200,
@@ -498,11 +498,11 @@ function resolveSpendCredential(state, bearerToken, proof, requestBinding) {
498
498
  // proof of control of the agent key — the bearer token alone is not enough.
499
499
  if (credential.agent_public_key) {
500
500
  // Bind the proof to the actual request (account/amount/task) when the caller supplies a
501
- // request binding. `require` is set BY THE CALLER (requestBinding.require) so strict mode
502
- // applies only where a binding is actually supplied — the execute path. The reserve/
503
- // release/reverse lifecycle paths pass no binding, so they are unaffected by strict mode
504
- // (and remain capped by the authority-binding bounds). Default keeps back-compat: an
505
- // unbound proof still authorizes.
501
+ // request binding: a request-bound signature then verifies only for that exact request.
502
+ // `require` is set BY THE CALLER (requestBinding.require), so strict mode applies only to the
503
+ // execute path. The reserve/release/reverse lifecycle paths supply a binding WITHOUT
504
+ // `require`: bound signatures are checked against the request, unbound ones still authorize
505
+ // (back-compat), and both remain capped by the authority-binding bounds.
506
506
  const requireBinding = Boolean(requestBinding && requestBinding.require);
507
507
  const verdict = verifySpendAuthProof(credential.agent_public_key, credential.credential_id, proof || {}, {
508
508
  requestBinding: requestBinding || null,
@@ -13,9 +13,9 @@ function buildAsyncApiDocument() {
13
13
  },
14
14
  servers: {
15
15
  httpRuntime: {
16
- host: "127.0.0.1:4320",
17
- protocol: "http",
18
- description: "Local xytara HTTP event and handoff entrypoint"
16
+ host: String(process.env.XYTARA_PUBLIC_BASE_URL || "https://xytara.onrender.com").trim().replace(/^https?:\/\//, "").replace(/\/+$/, ""),
17
+ protocol: "https",
18
+ description: "Hosted xytara HTTP event and handoff entrypoint"
19
19
  }
20
20
  },
21
21
  channels: {
@@ -0,0 +1,215 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+
5
+ // Buyer-journey event log: every failed buyer attempt (ours, upstream, or the buyer's) is recorded
6
+ // so the operator can see who tried to use the system and why it did not work — plus counters for
7
+ // the normal steps (402 challenges issued, paid calls served) as denominators.
8
+ //
9
+ // Privacy: events never carry payment signatures/headers, request bodies, tokens, IP addresses or
10
+ // full wallet addresses. Payer addresses are reduced to a salted hash prefix; detail text is
11
+ // truncated and stripped of long hex/base64 runs.
12
+ //
13
+ // Storage: state.buyerEventLog (bounded ring, newest last) and state.buyerEventDaily (per-day
14
+ // counters, 30 days) are ordinary runtime state, so they persist with the Supabase snapshot and the
15
+ // daily off-site backup. The MCP executor forwards its own events to POST /v1/operator/buyer-events/ingest.
16
+
17
+ const DAILY_RETENTION_DAYS = 30;
18
+
19
+ function maxEvents() {
20
+ const value = Number(process.env.XYTARA_BUYER_EVENT_LOG_MAX || 500);
21
+ return Number.isFinite(value) && value >= 10 ? Math.min(5000, Math.floor(value)) : 500;
22
+ }
23
+
24
+ function ensureBuyerEventCollections(state) {
25
+ if (!Array.isArray(state.buyerEventLog)) state.buyerEventLog = [];
26
+ if (!(state.buyerEventDaily instanceof Map)) state.buyerEventDaily = new Map();
27
+ }
28
+
29
+ function cleanText(value, max) {
30
+ if (value === null || value === undefined) return null;
31
+ const text = String(value)
32
+ .replace(/0x[0-9a-fA-F]{40,}/g, "0x…")
33
+ .replace(/[A-Za-z0-9+/=_-]{40,}/g, "…")
34
+ .replace(/\s+/g, " ")
35
+ .trim();
36
+ return text ? text.slice(0, max) : null;
37
+ }
38
+
39
+ function payerHash(address) {
40
+ const value = String(address || "").trim().toLowerCase();
41
+ if (!value) return null;
42
+ const salt = String(process.env.XYTARA_BUYER_EVENT_SALT || "naxytra-buyer-events");
43
+ return crypto.createHash("sha256").update(`${salt}:${value}`).digest("hex").slice(0, 12);
44
+ }
45
+
46
+ // Path template without ids: segments that look like identifiers become ":id".
47
+ function routeTemplate(pathname) {
48
+ return String(pathname || "/")
49
+ .split("/")
50
+ .map((segment) => (/(^0x[0-9a-f]{6,})|([0-9a-f]{12,})|(_[0-9a-z]{6,}$)|(^\d{3,}$)/i.test(segment) ? ":id" : segment))
51
+ .join("/")
52
+ .slice(0, 120);
53
+ }
54
+
55
+ // reason -> [stage, fault]. fault: "ours" (our bug/infra), "upstream" (facilitator, RPC, provider),
56
+ // "buyer" (bad input / bad payment), "none" (expected protocol step).
57
+ const REASON_RULES = [
58
+ [/^(capability_not_executable|task_ref_required|task_not_found|unknown_tool)/, "route", "buyer"],
59
+ [/^(body_must_be_valid_json|invalid_json|request_body_too_large)/, "parse", "buyer"],
60
+ [/^payment_(signature_invalid|requirements_mismatch|header)/, "payment_match", "buyer"],
61
+ [/^payment_authorization_in_progress/, "payment_verify", "buyer"],
62
+ [/^payment_verification_failed|^invalid_exact/, "payment_verify", "buyer"],
63
+ [/^facilitator_|_unreachable$/, "payment_verify", "upstream"],
64
+ [/^(payment_settlement_failed|settlement_failed|settle_)/, "payment_settle", "upstream"],
65
+ [/^(external_execution|no_healthy_provider|provider_)/, "provider", "upstream"],
66
+ [/^(insufficient_credits|credit_spend_|delegated_)/, "credit", "buyer"],
67
+ [/^agent_signature|^spend_credential|_bearer|^unauthorized|^forbidden/, "auth", "buyer"],
68
+ [/^rate_limit/, "rate_limit", "buyer"],
69
+ [/^(state_restore_pending|runtime_state)/, "state", "ours"],
70
+ [/^quote_(expired|already_used)/, "quote", "buyer"]
71
+ ];
72
+
73
+ function classify(reason, httpStatus) {
74
+ const text = String(reason || "");
75
+ for (const [pattern, stage, fault] of REASON_RULES) {
76
+ if (pattern.test(text)) return { stage, fault };
77
+ }
78
+ if (httpStatus >= 500) return { stage: "internal", fault: "ours" };
79
+ if (httpStatus === 404) return { stage: "route", fault: "buyer" };
80
+ return { stage: "other", fault: httpStatus >= 400 && httpStatus < 500 ? "buyer" : "ours" };
81
+ }
82
+
83
+ function dayKey(iso) {
84
+ return String(iso).slice(0, 10);
85
+ }
86
+
87
+ function bumpDaily(state, iso, service, key) {
88
+ const day = dayKey(iso);
89
+ let bucket = state.buyerEventDaily.get(day);
90
+ if (!bucket) {
91
+ bucket = {};
92
+ state.buyerEventDaily.set(day, bucket);
93
+ // Drop days beyond retention (keys are ISO dates, so lexical order is chronological).
94
+ const days = Array.from(state.buyerEventDaily.keys()).sort();
95
+ while (days.length > DAILY_RETENTION_DAYS) state.buyerEventDaily.delete(days.shift());
96
+ }
97
+ const counterKey = `${service}|${key}`;
98
+ bucket[counterKey] = (Number(bucket[counterKey]) || 0) + 1;
99
+ }
100
+
101
+ // Count a normal step without logging an event (challenge issued, paid call served, ...).
102
+ function countBuyerStep(state, step, options = {}) {
103
+ try {
104
+ ensureBuyerEventCollections(state);
105
+ bumpDaily(state, new Date().toISOString(), options.service || "runtime", `step:${step}|${routeTemplate(options.route)}`);
106
+ } catch (_) { /* observability must never break a buyer response */ }
107
+ }
108
+
109
+ function recordBuyerEvent(state, input) {
110
+ try {
111
+ ensureBuyerEventCollections(state);
112
+ const at = typeof input.at_iso === "string" && !Number.isNaN(Date.parse(input.at_iso)) ? new Date(input.at_iso).toISOString() : new Date().toISOString();
113
+ const httpStatus = Number.isFinite(Number(input.http_status)) ? Number(input.http_status) : null;
114
+ const reason = cleanText(input.reason, 80) || (httpStatus ? `http_${httpStatus}` : "unknown");
115
+ const classified = classify(reason, httpStatus || 0);
116
+ const event = {
117
+ id: `bev_${crypto.randomBytes(6).toString("hex")}`,
118
+ at_iso: at,
119
+ service: cleanText(input.service, 40) || "runtime",
120
+ request_id: cleanText(input.request_id, 40),
121
+ stage: cleanText(input.stage, 30) || classified.stage,
122
+ reason,
123
+ fault: cleanText(input.fault, 10) || classified.fault,
124
+ http_status: httpStatus,
125
+ route: input.route ? routeTemplate(input.route) : null,
126
+ method: cleanText(input.method, 8),
127
+ task_or_tool: cleanText(input.task_or_tool, 80),
128
+ rail: cleanText(input.rail, 20),
129
+ network: cleanText(input.network, 30),
130
+ amount_atomic: /^\d{1,30}$/.test(String(input.amount_atomic || "")) ? String(input.amount_atomic) : null,
131
+ payer_hash: input.payer_hash ? cleanText(input.payer_hash, 16) : payerHash(input.payer),
132
+ money_moved: input.money_moved === true,
133
+ latency_ms: Number.isFinite(Number(input.latency_ms)) ? Math.round(Number(input.latency_ms)) : null,
134
+ detail: cleanText(input.detail, 160),
135
+ ua: cleanText(String(input.ua || "").split(/[\s/]/)[0], 32)
136
+ };
137
+ state.buyerEventLog.push(event);
138
+ const cap = maxEvents();
139
+ if (state.buyerEventLog.length > cap) state.buyerEventLog.splice(0, state.buyerEventLog.length - cap);
140
+ bumpDaily(state, at, event.service, `fail:${event.stage}:${event.reason}:${event.fault}`);
141
+ if (event.money_moved) bumpDaily(state, at, event.service, "money_moved");
142
+ return event;
143
+ } catch (_) {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ function listBuyerEvents(state, query = {}) {
149
+ ensureBuyerEventCollections(state);
150
+ const since = query.since && !Number.isNaN(Date.parse(query.since)) ? Date.parse(query.since) : null;
151
+ const limit = Math.max(1, Math.min(500, Number(query.limit) || 100));
152
+ const matches = state.buyerEventLog.filter((event) => {
153
+ if (since !== null && Date.parse(event.at_iso) < since) return false;
154
+ if (query.service && event.service !== query.service) return false;
155
+ if (query.stage && event.stage !== query.stage) return false;
156
+ if (query.fault && event.fault !== query.fault) return false;
157
+ if (query.money_moved === "true" && !event.money_moved) return false;
158
+ return true;
159
+ });
160
+ return { count: matches.length, events: matches.slice(-limit).reverse() };
161
+ }
162
+
163
+ function summarizeBuyerEvents(state, days = 7) {
164
+ ensureBuyerEventCollections(state);
165
+ const window = Math.max(1, Math.min(DAILY_RETENTION_DAYS, Number(days) || 7));
166
+ const keys = Array.from(state.buyerEventDaily.keys()).sort().slice(-window);
167
+ const totals = { failures: 0, ours: 0, upstream: 0, buyer: 0, money_moved: 0, challenges_issued: 0, paid_ok: 0 };
168
+ const byReason = {};
169
+ const perDay = keys.map((day) => {
170
+ const bucket = state.buyerEventDaily.get(day) || {};
171
+ const dayTotals = { day, failures: 0, ours: 0, upstream: 0, money_moved: 0, challenges_issued: 0, paid_ok: 0 };
172
+ for (const [key, count] of Object.entries(bucket)) {
173
+ const [service, rest] = [key.slice(0, key.indexOf("|")), key.slice(key.indexOf("|") + 1)];
174
+ if (rest === "money_moved") { dayTotals.money_moved += count; totals.money_moved += count; continue; }
175
+ if (rest.startsWith("step:challenge_issued")) { dayTotals.challenges_issued += count; totals.challenges_issued += count; continue; }
176
+ if (rest.startsWith("step:paid_ok")) { dayTotals.paid_ok += count; totals.paid_ok += count; continue; }
177
+ if (!rest.startsWith("fail:")) continue;
178
+ const [, stage, reason, fault] = rest.split(":");
179
+ dayTotals.failures += count;
180
+ totals.failures += count;
181
+ if (fault === "ours") { dayTotals.ours += count; totals.ours += count; }
182
+ if (fault === "upstream") { dayTotals.upstream += count; totals.upstream += count; }
183
+ if (fault === "buyer") totals.buyer += count;
184
+ const reasonKey = `${service}:${stage}:${reason}:${fault}`;
185
+ byReason[reasonKey] = (byReason[reasonKey] || 0) + count;
186
+ }
187
+ return dayTotals;
188
+ });
189
+ const topReasons = Object.entries(byReason)
190
+ .sort((a, b) => b[1] - a[1])
191
+ .slice(0, 25)
192
+ .map(([key, count]) => {
193
+ const [service, stage, reason, fault] = key.split(":");
194
+ return { service, stage, reason, fault, count };
195
+ });
196
+ return {
197
+ window_days: window,
198
+ totals,
199
+ // Needs attention: money moved without a delivered result, or failures that are our fault.
200
+ attention: totals.money_moved > 0 || totals.ours > 0,
201
+ per_day: perDay,
202
+ top_reasons: topReasons,
203
+ generated_at_iso: new Date().toISOString()
204
+ };
205
+ }
206
+
207
+ module.exports = {
208
+ countBuyerStep,
209
+ ensureBuyerEventCollections,
210
+ listBuyerEvents,
211
+ payerHash,
212
+ recordBuyerEvent,
213
+ routeTemplate,
214
+ summarizeBuyerEvents
215
+ };
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+
3
+ // Money-safety policy for the buyer path. Like the provider endpoint policy, each rule is
4
+ // "strict" by default on a hosted deploy (RENDER is set) and "permissive" locally, so the local
5
+ // verification suites (which exercise catalog tasks without a live execution target) keep working.
6
+ //
7
+ // - Paid execution: only a task that resolves to a REAL execution target (an operator-pinned
8
+ // env target or an active open-network provider) may be quoted for payment. Anything else is
9
+ // refused up front with capability_not_executable, so a buyer is never charged for a
10
+ // synthetic result. (XYTARA_PAID_EXECUTION_POLICY=strict|permissive)
11
+ // - Credit spend: spending an account's credits requires a spend credential (xytara_spend_…
12
+ // bearer) bound to that account. A bare account_id is never authority — payer addresses are
13
+ // public on-chain. (XYTARA_CREDIT_SPEND_POLICY=strict|permissive)
14
+
15
+ function policyMode(envName) {
16
+ const raw = String(process.env[envName] || "").trim().toLowerCase();
17
+ if (raw === "strict" || raw === "permissive") return raw;
18
+ return process.env.RENDER ? "strict" : "permissive";
19
+ }
20
+
21
+ function paidExecutionPolicyMode() {
22
+ return policyMode("XYTARA_PAID_EXECUTION_POLICY");
23
+ }
24
+
25
+ function creditSpendPolicyMode() {
26
+ return policyMode("XYTARA_CREDIT_SPEND_POLICY");
27
+ }
28
+
29
+ // Ledger/business reads (payer addresses, every quote, receipts, operations, revenue metrics) are
30
+ // operator-only on a hosted deploy. (XYTARA_LEDGER_READ_POLICY=strict|permissive)
31
+ function ledgerReadPolicyMode() {
32
+ return policyMode("XYTARA_LEDGER_READ_POLICY");
33
+ }
34
+
35
+ const PRIVATE_READ_PREFIXES = [
36
+ "/v1/payment-ledger",
37
+ "/v1/operator-export-pack",
38
+ "/v1/receipts",
39
+ "/v1/operations/",
40
+ "/v1/economics/accounts/",
41
+ "/v1/pricing-optimization/",
42
+ "/v1/revenue-signal",
43
+ "/v1/activity-ledger"
44
+ ];
45
+
46
+ // Whether a GET is a private ledger read. A single quote by id stays public (its holder needs it).
47
+ function isPrivateLedgerRead(method, pathname) {
48
+ if (String(method || "").toUpperCase() !== "GET") return false;
49
+ if (pathname === "/v1/payment-quotes") return true;
50
+ return PRIVATE_READ_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`) || pathname.startsWith(`${prefix}-`));
51
+ }
52
+
53
+ // Test networks: their USDC is free from faucets, so by default it cannot buy credits that are
54
+ // spendable on real (mainnet-paid-out) provider work. Pay-per-call on a testnet is unaffected.
55
+ const TESTNET_NETWORKS = new Set(["eip155:84532", "eip155:11155111"]);
56
+
57
+ function isTestnetNetwork(network) {
58
+ return TESTNET_NETWORKS.has(String(network || "").trim().toLowerCase());
59
+ }
60
+
61
+ // What one credit unit costs, in the payment asset's base units (USDC: 6 decimals). Default
62
+ // 2500 = $0.0025, so a decision-support call (3 units) costs $0.0075 — never more than the flat
63
+ // pay-per-call price for the executable lanes.
64
+ function creditUnitPriceAtomic() {
65
+ const value = Number(process.env.XYTARA_CREDIT_UNIT_PRICE_ATOMIC || 2500);
66
+ return Number.isFinite(value) && value >= 1 ? Math.floor(value) : 2500;
67
+ }
68
+
69
+ // A top-up is priced as a multiple of the per-call accepts amount (default 100x: $0.01 -> $1.00).
70
+ function topupAmountMultiplier() {
71
+ const value = Number(process.env.XYTARA_X402_TOPUP_AMOUNT_MULTIPLIER || 100);
72
+ return Number.isFinite(value) && value >= 1 ? Math.floor(value) : 100;
73
+ }
74
+
75
+ // The accepts a credit top-up is paid with: mainnet-only unless testnet top-ups are explicitly
76
+ // enabled, each amount scaled to the top-up price.
77
+ function buildTopupAccepts(accepts) {
78
+ const allowTestnet = String(process.env.XYTARA_X402_TESTNET_TOPUP || "").trim() === "1";
79
+ const multiplier = BigInt(topupAmountMultiplier());
80
+ return (Array.isArray(accepts) ? accepts : [])
81
+ .filter((accept) => accept && (allowTestnet || !isTestnetNetwork(accept.network)))
82
+ .filter((accept) => /^\d{1,30}$/.test(String(accept.amount)))
83
+ .map((accept) => ({ ...accept, amount: (BigInt(String(accept.amount)) * multiplier).toString() }));
84
+ }
85
+
86
+ // Credits granted for a settled top-up: always derived from what was actually paid, so a unit is
87
+ // never worth less than creditUnitPriceAtomic(). An accepts entry's own extra.credits may lower
88
+ // that, never raise it.
89
+ function topupUnitsForAccept(accept) {
90
+ const amount = accept && /^\d{1,30}$/.test(String(accept.amount)) ? Number(accept.amount) : 0;
91
+ const derived = Math.floor(amount / creditUnitPriceAtomic());
92
+ const explicit = accept && accept.extra ? Number(accept.extra.credits) : NaN;
93
+ return Number.isFinite(explicit) && explicit > 0 ? Math.min(Math.floor(explicit), derived) : derived;
94
+ }
95
+
96
+ // While the durable state has not been restored yet (boot, or a Supabase outage), balances read
97
+ // as empty: a credit spend would wrongly say "insufficient credits" and a top-up could be lost if
98
+ // the process died before the restore merged. Those routes answer 503 + Retry-After instead.
99
+ let stateRestorePendingProbe = () => false;
100
+
101
+ function setStateRestorePendingProbe(probe) {
102
+ stateRestorePendingProbe = typeof probe === "function" ? probe : () => false;
103
+ }
104
+
105
+ function isStateRestorePending() {
106
+ try {
107
+ return stateRestorePendingProbe() === true;
108
+ } catch (_) {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ const STATE_RESTORE_PENDING_RESPONSE = Object.freeze({
114
+ ok: false,
115
+ reason: "state_restore_pending",
116
+ detail: "Account balances are being restored after a restart. Retry in about 30 seconds; nothing was charged. Pay-per-call x402 (without payment_preference) works meanwhile."
117
+ });
118
+
119
+ module.exports = {
120
+ STATE_RESTORE_PENDING_RESPONSE,
121
+ isPrivateLedgerRead,
122
+ ledgerReadPolicyMode,
123
+ isStateRestorePending,
124
+ setStateRestorePendingProbe,
125
+ paidExecutionPolicyMode,
126
+ creditSpendPolicyMode,
127
+ isTestnetNetwork,
128
+ creditUnitPriceAtomic,
129
+ topupAmountMultiplier,
130
+ buildTopupAccepts,
131
+ topupUnitsForAccept
132
+ };