callx402 1.0.2 → 1.0.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/.github/workflows/npm-publish.yml +4 -4
- package/.github/workflows/review-ci.yml +24 -0
- package/README.md +18 -8
- package/bin/callx402.js +6 -2
- package/core/payer-auth.js +59 -0
- package/core/rail.js +98 -14
- package/docs/signed-redemption.md +84 -0
- package/package.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,21 +301,31 @@ 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
331
|
The x402-path price is the Stripe-path fee divided by 20 — for example
|
package/bin/callx402.js
CHANGED
|
@@ -305,6 +305,7 @@ function parseArgv(argv) {
|
|
|
305
305
|
const KNOWN_FLAGS = new Set([
|
|
306
306
|
'help', 'version', 'json', 'timeout', 'maxBudget', 'approvalThreshold',
|
|
307
307
|
'idempotencyKey', 'evidence', 'incident', 'goal', 'intent', 'query',
|
|
308
|
+
'txHash', 'payerAuth', 'creditId',
|
|
308
309
|
'target', 'speed', 'interval', 'network', 'networks', 'asset', 'assets',
|
|
309
310
|
'providers', 'deadline', 'risk',
|
|
310
311
|
'dryRun', 'approve', 'forceRetry', 'retry', 'auth', 'watch', 'once',
|
|
@@ -324,7 +325,8 @@ function parseArgv(argv) {
|
|
|
324
325
|
i += 1;
|
|
325
326
|
continue;
|
|
326
327
|
}
|
|
327
|
-
const NEEDS_VALUE = new Set(['timeout', 'maxBudget', 'approvalThreshold', 'idempotencyKey', 'evidence', 'incident', 'goal', 'intent', 'query', 'target',
|
|
328
|
+
const NEEDS_VALUE = new Set(['timeout', 'maxBudget', 'approvalThreshold', 'idempotencyKey', 'evidence', 'incident', 'goal', 'intent', 'query', 'target',
|
|
329
|
+
'txHash', 'payerAuth', 'creditId', 'speed', 'interval', 'network', 'networks', 'asset', 'assets', 'providers', 'deadline', 'risk', 'dir', 'operationId', 'identity']);
|
|
328
330
|
if (NEEDS_VALUE.has(name)) {
|
|
329
331
|
if (val !== null) out.flags[name] = val;
|
|
330
332
|
else if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) out.flags[name] = argv[++i];
|
|
@@ -503,7 +505,9 @@ async function main() {
|
|
|
503
505
|
// payment/authorization -> result. Set CALLX402_LOCAL=1 to prefer the
|
|
504
506
|
// local v2.0.0 runtime tree instead (advanced self-hosted mode).
|
|
505
507
|
if (rail.isRailCommand(actionName) && !rail.preferLocal()) {
|
|
506
|
-
const railResult = await rail.invokeRail(actionName,
|
|
508
|
+
const railResult = await rail.invokeRail(actionName,
|
|
509
|
+
{ ...args, txHash: flags.txHash, payerAuth: flags.payerAuth, creditId: flags.creditId },
|
|
510
|
+
{ yes: args.approve, json: args.json });
|
|
507
511
|
if (args.json) {
|
|
508
512
|
console.log(JSON.stringify(railResult, null, 2));
|
|
509
513
|
} 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;
|
|
@@ -141,7 +157,7 @@ async function invokeRail(command, args, opts = {}) {
|
|
|
141
157
|
return {
|
|
142
158
|
ok: false,
|
|
143
159
|
error: `Payment of $${price} required. Re-run with --yes to approve non-interactively, or run interactively.`,
|
|
144
|
-
quote: { action, price_usd: price, quote_id: quoteId },
|
|
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
|
}
|
|
@@ -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/package.json
CHANGED
|
@@ -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
|