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 +30 -3
- package/REAL_PAYMENT_SETUP.md +14 -7
- package/RELEASE_NOTES.md +8 -5
- package/fixtures/catalog.js +22 -0
- package/lib/a2a_rpc.js +37 -30
- package/lib/account_auth.js +5 -5
- package/lib/asyncapi_contract.js +3 -3
- package/lib/buyer_event_log.js +215 -0
- package/lib/buyer_safety_policy.js +132 -0
- package/lib/command_flow.js +223 -34
- package/lib/http_transport.js +23 -5
- package/lib/openapi_contract.js +4 -0
- package/lib/payment_config.js +5 -1
- package/lib/provider_registry.js +11 -1
- package/lib/runtime_state_store.js +4 -0
- package/lib/x402_facilitator.js +8 -1
- package/lib/x402_lane_contract.js +5 -3
- package/lib/x402_settlement_recovery.js +175 -0
- package/package.json +7 -3
- package/scripts/buyer_events_daily_report.js +79 -0
- package/scripts/verify_a2a_rpc.js +32 -6
- package/scripts/verify_buyer_event_log.js +123 -0
- package/scripts/verify_buyer_safety.js +141 -0
- package/scripts/verify_lifecycle_spend_binding.js +88 -0
- package/scripts/verify_verify_execute_settle.js +2 -1
- package/scripts/verify_x402_facilitator.js +27 -6
- package/scripts/verify_x402_settlement_recovery.js +153 -0
- package/server.js +330 -64
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
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
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=
|
package/REAL_PAYMENT_SETUP.md
CHANGED
|
@@ -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
|
|
54
|
-
|
|
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,
|
|
57
|
-
settlement receipt
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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.
|
|
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
|
-
- **
|
|
4
|
-
- **
|
|
5
|
-
- **
|
|
6
|
-
- **
|
|
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.
|
package/fixtures/catalog.js
CHANGED
|
@@ -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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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,
|
package/lib/account_auth.js
CHANGED
|
@@ -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
|
|
502
|
-
//
|
|
503
|
-
// release/reverse lifecycle paths
|
|
504
|
-
//
|
|
505
|
-
//
|
|
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,
|
package/lib/asyncapi_contract.js
CHANGED
|
@@ -13,9 +13,9 @@ function buildAsyncApiDocument() {
|
|
|
13
13
|
},
|
|
14
14
|
servers: {
|
|
15
15
|
httpRuntime: {
|
|
16
|
-
host: "
|
|
17
|
-
protocol: "
|
|
18
|
-
description: "
|
|
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
|
+
};
|