Skip to content

Commit b7487d8

Browse files
committed
Add x402 provider verification abstraction for paid-action
1 parent 707b1fd commit b7487d8

4 files changed

Lines changed: 233 additions & 15 deletions

File tree

api/examples/x402-paid-action.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

33
const { signReceipt, resolveReceiptSigningConfigFromEnv, hasValidSigningConfig } = require('../../lib/receiptSigning');
4+
const { verifyWithProvider } = require('../../lib/x402ProviderVerification');
45

56
const seenReceipts = new Map();
67
const MAX_TEXT_LENGTH = 4000;
@@ -11,12 +12,12 @@ function buildDeterministicSummary(text) {
1112
return prefix.length < normalized.length ? `${prefix}…` : prefix;
1213
}
1314

14-
function normalizePaidActionReceipt(payload, signerId) {
15+
function normalizePaidActionReceipt(payload, signerId, verificationResult) {
1516
const timestamp = new Date().toISOString();
1617
const paymentId = payload.payment.payment_id;
1718
const requestId = payload.request_id;
1819

19-
return {
20+
const receipt = {
2021
receipt_id: `rcpt:x402:${paymentId}:${requestId}`,
2122
signer: signerId,
2223
verb: 'summarize',
@@ -40,6 +41,7 @@ function normalizePaidActionReceipt(payload, signerId) {
4041
output: {
4142
summary: buildDeterministicSummary(payload.input.text),
4243
payment_accepted: true,
44+
payment_verification_mode: verificationResult.paymentVerificationMode,
4345
},
4446
execution: { status: 'succeeded' },
4547
ts: timestamp,
@@ -52,10 +54,23 @@ function normalizePaidActionReceipt(payload, signerId) {
5254
payment_protocol: 'x402',
5355
payment_id: paymentId,
5456
action: 'summarize.text',
57+
payment_verification_mode: verificationResult.paymentVerificationMode,
5558
},
5659
},
5760
},
5861
};
62+
63+
if (verificationResult.provider) {
64+
const safeProvider = {};
65+
if (verificationResult.provider.provider) safeProvider.provider = verificationResult.provider.provider;
66+
if (verificationResult.provider.status) safeProvider.status = verificationResult.provider.status;
67+
if (verificationResult.provider.reference) safeProvider.reference = verificationResult.provider.reference;
68+
if (Object.keys(safeProvider).length) {
69+
receipt.metadata.trace.provider_verification = safeProvider;
70+
}
71+
}
72+
73+
return receipt;
5974
}
6075

6176
function parsePayload(body) {
@@ -115,6 +130,11 @@ module.exports = async function handler(req, res) {
115130
return res.status(400).json({ ok: false, status: 'payment_invalid' });
116131
}
117132

133+
const verificationResult = await verifyWithProvider({ payload, req });
134+
if (!verificationResult.ok) {
135+
return res.status(verificationResult.httpStatus).json({ ok: false, status: verificationResult.status });
136+
}
137+
118138
const dedupeKey = `${requestId}::${payment.payment_id}`;
119139
if (seenReceipts.has(dedupeKey)) {
120140
return res.status(200).json({ ok: true, status: 'PAID_ACTION_EXECUTED_AND_SIGNED', duplicate: true, receipt: seenReceipts.get(dedupeKey) });
@@ -126,7 +146,7 @@ module.exports = async function handler(req, res) {
126146
}
127147

128148
try {
129-
const unsignedReceipt = normalizePaidActionReceipt(payload, signingCfg.signerId || 'runtime.commandlayer.eth');
149+
const unsignedReceipt = normalizePaidActionReceipt(payload, signingCfg.signerId || 'runtime.commandlayer.eth', verificationResult);
130150
const receipt = await signReceipt(unsignedReceipt, signingCfg);
131151
seenReceipts.set(dedupeKey, receipt);
132152
return res.status(200).json({ ok: true, status: 'PAID_ACTION_EXECUTED_AND_SIGNED', duplicate: false, receipt });

docs/integrations/x402-commandlayer-receipts.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,23 @@ A server-side example endpoint is available at `POST /api/examples/x402-paid-act
297297

298298
The endpoint returns status `PAID_ACTION_EXECUTED_AND_SIGNED` with a signed CLAS-style receipt.
299299

300+
### Verification modes
301+
302+
The example supports two server-side payment verification modes:
303+
304+
- `demo_accepted_envelope` (default): used when `X402_PROVIDER_VERIFICATION_URL` is not configured. In this mode, the endpoint accepts the declared `payment.protocol = x402` + `payment.status = accepted` envelope and marks the receipt with `payment_verification_mode: "demo_accepted_envelope"`.
305+
- `provider_verified`: enabled only when `X402_PROVIDER_VERIFICATION_URL` is configured. In this mode, the server posts the payment envelope and request metadata to the provider verification endpoint and executes only when provider verification indicates accepted/settled payment.
306+
307+
Optional provider auth:
308+
309+
- `X402_PROVIDER_API_KEY`: when set, the server sends `Authorization: Bearer <key>` to the provider verification endpoint.
310+
- Keys are not returned in API responses or receipt metadata.
311+
312+
Failure mapping in provider mode:
313+
314+
- Provider payment rejection: `400 payment_invalid` or `402 payment_required`.
315+
- Provider unavailable/network/malformed response: `503 payment_provider_unavailable`.
316+
300317
### Verification command
301318

302319
You can verify the returned receipt with the existing verify endpoint:
@@ -323,6 +340,6 @@ Example verified result pattern (redacted for safety):
323340
- signature_valid: `true`
324341
- key_id: `vC4WbcNoq2znSCiQ`
325342

326-
This shows a paid action can emit signed execution proof after an accepted x402 payment envelope is validated.
343+
This shows a paid action can emit signed execution proof after payment verification succeeds under the active mode.
327344

328-
The example validates an accepted x402 payment envelope; it does not claim full production settlement unless wired to a real x402 provider.
345+
Important: CommandLayer proves execution and receipt integrity; x402 + the configured provider prove payment acceptance/settlement. The default example remains a demo accepted-envelope flow unless `X402_PROVIDER_VERIFICATION_URL` is configured.

lib/x402ProviderVerification.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
'use strict';
2+
3+
const PAYMENT_VERIFICATION_MODES = {
4+
DEMO_ACCEPTED_ENVELOPE: 'demo_accepted_envelope',
5+
PROVIDER_VERIFIED: 'provider_verified',
6+
};
7+
8+
function getProviderVerificationUrl() {
9+
const value = process.env.X402_PROVIDER_VERIFICATION_URL;
10+
return typeof value === 'string' && value.trim() ? value.trim() : null;
11+
}
12+
13+
function resolveVerificationMode() {
14+
return getProviderVerificationUrl()
15+
? PAYMENT_VERIFICATION_MODES.PROVIDER_VERIFIED
16+
: PAYMENT_VERIFICATION_MODES.DEMO_ACCEPTED_ENVELOPE;
17+
}
18+
19+
async function verifyWithProvider({ payload, req }) {
20+
const url = getProviderVerificationUrl();
21+
if (!url) {
22+
return {
23+
ok: true,
24+
paymentVerificationMode: PAYMENT_VERIFICATION_MODES.DEMO_ACCEPTED_ENVELOPE,
25+
provider: null,
26+
};
27+
}
28+
29+
const headers = {
30+
'Content-Type': 'application/json; charset=utf-8',
31+
};
32+
if (process.env.X402_PROVIDER_API_KEY) {
33+
headers.Authorization = `Bearer ${process.env.X402_PROVIDER_API_KEY}`;
34+
}
35+
36+
const providerPayload = {
37+
payment: payload.payment,
38+
request: {
39+
request_id: payload.request_id,
40+
action: payload.action,
41+
input: payload.input,
42+
},
43+
metadata: {
44+
method: req.method,
45+
path: req.url || req.path || '/api/examples/x402-paid-action',
46+
headers: {
47+
'x-request-id': req.headers?.['x-request-id'] || req.headers?.['X-Request-Id'] || null,
48+
},
49+
},
50+
};
51+
52+
let response;
53+
try {
54+
response = await fetch(url, {
55+
method: 'POST',
56+
headers,
57+
body: JSON.stringify(providerPayload),
58+
});
59+
} catch {
60+
return { ok: false, httpStatus: 503, status: 'payment_provider_unavailable' };
61+
}
62+
63+
let data;
64+
try {
65+
data = await response.json();
66+
} catch {
67+
return { ok: false, httpStatus: 503, status: 'payment_provider_unavailable' };
68+
}
69+
70+
if (!data || typeof data !== 'object') {
71+
return { ok: false, httpStatus: 503, status: 'payment_provider_unavailable' };
72+
}
73+
74+
const accepted = data.accepted === true || data.settled === true || data.status === 'accepted' || data.status === 'settled';
75+
if (!response.ok || !accepted) {
76+
const paymentStatus = data.status;
77+
if (paymentStatus === 'required') return { ok: false, httpStatus: 402, status: 'payment_required' };
78+
if (paymentStatus === 'invalid' || response.status === 400 || response.status === 402) {
79+
return { ok: false, httpStatus: response.status === 402 ? 402 : 400, status: response.status === 402 ? 'payment_required' : 'payment_invalid' };
80+
}
81+
return { ok: false, httpStatus: 503, status: 'payment_provider_unavailable' };
82+
}
83+
84+
return {
85+
ok: true,
86+
paymentVerificationMode: PAYMENT_VERIFICATION_MODES.PROVIDER_VERIFIED,
87+
provider: {
88+
status: typeof data.status === 'string' ? data.status : 'accepted',
89+
reference: typeof data.reference === 'string' ? data.reference : null,
90+
provider: typeof data.provider === 'string' ? data.provider : null,
91+
},
92+
};
93+
}
94+
95+
module.exports = {
96+
PAYMENT_VERIFICATION_MODES,
97+
resolveVerificationMode,
98+
verifyWithProvider,
99+
};

tests/api-x402-paid-action.test.js

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ function makeRes() {
1919
}
2020

2121
const originalEnv = { ...process.env };
22+
const originalFetch = global.fetch;
2223

2324
function validPayload(overrides = {}) {
2425
return {
@@ -37,6 +38,14 @@ function validPayload(overrides = {}) {
3738
};
3839
}
3940

41+
function setSigningEnv() {
42+
process.env.CL_RECEIPT_SIGNER_ID = 'runtime.commandlayer.eth';
43+
process.env.CL_RECEIPT_SIGNING_KID = 'x402-kid-1';
44+
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
45+
process.env.CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' });
46+
return publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('base64');
47+
}
48+
4049
test.beforeEach(() => {
4150
process.env = { ...originalEnv };
4251
delete process.env.CL_RECEIPT_SIGNER_ID;
@@ -45,11 +54,15 @@ test.beforeEach(() => {
4554
delete process.env.RECEIPT_SIGNER_ID;
4655
delete process.env.RECEIPT_SIGNING_KID;
4756
delete process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64;
57+
delete process.env.X402_PROVIDER_VERIFICATION_URL;
58+
delete process.env.X402_PROVIDER_API_KEY;
59+
global.fetch = originalFetch;
4860
handler._internal.clearSeen();
4961
});
5062

5163
test.after(() => {
5264
process.env = originalEnv;
65+
global.fetch = originalFetch;
5366
});
5467

5568
test('GET returns 405', async () => {
@@ -100,22 +113,16 @@ test('missing signing env returns 503 after valid request', async () => {
100113
assert.equal(res.body.status, 'signing_unavailable');
101114
});
102115

103-
test('valid paid action returns signed receipt; duplicate returns same receipt; verifies locally', async () => {
104-
process.env.CL_RECEIPT_SIGNER_ID = 'runtime.commandlayer.eth';
105-
process.env.CL_RECEIPT_SIGNING_KID = 'x402-kid-1';
106-
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
107-
process.env.CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' });
108-
109-
const pubRaw = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('base64');
116+
test('demo mode returns signed receipt, includes verification mode, duplicate returns same receipt; verifies locally', async () => {
117+
const pubRaw = setSigningEnv();
110118

111119
const res1 = makeRes();
112120
await handler({ method: 'POST', headers: {}, body: validPayload() }, res1);
113121
assert.equal(res1.statusCode, 200);
114122
assert.equal(res1.body.status, 'PAID_ACTION_EXECUTED_AND_SIGNED');
115123
assert.equal(res1.body.duplicate, false);
116-
assert.equal(res1.body.receipt.metadata.trace.trace_id, 'x402:req_1');
117-
assert.equal(res1.body.receipt.metadata.proof.hash.alg, 'SHA-256');
118-
assert.equal(res1.body.receipt.metadata.proof.signature.alg, 'Ed25519');
124+
assert.equal(res1.body.receipt.output.payment_verification_mode, 'demo_accepted_envelope');
125+
assert.equal(res1.body.receipt.metadata.trace.tags.payment_verification_mode, 'demo_accepted_envelope');
119126

120127
const verification = await verifyReceipt(res1.body.receipt, {
121128
ens: {
@@ -139,3 +146,78 @@ test('valid paid action returns signed receipt; duplicate returns same receipt;
139146
assert.equal(res2.body.duplicate, true);
140147
assert.deepEqual(res2.body.receipt, res1.body.receipt);
141148
});
149+
150+
test('provider mode success returns provider_verified and safe provider metadata', async () => {
151+
const pubRaw = setSigningEnv();
152+
process.env.X402_PROVIDER_VERIFICATION_URL = 'https://provider.example/verify';
153+
process.env.X402_PROVIDER_API_KEY = 'super-secret-token';
154+
155+
global.fetch = async (_url, options) => {
156+
assert.equal(options.headers.Authorization, 'Bearer super-secret-token');
157+
return {
158+
ok: true,
159+
status: 200,
160+
async json() {
161+
return { accepted: true, status: 'settled', reference: 'prov_ref_123', provider: 'demo-provider' };
162+
},
163+
};
164+
};
165+
166+
const res = makeRes();
167+
await handler({ method: 'POST', headers: {}, body: validPayload() }, res);
168+
assert.equal(res.statusCode, 200);
169+
assert.equal(res.body.receipt.output.payment_verification_mode, 'provider_verified');
170+
assert.equal(res.body.receipt.metadata.trace.tags.payment_verification_mode, 'provider_verified');
171+
assert.equal(res.body.receipt.metadata.trace.provider_verification.reference, 'prov_ref_123');
172+
assert.equal(JSON.stringify(res.body).includes('super-secret-token'), false);
173+
174+
const verification = await verifyReceipt(res.body.receipt, {
175+
ens: {
176+
textResolver: async (name, key) => {
177+
if (name !== 'runtime.commandlayer.eth') return null;
178+
const records = {
179+
'cl.sig.pub': `ed25519:${pubRaw}`,
180+
'cl.sig.kid': 'x402-kid-1',
181+
'cl.sig.canonical': 'json.sorted_keys.v1',
182+
'cl.receipt.signer': 'runtime.commandlayer.eth',
183+
};
184+
return records[key] || null;
185+
},
186+
},
187+
});
188+
assert.equal(verification.ok, true);
189+
});
190+
191+
test('provider mode rejection returns payment_invalid/payment_required', async () => {
192+
setSigningEnv();
193+
process.env.X402_PROVIDER_VERIFICATION_URL = 'https://provider.example/verify';
194+
195+
global.fetch = async () => ({ ok: false, status: 400, async json() { return { status: 'invalid' }; } });
196+
const invalidRes = makeRes();
197+
await handler({ method: 'POST', headers: {}, body: validPayload() }, invalidRes);
198+
assert.equal(invalidRes.statusCode, 400);
199+
assert.equal(invalidRes.body.status, 'payment_invalid');
200+
201+
global.fetch = async () => ({ ok: false, status: 402, async json() { return { status: 'required' }; } });
202+
const requiredRes = makeRes();
203+
await handler({ method: 'POST', headers: {}, body: validPayload({ request_id: 'req_2', payment: { ...validPayload().payment, payment_id: 'pay_2' } }) }, requiredRes);
204+
assert.equal(requiredRes.statusCode, 402);
205+
assert.equal(requiredRes.body.status, 'payment_required');
206+
});
207+
208+
test('provider unavailable/malformed response returns 503 payment_provider_unavailable', async () => {
209+
setSigningEnv();
210+
process.env.X402_PROVIDER_VERIFICATION_URL = 'https://provider.example/verify';
211+
212+
global.fetch = async () => { throw new Error('network'); };
213+
const networkRes = makeRes();
214+
await handler({ method: 'POST', headers: {}, body: validPayload() }, networkRes);
215+
assert.equal(networkRes.statusCode, 503);
216+
assert.equal(networkRes.body.status, 'payment_provider_unavailable');
217+
218+
global.fetch = async () => ({ ok: true, status: 200, async json() { return 'bad'; } });
219+
const malformedRes = makeRes();
220+
await handler({ method: 'POST', headers: {}, body: validPayload({ request_id: 'req_3', payment: { ...validPayload().payment, payment_id: 'pay_3' } }) }, malformedRes);
221+
assert.equal(malformedRes.statusCode, 503);
222+
assert.equal(malformedRes.body.status, 'payment_provider_unavailable');
223+
});

0 commit comments

Comments
 (0)