callx402 1.0.2 → 1.0.4
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/.github/workflows/npm-publish.yml +4 -4
- package/.github/workflows/review-ci.yml +24 -0
- package/README.md +87 -11
- package/bin/callx402.js +17 -9
- package/core/payer-auth.js +59 -0
- package/core/rail.js +99 -15
- package/core/subsystems.js +10 -2
- package/docs/problem-map.md +19 -8
- package/docs/signed-redemption.md +84 -0
- package/integrations/core.py +11 -2
- package/integrations/langchain/callx402_tools.py +5 -2
- package/mcp/index.js +46 -13
- package/package.json +2 -2
- package/server.json +2 -2
- package/test/payer-auth.test.js +92 -0
- package/mcp-publisher +0 -0
|
@@ -15,14 +15,14 @@ jobs:
|
|
|
15
15
|
steps:
|
|
16
16
|
- uses: actions/checkout@v4
|
|
17
17
|
|
|
18
|
-
- run: npm install -g npm@latest
|
|
19
18
|
- uses: actions/setup-node@v4
|
|
20
19
|
with:
|
|
21
|
-
node-version: '
|
|
20
|
+
node-version: '22'
|
|
22
21
|
registry-url: 'https://registry.npmjs.org'
|
|
23
|
-
|
|
24
|
-
- run: npm
|
|
22
|
+
- run: npm install -g npm@latest
|
|
23
|
+
- run: npm ci --no-audit --no-fund
|
|
25
24
|
- run: npm test
|
|
25
|
+
- run: npm pack --dry-run
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
- name: Publish to npm via Trusted Publishing (OIDC, no token)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: callx402 Review Tests (no live payments)
|
|
2
|
+
on:
|
|
3
|
+
pull_request:
|
|
4
|
+
branches: [main]
|
|
5
|
+
workflow_dispatch:
|
|
6
|
+
permissions:
|
|
7
|
+
contents: read
|
|
8
|
+
jobs:
|
|
9
|
+
verify:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-node@v4
|
|
14
|
+
with:
|
|
15
|
+
node-version: "22"
|
|
16
|
+
- run: npm ci
|
|
17
|
+
- run: npm test
|
|
18
|
+
- run: node --check core/rail.js
|
|
19
|
+
- run: node --check core/payer-auth.js
|
|
20
|
+
- run: node --check bin/callx402.js
|
|
21
|
+
- name: Validate package/lockfile version parity
|
|
22
|
+
run: node -e "const p=require('./package.json');const l=require('./package-lock.json');if(p.version!==l.version||p.version!==l.packages[''].version)process.exit(1)"
|
|
23
|
+
- name: Verify npm release archive without publishing
|
|
24
|
+
run: npm pack --dry-run
|
package/README.md
CHANGED
|
@@ -301,30 +301,106 @@ read-only diagnostic server-side and returns the result**. No local setup, no
|
|
|
301
301
|
separate tree, no keys. The rail never fakes execution: every result is
|
|
302
302
|
produced by the diagnostic modules over the evidence you supply.
|
|
303
303
|
|
|
304
|
-
The quote-before-payment flow
|
|
304
|
+
The hosted quote-before-payment flow:
|
|
305
305
|
|
|
306
306
|
```sh
|
|
307
|
-
# 1.
|
|
307
|
+
# 1. Obtain a free quote. No money moves.
|
|
308
308
|
curl "https://payload-rail.fly.dev/v1/callx402/quote?action=resolve&path=x402"
|
|
309
|
-
# -> {"quote_id":"...","quoted_price_usd":"0.25", ...}
|
|
310
309
|
|
|
311
|
-
# 2.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
310
|
+
# 2. Run the interactive client. Pay explicitly by card or with USDC.
|
|
311
|
+
# For USDC redemption, the paying wallet must EIP-191-sign the exact
|
|
312
|
+
# authorization message displayed by the CLI.
|
|
313
|
+
callx402 resolve --evidence '{"txHash":"0x..."}'
|
|
315
314
|
|
|
316
|
-
# 3.
|
|
315
|
+
# 3. Agents with an external wallet-produced signature can redeem the
|
|
316
|
+
# ORIGINAL canonical quote with a signed authorization file:
|
|
317
|
+
callx402 resolve --evidence '{"txHash":"0x..."}' \
|
|
318
|
+
--tx-hash 0xYOUR_SETTLED_PAYMENT_HASH \
|
|
319
|
+
--payer-auth @signed-auth.json --approve
|
|
317
320
|
```
|
|
318
321
|
|
|
322
|
+
A public transaction hash alone never authorizes an action. The signed authorization
|
|
323
|
+
binds the paying wallet, action, transaction hash, quote, recipient, network,
|
|
324
|
+
and request inputs. Keep the original `quote_inputs` with the signed authorization;
|
|
325
|
+
requesting a new quote does not authorize an older payment. **Never give Payload
|
|
326
|
+
a private key, seed phrase, or wallet recovery phrase.** EIP-1271 contract-wallet
|
|
327
|
+
redemption is not currently enabled. Stripe credits use a separate redemption path.
|
|
328
|
+
|
|
319
329
|
Live fee schedule: `GET https://payload-rail.fly.dev/v1/callx402/actions`
|
|
320
330
|
(responds `"model":"paid on-demand per action; no subscription required"`).
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
331
|
+
There are two prices per action, and they are not interchangeable: the
|
|
332
|
+
**card price** is the Stripe schedule fee; the **USDC price** on the x402
|
|
333
|
+
path is exactly that fee divided by 20 (the free quote endpoint is the
|
|
334
|
+
source of truth for the USDC column). Both, side by side:
|
|
335
|
+
|
|
336
|
+
| Action | Card (Stripe) | USDC (x402 path) |
|
|
337
|
+
|---|---|---|
|
|
338
|
+
| `diagnose` | $2.00 | $0.10 |
|
|
339
|
+
| `explain` | $1.00 | $0.05 |
|
|
340
|
+
| `evidence` | $2.00 | $0.10 |
|
|
341
|
+
| `recover` | $10.00 | $0.50 |
|
|
342
|
+
| `resolve` | $5.00 | $0.25 |
|
|
343
|
+
| `preflight` | $3.00 | $0.15 |
|
|
344
|
+
| `settlement_interpretation` | $3.00 | $0.15 |
|
|
345
|
+
| `failure_classification` | $2.00 | $0.10 |
|
|
346
|
+
| `duplicate_payment_risk` | $5.00 | $0.25 |
|
|
347
|
+
|
|
348
|
+
Rail `evidence` and `explain` read back a prior paid invocation (the
|
|
349
|
+
request carries that invocation's `invocation_id`), so they cannot be used
|
|
350
|
+
cold; the free local forms read your local operation ledger instead.
|
|
351
|
+
|
|
352
|
+
**Not sold on the rail:** `safe_retry`, `rescue`, `monitor`, and `execute`
|
|
353
|
+
are local-only. They run in the self-hosted runtime (v2.0.0 tree), are
|
|
354
|
+
absent from the rail catalog above, and the rail refuses them with
|
|
355
|
+
`422 HOSTED_ACTION_UNAVAILABLE` — there is no Stripe or x402 purchase path
|
|
356
|
+
for them. For the "is this retry safe?" question, the free local `recover`
|
|
357
|
+
verdict is the available read-only answer.
|
|
358
|
+
|
|
359
|
+
Never blind-retry a payment to reach a paid action: get the quote first.
|
|
324
360
|
|
|
325
361
|
Problem to action map (which paid action answers which failure, with the free
|
|
326
362
|
CLI/MCP path for each): [`docs/problem-map.md`](docs/problem-map.md).
|
|
327
363
|
|
|
364
|
+
## Public RevRule evaluation (0.01 USDC)
|
|
365
|
+
|
|
366
|
+
The rail also exposes one public RevRule evaluation endpoint. It is a
|
|
367
|
+
RevRule demo, not a callx402 diagnostic action:
|
|
368
|
+
`POST https://payload-rail.fly.dev/v1/x402/public/evaluate` evaluates one
|
|
369
|
+
economic event against the fixed, transparent public demo graph (platform
|
|
370
|
+
5% of gross; creator 70% of the remainder; referrer 25% of the remainder
|
|
371
|
+
when a referrer is named in the event) for 0.01 USDC on Base.
|
|
372
|
+
|
|
373
|
+
The full, correct request body is:
|
|
374
|
+
|
|
375
|
+
```json
|
|
376
|
+
{
|
|
377
|
+
"event": {
|
|
378
|
+
"eventId": "my-event-001",
|
|
379
|
+
"type": "SALE_COMPLETED",
|
|
380
|
+
"rail": "stripe",
|
|
381
|
+
"amountMicros": 100000000,
|
|
382
|
+
"currency": "USD",
|
|
383
|
+
"occurredAt": "2026-10-13T00:00:00.000Z"
|
|
384
|
+
},
|
|
385
|
+
"txHash": "0x<your settled 0.01 USDC payment on Base>"
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
- `event.eventId` (any unique non-empty string) and `event.rail` are
|
|
390
|
+
**required**, even though the 402's advertised bazaar schema omits both.
|
|
391
|
+
Valid `rail` values: `stripe`, `ach`, `x402`, `manual`, `stablecoin`.
|
|
392
|
+
- `event.type` must be one of: `SALE_COMPLETED`, `API_PAYMENT`,
|
|
393
|
+
`LICENSE_PAID`, `SUBSCRIPTION_PAYMENT`, `REFERRAL_CONVERTED`,
|
|
394
|
+
`ROYALTY_RECEIVED`, `CAMPAIGN_MILESTONE`, `MARKETPLACE_TRANSACTION`,
|
|
395
|
+
`TOUCH`.
|
|
396
|
+
- The event is validated **before** payment: a malformed body gets
|
|
397
|
+
`400 INVALID_EVENT`, not a 402 (verified live: the body above, minus
|
|
398
|
+
`txHash`, returns the 402). The endpoint fills in the rest itself —
|
|
399
|
+
`graphId` is fixed to the public demo graph, and `currency` defaults to
|
|
400
|
+
`"USD"` and `occurredAt` to now when omitted.
|
|
401
|
+
- Omit `txHash` to receive the x402 v2 `402` challenge; pay 0.01 USDC,
|
|
402
|
+
then retry the same body with the hash. Each txHash is single-use.
|
|
403
|
+
|
|
328
404
|
## Integrations
|
|
329
405
|
|
|
330
406
|
Working entry points for agent frameworks, automation, MCP clients, and x402
|
package/bin/callx402.js
CHANGED
|
@@ -222,9 +222,11 @@ never executes, never repays. Requires PAYLOAD_VEYLINE_RECOVERY=1.
|
|
|
222
222
|
|
|
223
223
|
This is the read-only answer to "is a retry safe?" — a SAFE_RETRY verdict
|
|
224
224
|
here is advisory; performing the safe action stays with your own adapters.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
225
|
+
There is NO paid rail form of 'safe_retry': the hosted rail refuses it as
|
|
226
|
+
local-only (it is absent from the rail catalog and cannot be bought via
|
|
227
|
+
Stripe or x402). This free recover verdict is the read-only answer
|
|
228
|
+
available without the self-hosted runtime tree (see docs/problem-map.md).
|
|
229
|
+
The MCP tool x402_recover covers the same free diagnostic.
|
|
228
230
|
|
|
229
231
|
Exit codes: 0 ok · 1 failure · 2 usage (missing identity/evidence) ·
|
|
230
232
|
3 disabled/unreachable · 4 budget refused ·
|
|
@@ -256,14 +258,16 @@ Usage:
|
|
|
256
258
|
callx402 "natural language intent" intent mode (plans, never executes blindly)
|
|
257
259
|
callx402 --help | --version
|
|
258
260
|
|
|
259
|
-
Commands (hosted by default — zero setup
|
|
261
|
+
Commands (rail actions hosted by default — zero setup; local-only
|
|
262
|
+
commands marked, they need the v2.0.0 runtime tree and are NOT sold on
|
|
263
|
+
the hosted rail):
|
|
260
264
|
diagnose run the x402 doctor over supplied evidence [--target <url|json>] [--evidence <json|@file>]
|
|
261
|
-
rescue incident triage (auth-gated; read-only without auth) --incident <id>
|
|
265
|
+
rescue incident triage, local only (auth-gated; read-only without auth) --incident <id>
|
|
262
266
|
route select a tool/route for a goal --goal <text>
|
|
263
267
|
resolve resolve settlement state from evidence --evidence <json|@file>
|
|
264
268
|
doctor alias of diagnose
|
|
265
|
-
execute run the intent pipeline
|
|
266
|
-
monitor sentinel snapshot [--once] or stream [--watch]
|
|
269
|
+
execute run the intent pipeline, local only --intent <text> [--max-budget N] [--dry-run] [--idempotency-key K]
|
|
270
|
+
monitor sentinel snapshot, local only [--once] or stream [--watch]
|
|
267
271
|
status per-subsystem reachable/enabled/version report
|
|
268
272
|
preflight run MCP preflight checks
|
|
269
273
|
inspect capability-graph lookup/stats [--query <text>]
|
|
@@ -305,6 +309,7 @@ function parseArgv(argv) {
|
|
|
305
309
|
const KNOWN_FLAGS = new Set([
|
|
306
310
|
'help', 'version', 'json', 'timeout', 'maxBudget', 'approvalThreshold',
|
|
307
311
|
'idempotencyKey', 'evidence', 'incident', 'goal', 'intent', 'query',
|
|
312
|
+
'txHash', 'payerAuth', 'creditId',
|
|
308
313
|
'target', 'speed', 'interval', 'network', 'networks', 'asset', 'assets',
|
|
309
314
|
'providers', 'deadline', 'risk',
|
|
310
315
|
'dryRun', 'approve', 'forceRetry', 'retry', 'auth', 'watch', 'once',
|
|
@@ -324,7 +329,8 @@ function parseArgv(argv) {
|
|
|
324
329
|
i += 1;
|
|
325
330
|
continue;
|
|
326
331
|
}
|
|
327
|
-
const NEEDS_VALUE = new Set(['timeout', 'maxBudget', 'approvalThreshold', 'idempotencyKey', 'evidence', 'incident', 'goal', 'intent', 'query', 'target',
|
|
332
|
+
const NEEDS_VALUE = new Set(['timeout', 'maxBudget', 'approvalThreshold', 'idempotencyKey', 'evidence', 'incident', 'goal', 'intent', 'query', 'target',
|
|
333
|
+
'txHash', 'payerAuth', 'creditId', 'speed', 'interval', 'network', 'networks', 'asset', 'assets', 'providers', 'deadline', 'risk', 'dir', 'operationId', 'identity']);
|
|
328
334
|
if (NEEDS_VALUE.has(name)) {
|
|
329
335
|
if (val !== null) out.flags[name] = val;
|
|
330
336
|
else if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) out.flags[name] = argv[++i];
|
|
@@ -503,7 +509,9 @@ async function main() {
|
|
|
503
509
|
// payment/authorization -> result. Set CALLX402_LOCAL=1 to prefer the
|
|
504
510
|
// local v2.0.0 runtime tree instead (advanced self-hosted mode).
|
|
505
511
|
if (rail.isRailCommand(actionName) && !rail.preferLocal()) {
|
|
506
|
-
const railResult = await rail.invokeRail(actionName,
|
|
512
|
+
const railResult = await rail.invokeRail(actionName,
|
|
513
|
+
{ ...args, txHash: flags.txHash, payerAuth: flags.payerAuth, creditId: flags.creditId },
|
|
514
|
+
{ yes: args.approve, json: args.json });
|
|
507
515
|
if (args.json) {
|
|
508
516
|
console.log(JSON.stringify(railResult, null, 2));
|
|
509
517
|
} else if (railResult.ok) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The client never signs messages or accepts wallet private keys.
|
|
5
|
+
* An external wallet signs the EIP-191 message and supplies a 65-byte signature.
|
|
6
|
+
* This canonical message MUST remain in lockstep with Rail payer-auth.ts.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const { createHash } = require('node:crypto');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
|
|
12
|
+
function actionRequestHash(action, body) {
|
|
13
|
+
const data = {
|
|
14
|
+
action,
|
|
15
|
+
evidence: body.evidence ?? null,
|
|
16
|
+
invocation_id: body.invocation_id ?? null,
|
|
17
|
+
context: body.context ?? null,
|
|
18
|
+
operation_id: body.operation_id ?? null,
|
|
19
|
+
target: body.target ?? null,
|
|
20
|
+
identity: body.identity ?? null,
|
|
21
|
+
};
|
|
22
|
+
return '0x' + createHash('sha256').update(JSON.stringify(data), 'utf8').digest('hex');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildAuthMessage(auth) {
|
|
26
|
+
return [
|
|
27
|
+
'Payload x402 Authorization',
|
|
28
|
+
`wallet:${String(auth.wallet).toLowerCase()}`,
|
|
29
|
+
`action:${auth.action}`,
|
|
30
|
+
`txHash:${String(auth.txHash).toLowerCase()}`,
|
|
31
|
+
`quote_id:${auth.quote_id}`,
|
|
32
|
+
`request_hash:${auth.request_hash}`,
|
|
33
|
+
`network:${auth.network}`,
|
|
34
|
+
`recipient:${String(auth.recipient).toLowerCase()}`,
|
|
35
|
+
`nonce:${auth.nonce}`,
|
|
36
|
+
`expiry:${auth.expiry}`,
|
|
37
|
+
].join('\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readSignedAuthorization(value) {
|
|
41
|
+
if (!value || typeof value !== 'string') throw new Error('payer authorization JSON or @file required');
|
|
42
|
+
const raw = value.startsWith('@') ?
|
|
43
|
+
fs.readFileSync(path.resolve(value.slice(1)), 'utf8') : value;
|
|
44
|
+
const obj = JSON.parse(raw);
|
|
45
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('expected an object');
|
|
46
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(obj.wallet || '')) throw new Error('invalid wallet');
|
|
47
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(obj.txHash || '')) throw new Error('invalid txHash');
|
|
48
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(obj.recipient || '')) throw new Error('invalid recipient');
|
|
49
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(obj.request_hash || '')) throw new Error('invalid action request hash');
|
|
50
|
+
if (!/^0x[0-9a-fA-F]{130}$/.test(obj.signature || '')) throw new Error('invalid EIP-191 signature');
|
|
51
|
+
if (typeof obj.action !== 'string' || typeof obj.network !== 'string' ||
|
|
52
|
+
typeof obj.quote_id !== 'string' || typeof obj.nonce !== 'string' ||
|
|
53
|
+
!Number.isSafeInteger(obj.expiry) || obj.expiry <= Math.floor(Date.now() / 1000)) {
|
|
54
|
+
throw new Error('malformed or expired authorization fields');
|
|
55
|
+
}
|
|
56
|
+
return obj;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { buildAuthMessage, actionRequestHash, readSignedAuthorization };
|
package/core/rail.js
CHANGED
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
* Nothing here signs, holds keys, or moves money.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
const { randomBytes } = require('node:crypto');
|
|
22
|
+
const { buildAuthMessage, actionRequestHash, readSignedAuthorization } = require('./payer-auth.js');
|
|
21
23
|
const RAIL_BASE = process.env.CALLX402_RAIL || 'https://payload-rail.fly.dev';
|
|
22
24
|
const RAIL_ACTIONS = [
|
|
23
|
-
'diagnose', 'doctor', '
|
|
24
|
-
'
|
|
25
|
-
'settlement_interpretation', '
|
|
26
|
-
'duplicate_payment_risk',
|
|
25
|
+
'diagnose', 'doctor', 'resolve', 'preflight',
|
|
26
|
+
'evidence', 'explain', 'recover',
|
|
27
|
+
'settlement_interpretation', 'failure_classification', 'duplicate_payment_risk',
|
|
27
28
|
];
|
|
28
29
|
|
|
29
30
|
// Map CLI command names to rail action names.
|
|
@@ -120,12 +121,27 @@ async function invokeRail(command, args, opts = {}) {
|
|
|
120
121
|
const evidence = buildEvidence(action, args);
|
|
121
122
|
const interactive = process.stdin.isTTY && !opts.yes;
|
|
122
123
|
|
|
123
|
-
// 1.
|
|
124
|
+
// 1. Fresh quote for new actions; previously paid agent redemptions
|
|
125
|
+
// must reuse their ORIGINAL signed quote. A fresh quote has a different
|
|
126
|
+
// issuedAt/quote_id and would invalidate an otherwise authentic signature.
|
|
124
127
|
let quote;
|
|
128
|
+
let signedAuthorization = null;
|
|
125
129
|
try {
|
|
126
|
-
|
|
130
|
+
if (args.txHash && args.payerAuth) {
|
|
131
|
+
signedAuthorization = readSignedAuthorization(String(args.payerAuth));
|
|
132
|
+
if (!signedAuthorization.quote_inputs || typeof signedAuthorization.quote_inputs !== 'object') {
|
|
133
|
+
throw new Error('Signed authorization file must include the canonical quote_inputs from the original quote.');
|
|
134
|
+
}
|
|
135
|
+
quote = {
|
|
136
|
+
quote_id: signedAuthorization.quote_id,
|
|
137
|
+
quote_inputs: signedAuthorization.quote_inputs,
|
|
138
|
+
quoted_price_usd: signedAuthorization.quoted_price_usd ?? 'see signed quote',
|
|
139
|
+
};
|
|
140
|
+
} else {
|
|
141
|
+
quote = await getQuote(action);
|
|
142
|
+
}
|
|
127
143
|
} catch (err) {
|
|
128
|
-
return { ok: false, error: `
|
|
144
|
+
return { ok: false, error: `Unable to load a valid payment quote: ${err.message}` };
|
|
129
145
|
}
|
|
130
146
|
const price = quote.quoted_price_usd;
|
|
131
147
|
const quoteId = quote.quote_id;
|
|
@@ -140,8 +156,8 @@ async function invokeRail(command, args, opts = {}) {
|
|
|
140
156
|
if (!interactive) {
|
|
141
157
|
return {
|
|
142
158
|
ok: false,
|
|
143
|
-
error: `Payment of $${price} required. Re-run with --
|
|
144
|
-
quote: { action, price_usd: price, quote_id: quoteId },
|
|
159
|
+
error: `Payment of $${price} required. Re-run with --approve to approve non-interactively, or run interactively.`,
|
|
160
|
+
quote: { action, price_usd: price, quote_id: quoteId, quote_inputs: quote.quote_inputs },
|
|
145
161
|
};
|
|
146
162
|
}
|
|
147
163
|
const approve = await prompt(` Approve $${price} for '${action}'? [y/N] `);
|
|
@@ -151,17 +167,52 @@ async function invokeRail(command, args, opts = {}) {
|
|
|
151
167
|
}
|
|
152
168
|
|
|
153
169
|
// 3. Payment: x402 (paste tx hash) or card (browser checkout -> credit ID).
|
|
154
|
-
let payBody = { evidence, quote_id: quoteId, quote };
|
|
155
|
-
if (
|
|
170
|
+
let payBody = { evidence, quote_id: quoteId, quote: quote.quote_inputs || {} };
|
|
171
|
+
if ((action === 'evidence' || action === 'explain') && args.operationId) {
|
|
172
|
+
payBody.invocation_id = String(args.operationId);
|
|
173
|
+
}
|
|
174
|
+
// Agents can supply a wallet-produced EIP-191 signature through a file;
|
|
175
|
+
// private keys are never requested or handled by callx402.
|
|
176
|
+
if (args.creditId) {
|
|
177
|
+
payBody = { ...payBody, credit_id: String(args.creditId) };
|
|
178
|
+
} else if (args.txHash && args.payerAuth) {
|
|
179
|
+
if (!quote.quote_inputs) return { ok: false, error: 'Rail must return canonical quote_inputs before signed redemption.' };
|
|
180
|
+
const auth = signedAuthorization;
|
|
181
|
+
if (auth.txHash?.toLowerCase() !== String(args.txHash).toLowerCase() ||
|
|
182
|
+
auth.action !== action || auth.quote_id !== quoteId ||
|
|
183
|
+
auth.request_hash !== actionRequestHash(action, payBody)) {
|
|
184
|
+
return { ok: false, error: 'Signed authorization does not match the transaction, action, and current quote.' };
|
|
185
|
+
}
|
|
186
|
+
payBody = { ...payBody, txHash: String(args.txHash).toLowerCase(), payer_auth: auth };
|
|
187
|
+
}
|
|
188
|
+
if (!payBody.credit_id && !payBody.payer_auth && !interactive) {
|
|
156
189
|
return {
|
|
157
190
|
ok: false,
|
|
158
|
-
error: '
|
|
191
|
+
error: 'Non-interactive payment requires --credit-id or --tx-hash with --payer-auth @file.',
|
|
159
192
|
quote: { action, price_usd: price, quote_id: quoteId },
|
|
160
|
-
payment_instructions: `
|
|
193
|
+
payment_instructions: `Use --credit-id or --tx-hash plus --payer-auth @signed-auth.json after an authorized wallet signature; a bare txHash is never enough.`,
|
|
161
194
|
};
|
|
162
195
|
}
|
|
196
|
+
if (!payBody.credit_id && !payBody.payer_auth) {
|
|
163
197
|
const method = await prompt(' Pay with [1] USDC on Base (paste tx hash after paying) or [2] card via browser? [1/2] ');
|
|
164
198
|
if (method === '2') {
|
|
199
|
+
// Stripe is a distinct price path; never imply its fee equals the
|
|
200
|
+
// previously displayed micropayment quote.
|
|
201
|
+
let cardPrice;
|
|
202
|
+
try {
|
|
203
|
+
const catalog = await railFetch('/v1/callx402/actions');
|
|
204
|
+
const item = catalog.body?.actions?.find(a => a.action === action);
|
|
205
|
+
if (catalog.status !== 200 || !Number.isSafeInteger(item?.amount_cents) || item.amount_cents <= 0) {
|
|
206
|
+
throw new Error('card fee not available');
|
|
207
|
+
}
|
|
208
|
+
cardPrice = (item.amount_cents / 100).toFixed(2);
|
|
209
|
+
} catch {
|
|
210
|
+
return { ok: false, error: 'Could not confirm the exact card price. Checkout cancelled before payment.' };
|
|
211
|
+
}
|
|
212
|
+
const cardApproval = await prompt(` Card checkout total $ ${cardPrice} USD for '${action}'. Continue? [y/N] `);
|
|
213
|
+
if (!/^y(es)?$/i.test(cardApproval)) {
|
|
214
|
+
return { ok: false, error: 'Card checkout cancelled before payment.' };
|
|
215
|
+
}
|
|
165
216
|
// Stripe checkout path.
|
|
166
217
|
const { status, body } = await railFetch('/v1/callx402/checkout', {
|
|
167
218
|
method: 'POST',
|
|
@@ -179,10 +230,11 @@ async function invokeRail(command, args, opts = {}) {
|
|
|
179
230
|
if (!creditId) return { ok: false, error: 'No credit ID provided.' };
|
|
180
231
|
payBody = { ...payBody, credit_id: creditId };
|
|
181
232
|
} else {
|
|
233
|
+
if (!quote.quote_inputs) return { ok: false, error: 'Rail must return canonical quote_inputs before USDC payment.' };
|
|
182
234
|
// x402 path: get exact payment terms from the 402, then user pays.
|
|
183
235
|
const { status, body } = await railFetch(`/v1/callx402/actions/${action}`, {
|
|
184
236
|
method: 'POST',
|
|
185
|
-
body: JSON.stringify({ evidence, quote_id: quoteId, quote }),
|
|
237
|
+
body: JSON.stringify({ evidence, quote_id: quoteId, quote: quote.quote_inputs }),
|
|
186
238
|
});
|
|
187
239
|
if (status !== 402) {
|
|
188
240
|
return { ok: false, error: `Expected a 402 payment challenge, got HTTP ${status}.` };
|
|
@@ -196,7 +248,33 @@ async function invokeRail(command, args, opts = {}) {
|
|
|
196
248
|
if (!/^0x[0-9a-fA-F]{64}$/.test(txHash)) {
|
|
197
249
|
return { ok: false, error: 'Invalid transaction hash format.' };
|
|
198
250
|
}
|
|
199
|
-
|
|
251
|
+
const wallet = await prompt(' Payer wallet address (0x...): ');
|
|
252
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(wallet)) {
|
|
253
|
+
return { ok: false, error: 'A valid Ethereum payer wallet address is required.' };
|
|
254
|
+
}
|
|
255
|
+
const auth = {
|
|
256
|
+
wallet, action, txHash: txHash.toLowerCase(), quote_id: quoteId,
|
|
257
|
+
request_hash: actionRequestHash(action, payBody),
|
|
258
|
+
network: accept.network || 'eip155:8453',
|
|
259
|
+
recipient: accept.payTo,
|
|
260
|
+
nonce: '0x' + randomBytes(16).toString('hex'),
|
|
261
|
+
expiry: Math.floor(Date.now() / 1000) + 300,
|
|
262
|
+
};
|
|
263
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(auth.recipient || '')) {
|
|
264
|
+
return { ok: false, error: 'The payment challenge omitted a valid recipient; refusing redemption.' };
|
|
265
|
+
}
|
|
266
|
+
const signingMessage = buildAuthMessage(auth);
|
|
267
|
+
console.log('\n Sign the exact message below using your paying wallet (EIP-191 personal_sign).');
|
|
268
|
+
console.log(' NEVER paste a seed phrase, private key, or recovery code.\n');
|
|
269
|
+
console.log(signingMessage);
|
|
270
|
+
console.log();
|
|
271
|
+
const signature = await prompt(' Paste wallet signature (0x...): ');
|
|
272
|
+
if (!/^0x[0-9a-fA-F]{130}$/.test(signature)) {
|
|
273
|
+
return { ok: false, error: 'Expected a 65-byte Ethereum personal_sign signature.' };
|
|
274
|
+
}
|
|
275
|
+
payBody = { ...payBody, txHash: txHash.toLowerCase(),
|
|
276
|
+
payer_auth: { ...auth, signature } };
|
|
277
|
+
}
|
|
200
278
|
}
|
|
201
279
|
|
|
202
280
|
// 4. Submit payment proof -> verified, executed, metered -> result.
|
|
@@ -207,6 +285,12 @@ async function invokeRail(command, args, opts = {}) {
|
|
|
207
285
|
if (status === 402) {
|
|
208
286
|
return { ok: false, error: 'Payment not yet recognized. The quote may have expired — re-run to get a fresh quote.' };
|
|
209
287
|
}
|
|
288
|
+
if (status === 401) {
|
|
289
|
+
return { ok: false, error: `Wallet authorization rejected: ${body?.error?.message || 'check signed message and wallet'}` };
|
|
290
|
+
}
|
|
291
|
+
if (status === 422) {
|
|
292
|
+
return { ok: false, error: `Action could not execute: ${body?.error?.message || body?.execution?.error || 'not available'}` };
|
|
293
|
+
}
|
|
210
294
|
if (status === 409) {
|
|
211
295
|
return { ok: false, error: `Already used: ${body?.error?.message || 'this payment was already consumed'}.` };
|
|
212
296
|
}
|
package/core/subsystems.js
CHANGED
|
@@ -52,6 +52,14 @@ const NOTES = {
|
|
|
52
52
|
recovery: 'File-backed idempotency store used for duplicate-idempotencyKey dedupe.',
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
/** First line only: Node require errors append a multi-line "Require stack:"
|
|
56
|
+
* dump that helps nobody in a status report or an MCP tool result. The short
|
|
57
|
+
* form still names the missing module and stays actionable. */
|
|
58
|
+
function briefError(err) {
|
|
59
|
+
const msg = err && err.message ? String(err.message) : String(err || 'unknown error');
|
|
60
|
+
return msg.split('\n')[0].trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
function defaultV2Root() {
|
|
56
64
|
return path.resolve(__dirname, '../../x402-paid-api-starter-kit/v2.0.0');
|
|
57
65
|
}
|
|
@@ -98,7 +106,7 @@ function getSubsystem(name, { config = {}, deps = {} } = {}) {
|
|
|
98
106
|
try {
|
|
99
107
|
mod = requireFiles(v2Root, MODULE_FILES[name]);
|
|
100
108
|
} catch (err) {
|
|
101
|
-
return { name, reachable: false, module: null, enabled: false, flag: null, version: null, note: NOTES[name] || null, subsystemStatus: 'unreachable', error: `require failed: ${err
|
|
109
|
+
return { name, reachable: false, module: null, enabled: false, flag: null, version: null, note: NOTES[name] || null, subsystemStatus: 'unreachable', error: `require failed: ${briefError(err)}` };
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
let flag = null;
|
|
@@ -107,7 +115,7 @@ function getSubsystem(name, { config = {}, deps = {} } = {}) {
|
|
|
107
115
|
flag = typeof mod.FLAG === 'string' ? mod.FLAG : null;
|
|
108
116
|
enabled = typeof mod.enabled === 'function' ? mod.enabled() : false;
|
|
109
117
|
} catch (err) {
|
|
110
|
-
return { name, reachable: true, module: mod, enabled: false, flag, version: null, note: NOTES[name] || null, subsystemStatus: 'disabled', error: `enabled() check failed: ${err
|
|
118
|
+
return { name, reachable: true, module: mod, enabled: false, flag, version: null, note: NOTES[name] || null, subsystemStatus: 'disabled', error: `enabled() check failed: ${briefError(err)}` };
|
|
111
119
|
}
|
|
112
120
|
|
|
113
121
|
const version = mod.version || mod.RESCUE_VERSION || mod.EFFECT_PROOF_VERSION || null;
|
package/docs/problem-map.md
CHANGED
|
@@ -25,7 +25,13 @@ v2.0.0 runtime tree and `CALLX402_LOCAL=1`). The paid form runs on the
|
|
|
25
25
|
production rail: quote first, payment verified exactly once, then the
|
|
26
26
|
diagnostic executes server-side and the result is returned — zero local setup.
|
|
27
27
|
The "Free path" column names the free equivalent; "rail only" means the action
|
|
28
|
-
exists only as a paid rail action.
|
|
28
|
+
exists only as a paid rail action. "Local only" means the opposite: the action
|
|
29
|
+
exists ONLY locally (self-hosted runtime tree). The hosted rail refuses it
|
|
30
|
+
with `422 HOSTED_ACTION_UNAVAILABLE`, it is absent from the rail catalog
|
|
31
|
+
(`GET /v1/callx402/actions` lists nine actions), and there is no Stripe or
|
|
32
|
+
x402 purchase path for it. Four actions are local-only: `safe_retry`,
|
|
33
|
+
`rescue`, `monitor`, `execute`. Their fee cells below show the schedule
|
|
34
|
+
figure for reference only — it cannot be paid on the rail.
|
|
29
35
|
|
|
30
36
|
**Fees.** The table lists the Stripe-path fee per action. The x402 path
|
|
31
37
|
quoted by the free quote endpoint is exactly that fee divided by 20
|
|
@@ -40,16 +46,16 @@ deterministic, not an estimate.
|
|
|
40
46
|
| 1 | Settlement uncertainty — can't tell from current info whether the payment settled | `resolve` | $5.00 | `callx402 resolve --evidence ...` / `x402_resolve` | Unknown stays unknown; never auto-retry, never repay |
|
|
41
47
|
| 2 | Lost result after payment — paid, but the result never arrived | `recover` | $10.00 | `callx402 recover <op> --identity ...` / `x402_recover` | Read-only: nothing charged, nothing executed; returns the safe recovery decision or the proven prior result, never a blind re-execution |
|
|
42
48
|
| 3 | Retry uncertainty — something failed; need what/why before deciding if retry is safe | `diagnose` | $2.00 | `callx402 diagnose --evidence ...` / `x402_diagnose` | Unknown failure class returns UNKNOWN, never a guess |
|
|
43
|
-
| 4 | Retry uncertainty, narrowed — is THIS specific retry safe to sign? | `safe_retry` | $5.00
|
|
49
|
+
| 4 | Retry uncertainty, narrowed — is THIS specific retry safe to sign? | `safe_retry` | $5.00 (local only — not purchasable on the rail) | local only (self-hosted runtime; no CLI command in this package and no rail purchase path — start free with `recover`: a SAFE_RETRY verdict there is the read-only answer) | A retry that would double-pay is refused |
|
|
44
50
|
| 5 | Duplicate-payment risk — a retry might sign a second authorization for the same intent | `duplicate_payment_risk` | $5.00 | rail only (start free: `resolve` the settlement, then `recover` for the retry verdict) | Ambiguous authorization state blocks the retry |
|
|
45
51
|
| 6 | Need proof — ground truth before acting on any incident | `evidence` | $2.00 | `callx402 evidence <op>` / `x402_evidence` | First step in any incident; returns what is recorded, nothing invented |
|
|
46
52
|
| 7 | Need explanation — plain-language state assessment before retry/refund/escalate | `explain` | $1.00 | `callx402 explain <op>` / `x402_explain` | INCOMPLETE means do not retry, do not repay |
|
|
47
53
|
| 8 | Pre-commit check — intent, payment readiness, risk before an economic operation commits | `preflight` | $3.00 | `callx402 preflight` (local checks) | Blocked stays blocked until the reasons clear |
|
|
48
54
|
| 9 | Failure triage — classify the failure so the right playbook runs | `failure_classification` | $2.00 | rail only (start free with `diagnose`) | Unclassifiable failures escalate to human review |
|
|
49
55
|
| 10 | Settlement-record interpretation — read a raw settlement record, state what it means | `settlement_interpretation` | $3.00 | rail only (start free with `resolve --evidence ...`) | Ambiguous records are reported ambiguous |
|
|
50
|
-
| 11 | Rescue a failed transaction — triage and recover the incident end to end | `rescue` | $15.00 | `callx402 rescue --incident <id>` (triage and quote only, never executes) | Never fabricates success; unreachable subsystems fail closed |
|
|
51
|
-
| 12 | Watch an operation/workload for state changes, failures, anomalies | `monitor` | $5.00 | `callx402 monitor --once` (local sentinel snapshot) | An unwatched gap is reported as a gap, not as healthy |
|
|
52
|
-
| 13 | Run an economic operation through the protected path (exactly-once) | `execute` | $10.00 | `callx402 execute --intent ... --dry-run` (plans only) | Over-budget intents refused with zero side effects |
|
|
56
|
+
| 11 | Rescue a failed transaction — triage and recover the incident end to end | `rescue` | $15.00 (local only — not purchasable on the rail) | `callx402 rescue --incident <id>` (triage and quote only, never executes) | Never fabricates success; unreachable subsystems fail closed |
|
|
57
|
+
| 12 | Watch an operation/workload for state changes, failures, anomalies | `monitor` | $5.00 (local only — not purchasable on the rail) | `callx402 monitor --once` (local sentinel snapshot) | An unwatched gap is reported as a gap, not as healthy |
|
|
58
|
+
| 13 | Run an economic operation through the protected path (exactly-once) | `execute` | $10.00 (local only — not purchasable on the rail) | `callx402 execute --intent ... --dry-run` (plans only) | Over-budget intents refused with zero side effects |
|
|
53
59
|
|
|
54
60
|
## Where each surface consumes this map
|
|
55
61
|
|
|
@@ -70,9 +76,14 @@ deterministic, not an estimate.
|
|
|
70
76
|
## Notes
|
|
71
77
|
|
|
72
78
|
- Action names are canonical in `snake_case`. The rail also accepts the
|
|
73
|
-
hyphenated aliases shown in the OpenAPI enum (`
|
|
74
|
-
`
|
|
75
|
-
`
|
|
79
|
+
hyphenated aliases shown in the OpenAPI enum (`duplicate-payment-risk`,
|
|
80
|
+
`settlement-interpretation`, `failure-classification`); both forms resolve
|
|
81
|
+
identically (verified live). `safe-retry` is not a rail action in either
|
|
82
|
+
spelling — it is local-only (see the two-tiers note above).
|
|
83
|
+
- Rail `evidence` / `explain` read back a PRIOR paid invocation: the request
|
|
84
|
+
must carry that invocation's `invocation_id`, and the rail returns
|
|
85
|
+
`400 MISSING_INPUT` without one, so they cannot be used cold. The free
|
|
86
|
+
local forms in the map read your local operation ledger instead.
|
|
76
87
|
- The x402-path quote is the valuation-layer quote, not the raw schedule;
|
|
77
88
|
the fixed schedule above is the Stripe-path floor.
|
|
78
89
|
- Local CLI / MCP-stdio diagnostics are free and read-only — no payment, no
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Secure, on-demand callx402 redemption
|
|
2
|
+
|
|
3
|
+
A publicly visible on-chain transaction hash is **never** sufficient to redeem
|
|
4
|
+
a paid callx402 action. The payer must produce an EIP-191 `personal_sign`
|
|
5
|
+
signature, outside Payload, over the exact action, transaction hash, quote,
|
|
6
|
+
action inputs, wallet, Base network, Payload recipient, nonce and expiration.
|
|
7
|
+
|
|
8
|
+
**Never provide your seed phrase or private key to Payload or the CLI.**
|
|
9
|
+
The CLI does not ask for or store wallet secrets.
|
|
10
|
+
|
|
11
|
+
## Human use
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
callx402 diagnose --evidence '{"error":"settlement timeout"}'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The CLI displays a free **USDC** quote before asking permission to spend.
|
|
18
|
+
For USDC it presents the payment details, then asks for the paying wallet
|
|
19
|
+
address and a signature created by your wallet over the canonical message.
|
|
20
|
+
For Stripe card checkout it explicitly confirms the separate card price.
|
|
21
|
+
|
|
22
|
+
Only actions supported by the hosted service are presented as paid services.
|
|
23
|
+
Some advanced commands require an optional local runtime and must not be
|
|
24
|
+
misrepresented as completed hosted executions.
|
|
25
|
+
|
|
26
|
+
## Non-interactive agent use
|
|
27
|
+
|
|
28
|
+
An agent must obtain a free quote **before** payment:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
GET https://payload-rail.fly.dev/v1/callx402/quote?action=resolve&path=x402
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Preserve both `quote_id` and `quote_inputs` from this exact quote response.
|
|
35
|
+
Quote IDs include issuance time; requesting a fresh quote after signing will
|
|
36
|
+
produce a different ID. Use the original quote before it expires.
|
|
37
|
+
|
|
38
|
+
Use `actionRequestHash(action, body)` from `core/payer-auth.js` for the exact
|
|
39
|
+
input the agent will redeem. For example, if the CLI will send
|
|
40
|
+
`{evidence: "{\\"error\\":\\"settlement timeout\\"}"}`, pass that exact evidence
|
|
41
|
+
**string** to the hash function; do not hash a parsed object instead.
|
|
42
|
+
|
|
43
|
+
The owner’s wallet must sign the canonical
|
|
44
|
+
`buildAuthMessage({wallet, action, txHash, quote_id, request_hash, network, recipient, nonce, expiry})`
|
|
45
|
+
via EIP-191 `personal_sign` with expiry no further than 15 minutes ahead.
|
|
46
|
+
The resulting JSON file should include:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"wallet": "0x...",
|
|
51
|
+
"action": "resolve",
|
|
52
|
+
"txHash": "0x...",
|
|
53
|
+
"quote_id": "the original quote ID",
|
|
54
|
+
"quote_inputs": {"action":"resolve","path":"x402","...":"other exact canonical inputs"},
|
|
55
|
+
"quoted_price_usd": "0.25",
|
|
56
|
+
"request_hash": "0x...",
|
|
57
|
+
"network": "eip155:8453",
|
|
58
|
+
"recipient": "0x...",
|
|
59
|
+
"nonce": "an unpredictable unique value",
|
|
60
|
+
"expiry": 1790000000,
|
|
61
|
+
"signature": "0x...65-byte EIP-191 signature"
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The example is illustrative, not a usable authorization; use the exact returned
|
|
66
|
+
canonical `quote_inputs`. Run:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
callx402 resolve --evidence @evidence.json --tx-hash 0xYOUR_TX_HASH \
|
|
70
|
+
--payer-auth @signed-auth.json --approve
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The CLI verifies that the signed action, transaction, quote and request
|
|
74
|
+
digest match what it is about to submit. The Rail independently re-derives
|
|
75
|
+
the quote, recovers the signer, matches the chain payer, checks recipient
|
|
76
|
+
and network, and atomically enforces one-time use. A rejected request
|
|
77
|
+
does not authorize execution.
|
|
78
|
+
|
|
79
|
+
**Contract wallets:** EIP-1271 is **not yet exposed on this redemption
|
|
80
|
+
path**. The current secured release supports EOA EIP-191 signatures only.
|
|
81
|
+
Do not claim contract-wallet redemption support until integration tests pass.
|
|
82
|
+
|
|
83
|
+
**No live-money tests:** Developers must use mocked verifier / Stripe
|
|
84
|
+
clients or pre-existing read-only evidence for automated tests.
|
package/integrations/core.py
CHANGED
|
@@ -5,7 +5,7 @@ Plain functions (no framework imports) so LangChain, CrewAI, AutoGen, or any
|
|
|
5
5
|
agent framework can wrap them directly. stdlib only.
|
|
6
6
|
|
|
7
7
|
The rail's public endpoints:
|
|
8
|
-
GET /v1/callx402/actions free — live
|
|
8
|
+
GET /v1/callx402/actions free — live fee schedule (9 hosted actions)
|
|
9
9
|
GET /v1/callx402/quote free — deterministic pre-execution quote
|
|
10
10
|
POST /v1/callx402/actions/{action} paid — unauthenticated -> x402 v2 402
|
|
11
11
|
(USDC on Base); credit_id / txHash /
|
|
@@ -30,6 +30,13 @@ RAIL = os.environ.get("CALLX402_RAIL", "https://payload-rail.fly.dev").rstrip("/
|
|
|
30
30
|
|
|
31
31
|
# Canonical action names (snake_case), verified live 2026-10-07.
|
|
32
32
|
# Hyphenated aliases are also accepted by the rail; these are canonical.
|
|
33
|
+
# AVAILABILITY (verified live 2026-10-13): only 9 of these 13 are hosted and
|
|
34
|
+
# purchasable on the rail — diagnose, explain, evidence, recover, resolve,
|
|
35
|
+
# preflight, settlement_interpretation, failure_classification,
|
|
36
|
+
# duplicate_payment_risk. The other four (safe_retry, rescue, monitor,
|
|
37
|
+
# execute) are LOCAL-ONLY: the rail refuses them with
|
|
38
|
+
# 422 HOSTED_ACTION_UNAVAILABLE, they are absent from the rail catalog, and
|
|
39
|
+
# they cannot be bought via Stripe or x402.
|
|
33
40
|
ACTIONS = (
|
|
34
41
|
"diagnose", "explain", "evidence", "recover", "resolve", "preflight",
|
|
35
42
|
"monitor", "execute", "rescue", "settlement_interpretation", "safe_retry",
|
|
@@ -303,7 +310,9 @@ FREE_LOCAL_ALTERNATIVE = (
|
|
|
303
310
|
"Local CLI / MCP-stdio diagnostics are free and read-only "
|
|
304
311
|
"(git clone https://github.com/Payloadhq/callx402; "
|
|
305
312
|
"node mcp/index.js as an MCP server). The paid rail actions above are "
|
|
306
|
-
"for hosted on-demand invocation with payment."
|
|
313
|
+
"for hosted on-demand invocation with payment. Note: safe_retry, "
|
|
314
|
+
"rescue, monitor, and execute are local-only and are NOT purchasable "
|
|
315
|
+
"on the hosted rail."
|
|
307
316
|
)
|
|
308
317
|
|
|
309
318
|
|
|
@@ -125,8 +125,11 @@ def callx402_explain(operation_id: str) -> str:
|
|
|
125
125
|
def callx402_safe_retry(retry_plan: str) -> str:
|
|
126
126
|
"""Go/no-go verdict on ONE specific retry plan before anything is signed.
|
|
127
127
|
Use when a retry is being considered and must be proven safe first.
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
AVAILABILITY: safe_retry is LOCAL-ONLY — the hosted rail refuses it
|
|
129
|
+
(422 HOSTED_ACTION_UNAVAILABLE) and it cannot be bought via Stripe or
|
|
130
|
+
x402, so this tool currently surfaces that refusal. For the
|
|
131
|
+
retry-safety question today, use callx402_recover's read-only verdict.
|
|
132
|
+
A retry that would double-pay is refused."""
|
|
130
133
|
return _render(invoke("safe_retry", {"retry_plan": retry_plan}, **_auth()))
|
|
131
134
|
|
|
132
135
|
|
package/mcp/index.js
CHANGED
|
@@ -18,13 +18,21 @@
|
|
|
18
18
|
* Only read-only / diagnostic actions are exposed. Actions with side effects
|
|
19
19
|
* (execute, rescue mutations) are intentionally NOT tools here.
|
|
20
20
|
*
|
|
21
|
+
* Honest surface: ONLY x402_status works on a bare install of this package.
|
|
22
|
+
* The other five tools dispatch into the v2.0.0 subsystem tree (the x402
|
|
23
|
+
* Paid API Starter Kit — a separate product that is NOT bundled here) and
|
|
24
|
+
* return `subsystem_unreachable` until CALLX402_V2_ROOT points at that tree
|
|
25
|
+
* and the matching feature flag is enabled. These tools never call the
|
|
26
|
+
* hosted rail; paid hosted equivalents live at
|
|
27
|
+
* https://payloadhq.github.io/agents.json.
|
|
28
|
+
*
|
|
21
29
|
* Usage (Claude Desktop / MCP client config):
|
|
22
30
|
* { "command": "node", "args": ["/path/to/callx402/mcp/index.js"] }
|
|
23
31
|
* Env: CALLX402_CONFIG, CALLX402_V2_ROOT, CALLX402_TIMEOUT_MS (default 30000).
|
|
24
32
|
*/
|
|
25
33
|
const readline = require('node:readline');
|
|
26
34
|
|
|
27
|
-
const VERSION = '1.0.
|
|
35
|
+
const VERSION = '1.0.4';
|
|
28
36
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
29
37
|
const SERVER_NAME = 'callx402';
|
|
30
38
|
const TIMEOUT_MS = Number(process.env.CALLX402_TIMEOUT_MS || 30000);
|
|
@@ -34,7 +42,29 @@ const core = require(process.env.CALLX402_CORE_PATH ||
|
|
|
34
42
|
|
|
35
43
|
/* ------------------------------------------------------------------ */
|
|
36
44
|
/* Tool definitions: name, description, input schema, core action. */
|
|
45
|
+
/* */
|
|
46
|
+
/* Requirement trailers, stated in every description because the */
|
|
47
|
+
/* description is what an MCP client actually shows the user: */
|
|
48
|
+
/* TREE_REQUIRED — dispatches into the v2.0.0 subsystem tree, which */
|
|
49
|
+
/* is NOT part of this package. */
|
|
50
|
+
/* LEDGER_REQUIRED — reads the local Veyline operation ledger via */
|
|
51
|
+
/* modules that are likewise not bundled here. */
|
|
37
52
|
/* ------------------------------------------------------------------ */
|
|
53
|
+
const TREE_REQUIRED =
|
|
54
|
+
' Requires the v2.0.0 subsystem tree (x402 Paid API Starter Kit, a separate ' +
|
|
55
|
+
'product that is NOT included in this package): set CALLX402_V2_ROOT to the ' +
|
|
56
|
+
'tree and enable the subsystem flag, or this tool returns ' +
|
|
57
|
+
'subsystem_unreachable and does nothing. With the tree configured it is free ' +
|
|
58
|
+
'and read-only: no payment, no auth. A paid hosted equivalent runs on the ' +
|
|
59
|
+
'Payload rail — see https://payloadhq.github.io/agents.json.';
|
|
60
|
+
const ledgerRequired = (flag) =>
|
|
61
|
+
' Requires the local Veyline ledger modules (NOT included in this package; ' +
|
|
62
|
+
`enable with ${flag}=1 where the ledger lives) and reads ` +
|
|
63
|
+
'operations recorded on THIS machine — it cannot read back hosted rail ' +
|
|
64
|
+
'invocations. Until configured it returns subsystem_unreachable and does ' +
|
|
65
|
+
'nothing. When configured it is free and read-only: no payment, no auth.';
|
|
66
|
+
const LEDGER_REQUIRED = ledgerRequired('PAYLOAD_VEYLINE_LEDGER');
|
|
67
|
+
|
|
38
68
|
const TOOLS = [
|
|
39
69
|
{
|
|
40
70
|
name: 'x402_diagnose',
|
|
@@ -43,8 +73,8 @@ const TOOLS = [
|
|
|
43
73
|
'Run the x402 doctor over supplied evidence. Use when something went ' +
|
|
44
74
|
'wrong with an x402 payment or MCP/x402 interaction and you need a ' +
|
|
45
75
|
'diagnosis. Input: evidence as a JSON string or object describing the ' +
|
|
46
|
-
'failure (error strings, responses, tx hashes). Read-only.
|
|
47
|
-
|
|
76
|
+
'failure (error strings, responses, tx hashes). Read-only.' +
|
|
77
|
+
TREE_REQUIRED,
|
|
48
78
|
inputSchema: {
|
|
49
79
|
type: 'object',
|
|
50
80
|
properties: {
|
|
@@ -66,8 +96,8 @@ const TOOLS = [
|
|
|
66
96
|
description:
|
|
67
97
|
'Show recorded ledger evidence for an operation: event trail, latest ' +
|
|
68
98
|
'per-plane states (payment / execution / delivery), protocols involved. ' +
|
|
69
|
-
'Use FIRST in any incident — establishes ground truth before acting. Read-only.
|
|
70
|
-
|
|
99
|
+
'Use FIRST in any incident — establishes ground truth before acting. Read-only.' +
|
|
100
|
+
LEDGER_REQUIRED,
|
|
71
101
|
inputSchema: {
|
|
72
102
|
type: 'object',
|
|
73
103
|
properties: {
|
|
@@ -84,8 +114,8 @@ const TOOLS = [
|
|
|
84
114
|
'Explain an operation\'s state in plain language from recorded evidence. ' +
|
|
85
115
|
'Returns an assessment: NO_BASIS, INCOMPLETE (do not retry, do not repay), ' +
|
|
86
116
|
'KNOWN_SAFE, RECOVERY_CANDIDATE, or PARTIAL. Use to understand an incident ' +
|
|
87
|
-
'before deciding whether to retry, refund, or escalate. Read-only.
|
|
88
|
-
|
|
117
|
+
'before deciding whether to retry, refund, or escalate. Read-only.' +
|
|
118
|
+
LEDGER_REQUIRED,
|
|
89
119
|
inputSchema: {
|
|
90
120
|
type: 'object',
|
|
91
121
|
properties: {
|
|
@@ -102,8 +132,8 @@ const TOOLS = [
|
|
|
102
132
|
'Report the safe recovery decision for an operation whose result was lost ' +
|
|
103
133
|
'(paid but no result). READ-ONLY: nothing is charged, nothing is executed. ' +
|
|
104
134
|
'Returns RECOVERABLE/SAFE_RETRY -> KNOWN_SAFE, or HUMAN_REVIEW. Use for ' +
|
|
105
|
-
'the exact situation where blind retry would double-pay or double-execute.
|
|
106
|
-
'
|
|
135
|
+
'the exact situation where blind retry would double-pay or double-execute.' +
|
|
136
|
+
ledgerRequired('PAYLOAD_VEYLINE_RECOVERY'),
|
|
107
137
|
inputSchema: {
|
|
108
138
|
type: 'object',
|
|
109
139
|
properties: {
|
|
@@ -119,8 +149,8 @@ const TOOLS = [
|
|
|
119
149
|
action: 'resolve',
|
|
120
150
|
description:
|
|
121
151
|
'Resolve settlement state from evidence (e.g. settlement_pending: was the ' +
|
|
122
|
-
'broadcast transaction confirmed on chain?). Use instead of blind retry. Read-only.
|
|
123
|
-
|
|
152
|
+
'broadcast transaction confirmed on chain?). Use instead of blind retry. Read-only.' +
|
|
153
|
+
TREE_REQUIRED,
|
|
124
154
|
inputSchema: {
|
|
125
155
|
type: 'object',
|
|
126
156
|
properties: {
|
|
@@ -137,8 +167,11 @@ const TOOLS = [
|
|
|
137
167
|
action: 'status',
|
|
138
168
|
description:
|
|
139
169
|
'Check callx402 subsystem reachability (which diagnostic subsystems are ' +
|
|
140
|
-
'available). Use to verify the tool is operational before diagnosing.
|
|
141
|
-
'
|
|
170
|
+
'available). Use to verify the tool is operational before diagnosing. ' +
|
|
171
|
+
'Read-only. This is the one tool that works with zero setup in any ' +
|
|
172
|
+
'install — no key, no tree, no payment. On a bare npm install it ' +
|
|
173
|
+
'honestly reports 0/15 subsystems reachable: the other five tools need ' +
|
|
174
|
+
'the v2.0.0 subsystem tree (CALLX402_V2_ROOT) before they can answer.',
|
|
142
175
|
inputSchema: { type: 'object', properties: {} },
|
|
143
176
|
},
|
|
144
177
|
];
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"transport": {
|
|
11
11
|
"type": "stdio"
|
|
12
12
|
},
|
|
13
|
-
"version": "1.0.
|
|
13
|
+
"version": "1.0.3"
|
|
14
14
|
}
|
|
15
15
|
],
|
|
16
16
|
"repository": {
|
|
@@ -18,6 +18,6 @@
|
|
|
18
18
|
"url": "https://github.com/Payloadhq/callx402"
|
|
19
19
|
},
|
|
20
20
|
"title": "callx402 MCP server",
|
|
21
|
-
"version": "1.0.
|
|
21
|
+
"version": "1.0.3",
|
|
22
22
|
"websiteUrl": "https://github.com/Payloadhq/callx402"
|
|
23
23
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { test } = require('node:test');
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const { buildAuthMessage, actionRequestHash, readSignedAuthorization } = require('../core/payer-auth.js');
|
|
5
|
+
const { invokeRail, isRailCommand } = require('../core/rail.js');
|
|
6
|
+
|
|
7
|
+
const txHash = '0x' + '22'.repeat(32);
|
|
8
|
+
const wallet = '0x' + '11'.repeat(20);
|
|
9
|
+
const recipient = '0x' + '33'.repeat(20);
|
|
10
|
+
const signature = '0x' + '44'.repeat(65);
|
|
11
|
+
const authorization = () => ({
|
|
12
|
+
wallet, action: 'resolve', txHash, quote_id: 'q_test',
|
|
13
|
+
quote_inputs: { action: 'resolve', path: 'x402', issuedAt: 1 }, quoted_price_usd: '0.25',
|
|
14
|
+
request_hash: actionRequestHash('resolve', { evidence: '{}' }),
|
|
15
|
+
network: 'eip155:8453', recipient, nonce: 'nonce_unique_1234',
|
|
16
|
+
expiry: Math.floor(Date.now() / 1000) + 300, signature,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('client and Rail use canonical EIP-191 signer message', () => {
|
|
20
|
+
const expected = [
|
|
21
|
+
'Payload x402 Authorization',
|
|
22
|
+
'wallet:' + wallet,
|
|
23
|
+
'action:resolve',
|
|
24
|
+
'txHash:' + txHash,
|
|
25
|
+
'quote_id:q_test',
|
|
26
|
+
'request_hash:' + authorization().request_hash,
|
|
27
|
+
'network:eip155:8453',
|
|
28
|
+
'recipient:' + recipient,
|
|
29
|
+
'nonce:nonce_unique_1234',
|
|
30
|
+
'expiry:' + authorization().expiry,
|
|
31
|
+
].join('\n');
|
|
32
|
+
assert.equal(buildAuthMessage(authorization()), expected);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('unsigned, malformed and expired authorizations are rejected before network payment', () => {
|
|
36
|
+
assert.throws(() => readSignedAuthorization('{}'), /wallet/);
|
|
37
|
+
const bad = { ...authorization(), expiry: Math.floor(Date.now()/1000) - 1 };
|
|
38
|
+
assert.throws(() => readSignedAuthorization(JSON.stringify(bad)), /expired/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('hosted CLI does not route local-only actions to paid Rail', () => {
|
|
42
|
+
assert.equal(isRailCommand('execute'), false);
|
|
43
|
+
assert.equal(isRailCommand('rescue'), false);
|
|
44
|
+
assert.equal(isRailCommand('resolve'), true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('signed noninteractive redemption sends signature, never wallet secrets', async () => {
|
|
48
|
+
const previous = global.fetch;
|
|
49
|
+
const requests = [];
|
|
50
|
+
global.fetch = async (url, init = {}) => {
|
|
51
|
+
requests.push({ url, init });
|
|
52
|
+
const quote = String(url).includes('/quote?');
|
|
53
|
+
const body = quote ? { quoted_price_usd: '0.25', quote_id: 'q_test', quote_inputs: { action: 'resolve', path: 'x402' } } :
|
|
54
|
+
{ ok: true, invocation_id: 'invo_mock', via: 'x402', execution: { executed: true, result: { state: 'UNKNOWN' } } };
|
|
55
|
+
return { status: 200, text: async () => JSON.stringify(body) };
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
const result = await invokeRail('resolve', {
|
|
59
|
+
evidence: '{}', txHash, payerAuth: JSON.stringify(authorization()),
|
|
60
|
+
}, { yes: true, json: true });
|
|
61
|
+
assert.equal(result.ok, true);
|
|
62
|
+
assert.equal(requests.length, 1);
|
|
63
|
+
const body = JSON.parse(requests[0].init.body);
|
|
64
|
+
assert.deepEqual(body.quote, authorization().quote_inputs);
|
|
65
|
+
assert.equal(body.txHash, txHash);
|
|
66
|
+
assert.equal(body.payer_auth.wallet, wallet);
|
|
67
|
+
assert.equal(body.payer_auth.signature, signature);
|
|
68
|
+
assert.equal(body.payer_auth.quote_id, 'q_test');
|
|
69
|
+
assert.equal('privateKey' in body, false);
|
|
70
|
+
} finally {
|
|
71
|
+
global.fetch = previous;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('stale signature for a different quote fails without submitting a payment proof', async () => {
|
|
76
|
+
const previous = global.fetch;
|
|
77
|
+
const requests = [];
|
|
78
|
+
global.fetch = async (url, init = {}) => {
|
|
79
|
+
requests.push({ url, init });
|
|
80
|
+
return { status: 200, text: async () => JSON.stringify({ quoted_price_usd: '0.25', quote_id: 'q_changed', quote_inputs: { action: 'resolve', path: 'x402' } }) };
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
const result = await invokeRail('resolve', {
|
|
84
|
+
txHash, payerAuth: JSON.stringify({ ...authorization(), action: 'recover' }),
|
|
85
|
+
}, { yes: true, json: true });
|
|
86
|
+
assert.equal(result.ok, false);
|
|
87
|
+
assert.match(result.error, /current quote/);
|
|
88
|
+
assert.equal(requests.length, 0);
|
|
89
|
+
} finally {
|
|
90
|
+
global.fetch = previous;
|
|
91
|
+
}
|
|
92
|
+
});
|
package/mcp-publisher
DELETED
|
Binary file
|