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 +11 -1
- package/README.md +4 -0
- package/REAL_PAYMENT_SETUP.md +49 -1
- package/RELEASE_NOTES.md +12 -13
- package/examples/generated.carried-handoff.json +30 -0
- package/lib/command_flow.js +8 -3
- package/lib/payment_config.js +49 -0
- package/lib/payment_verification.js +51 -3
- package/lib/x402_facilitator.js +155 -0
- package/package.json +3 -2
- package/scripts/verify_x402_facilitator.js +194 -0
- package/server.js +143 -18
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;
|
|
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
|
package/REAL_PAYMENT_SETUP.md
CHANGED
|
@@ -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
|
|
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
|
+
`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
|
|
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
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
}
|
package/lib/command_flow.js
CHANGED
|
@@ -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
|
|
package/lib/payment_config.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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.
|
|
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:
|
|
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
|
-
|
|
8913
|
-
|
|
8914
|
-
|
|
8915
|
-
|
|
8916
|
-
|
|
8917
|
-
|
|
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
|
|