From b7487d86ace976f4912c8b3e68d423c323d6a720 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sat, 23 May 2026 20:39:56 -0400 Subject: [PATCH] Add x402 provider verification abstraction for paid-action --- api/examples/x402-paid-action.js | 26 ++++- .../x402-commandlayer-receipts.md | 21 +++- lib/x402ProviderVerification.js | 99 +++++++++++++++++ tests/api-x402-paid-action.test.js | 102 ++++++++++++++++-- 4 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 lib/x402ProviderVerification.js diff --git a/api/examples/x402-paid-action.js b/api/examples/x402-paid-action.js index 4aa8e7c..5208cba 100644 --- a/api/examples/x402-paid-action.js +++ b/api/examples/x402-paid-action.js @@ -1,6 +1,7 @@ 'use strict'; const { signReceipt, resolveReceiptSigningConfigFromEnv, hasValidSigningConfig } = require('../../lib/receiptSigning'); +const { verifyWithProvider } = require('../../lib/x402ProviderVerification'); const seenReceipts = new Map(); const MAX_TEXT_LENGTH = 4000; @@ -11,12 +12,12 @@ function buildDeterministicSummary(text) { return prefix.length < normalized.length ? `${prefix}…` : prefix; } -function normalizePaidActionReceipt(payload, signerId) { +function normalizePaidActionReceipt(payload, signerId, verificationResult) { const timestamp = new Date().toISOString(); const paymentId = payload.payment.payment_id; const requestId = payload.request_id; - return { + const receipt = { receipt_id: `rcpt:x402:${paymentId}:${requestId}`, signer: signerId, verb: 'summarize', @@ -40,6 +41,7 @@ function normalizePaidActionReceipt(payload, signerId) { output: { summary: buildDeterministicSummary(payload.input.text), payment_accepted: true, + payment_verification_mode: verificationResult.paymentVerificationMode, }, execution: { status: 'succeeded' }, ts: timestamp, @@ -52,10 +54,23 @@ function normalizePaidActionReceipt(payload, signerId) { payment_protocol: 'x402', payment_id: paymentId, action: 'summarize.text', + payment_verification_mode: verificationResult.paymentVerificationMode, }, }, }, }; + + if (verificationResult.provider) { + const safeProvider = {}; + if (verificationResult.provider.provider) safeProvider.provider = verificationResult.provider.provider; + if (verificationResult.provider.status) safeProvider.status = verificationResult.provider.status; + if (verificationResult.provider.reference) safeProvider.reference = verificationResult.provider.reference; + if (Object.keys(safeProvider).length) { + receipt.metadata.trace.provider_verification = safeProvider; + } + } + + return receipt; } function parsePayload(body) { @@ -115,6 +130,11 @@ module.exports = async function handler(req, res) { return res.status(400).json({ ok: false, status: 'payment_invalid' }); } + const verificationResult = await verifyWithProvider({ payload, req }); + if (!verificationResult.ok) { + return res.status(verificationResult.httpStatus).json({ ok: false, status: verificationResult.status }); + } + const dedupeKey = `${requestId}::${payment.payment_id}`; if (seenReceipts.has(dedupeKey)) { 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) { } try { - const unsignedReceipt = normalizePaidActionReceipt(payload, signingCfg.signerId || 'runtime.commandlayer.eth'); + const unsignedReceipt = normalizePaidActionReceipt(payload, signingCfg.signerId || 'runtime.commandlayer.eth', verificationResult); const receipt = await signReceipt(unsignedReceipt, signingCfg); seenReceipts.set(dedupeKey, receipt); return res.status(200).json({ ok: true, status: 'PAID_ACTION_EXECUTED_AND_SIGNED', duplicate: false, receipt }); diff --git a/docs/integrations/x402-commandlayer-receipts.md b/docs/integrations/x402-commandlayer-receipts.md index c36c63d..0cb62b9 100644 --- a/docs/integrations/x402-commandlayer-receipts.md +++ b/docs/integrations/x402-commandlayer-receipts.md @@ -297,6 +297,23 @@ A server-side example endpoint is available at `POST /api/examples/x402-paid-act The endpoint returns status `PAID_ACTION_EXECUTED_AND_SIGNED` with a signed CLAS-style receipt. +### Verification modes + +The example supports two server-side payment verification modes: + +- `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"`. +- `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. + +Optional provider auth: + +- `X402_PROVIDER_API_KEY`: when set, the server sends `Authorization: Bearer ` to the provider verification endpoint. +- Keys are not returned in API responses or receipt metadata. + +Failure mapping in provider mode: + +- Provider payment rejection: `400 payment_invalid` or `402 payment_required`. +- Provider unavailable/network/malformed response: `503 payment_provider_unavailable`. + ### Verification command You can verify the returned receipt with the existing verify endpoint: @@ -323,6 +340,6 @@ Example verified result pattern (redacted for safety): - signature_valid: `true` - key_id: `vC4WbcNoq2znSCiQ` -This shows a paid action can emit signed execution proof after an accepted x402 payment envelope is validated. +This shows a paid action can emit signed execution proof after payment verification succeeds under the active mode. -The example validates an accepted x402 payment envelope; it does not claim full production settlement unless wired to a real x402 provider. +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. diff --git a/lib/x402ProviderVerification.js b/lib/x402ProviderVerification.js new file mode 100644 index 0000000..3b50dbe --- /dev/null +++ b/lib/x402ProviderVerification.js @@ -0,0 +1,99 @@ +'use strict'; + +const PAYMENT_VERIFICATION_MODES = { + DEMO_ACCEPTED_ENVELOPE: 'demo_accepted_envelope', + PROVIDER_VERIFIED: 'provider_verified', +}; + +function getProviderVerificationUrl() { + const value = process.env.X402_PROVIDER_VERIFICATION_URL; + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function resolveVerificationMode() { + return getProviderVerificationUrl() + ? PAYMENT_VERIFICATION_MODES.PROVIDER_VERIFIED + : PAYMENT_VERIFICATION_MODES.DEMO_ACCEPTED_ENVELOPE; +} + +async function verifyWithProvider({ payload, req }) { + const url = getProviderVerificationUrl(); + if (!url) { + return { + ok: true, + paymentVerificationMode: PAYMENT_VERIFICATION_MODES.DEMO_ACCEPTED_ENVELOPE, + provider: null, + }; + } + + const headers = { + 'Content-Type': 'application/json; charset=utf-8', + }; + if (process.env.X402_PROVIDER_API_KEY) { + headers.Authorization = `Bearer ${process.env.X402_PROVIDER_API_KEY}`; + } + + const providerPayload = { + payment: payload.payment, + request: { + request_id: payload.request_id, + action: payload.action, + input: payload.input, + }, + metadata: { + method: req.method, + path: req.url || req.path || '/api/examples/x402-paid-action', + headers: { + 'x-request-id': req.headers?.['x-request-id'] || req.headers?.['X-Request-Id'] || null, + }, + }, + }; + + let response; + try { + response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(providerPayload), + }); + } catch { + return { ok: false, httpStatus: 503, status: 'payment_provider_unavailable' }; + } + + let data; + try { + data = await response.json(); + } catch { + return { ok: false, httpStatus: 503, status: 'payment_provider_unavailable' }; + } + + if (!data || typeof data !== 'object') { + return { ok: false, httpStatus: 503, status: 'payment_provider_unavailable' }; + } + + const accepted = data.accepted === true || data.settled === true || data.status === 'accepted' || data.status === 'settled'; + if (!response.ok || !accepted) { + const paymentStatus = data.status; + if (paymentStatus === 'required') return { ok: false, httpStatus: 402, status: 'payment_required' }; + if (paymentStatus === 'invalid' || response.status === 400 || response.status === 402) { + return { ok: false, httpStatus: response.status === 402 ? 402 : 400, status: response.status === 402 ? 'payment_required' : 'payment_invalid' }; + } + return { ok: false, httpStatus: 503, status: 'payment_provider_unavailable' }; + } + + return { + ok: true, + paymentVerificationMode: PAYMENT_VERIFICATION_MODES.PROVIDER_VERIFIED, + provider: { + status: typeof data.status === 'string' ? data.status : 'accepted', + reference: typeof data.reference === 'string' ? data.reference : null, + provider: typeof data.provider === 'string' ? data.provider : null, + }, + }; +} + +module.exports = { + PAYMENT_VERIFICATION_MODES, + resolveVerificationMode, + verifyWithProvider, +}; diff --git a/tests/api-x402-paid-action.test.js b/tests/api-x402-paid-action.test.js index 952f731..27c42ed 100644 --- a/tests/api-x402-paid-action.test.js +++ b/tests/api-x402-paid-action.test.js @@ -19,6 +19,7 @@ function makeRes() { } const originalEnv = { ...process.env }; +const originalFetch = global.fetch; function validPayload(overrides = {}) { return { @@ -37,6 +38,14 @@ function validPayload(overrides = {}) { }; } +function setSigningEnv() { + process.env.CL_RECEIPT_SIGNER_ID = 'runtime.commandlayer.eth'; + process.env.CL_RECEIPT_SIGNING_KID = 'x402-kid-1'; + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519'); + process.env.CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }); + return publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('base64'); +} + test.beforeEach(() => { process.env = { ...originalEnv }; delete process.env.CL_RECEIPT_SIGNER_ID; @@ -45,11 +54,15 @@ test.beforeEach(() => { delete process.env.RECEIPT_SIGNER_ID; delete process.env.RECEIPT_SIGNING_KID; delete process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64; + delete process.env.X402_PROVIDER_VERIFICATION_URL; + delete process.env.X402_PROVIDER_API_KEY; + global.fetch = originalFetch; handler._internal.clearSeen(); }); test.after(() => { process.env = originalEnv; + global.fetch = originalFetch; }); test('GET returns 405', async () => { @@ -100,22 +113,16 @@ test('missing signing env returns 503 after valid request', async () => { assert.equal(res.body.status, 'signing_unavailable'); }); -test('valid paid action returns signed receipt; duplicate returns same receipt; verifies locally', async () => { - process.env.CL_RECEIPT_SIGNER_ID = 'runtime.commandlayer.eth'; - process.env.CL_RECEIPT_SIGNING_KID = 'x402-kid-1'; - const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519'); - process.env.CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }); - - const pubRaw = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('base64'); +test('demo mode returns signed receipt, includes verification mode, duplicate returns same receipt; verifies locally', async () => { + const pubRaw = setSigningEnv(); const res1 = makeRes(); await handler({ method: 'POST', headers: {}, body: validPayload() }, res1); assert.equal(res1.statusCode, 200); assert.equal(res1.body.status, 'PAID_ACTION_EXECUTED_AND_SIGNED'); assert.equal(res1.body.duplicate, false); - assert.equal(res1.body.receipt.metadata.trace.trace_id, 'x402:req_1'); - assert.equal(res1.body.receipt.metadata.proof.hash.alg, 'SHA-256'); - assert.equal(res1.body.receipt.metadata.proof.signature.alg, 'Ed25519'); + assert.equal(res1.body.receipt.output.payment_verification_mode, 'demo_accepted_envelope'); + assert.equal(res1.body.receipt.metadata.trace.tags.payment_verification_mode, 'demo_accepted_envelope'); const verification = await verifyReceipt(res1.body.receipt, { ens: { @@ -139,3 +146,78 @@ test('valid paid action returns signed receipt; duplicate returns same receipt; assert.equal(res2.body.duplicate, true); assert.deepEqual(res2.body.receipt, res1.body.receipt); }); + +test('provider mode success returns provider_verified and safe provider metadata', async () => { + const pubRaw = setSigningEnv(); + process.env.X402_PROVIDER_VERIFICATION_URL = 'https://provider.example/verify'; + process.env.X402_PROVIDER_API_KEY = 'super-secret-token'; + + global.fetch = async (_url, options) => { + assert.equal(options.headers.Authorization, 'Bearer super-secret-token'); + return { + ok: true, + status: 200, + async json() { + return { accepted: true, status: 'settled', reference: 'prov_ref_123', provider: 'demo-provider' }; + }, + }; + }; + + const res = makeRes(); + await handler({ method: 'POST', headers: {}, body: validPayload() }, res); + assert.equal(res.statusCode, 200); + assert.equal(res.body.receipt.output.payment_verification_mode, 'provider_verified'); + assert.equal(res.body.receipt.metadata.trace.tags.payment_verification_mode, 'provider_verified'); + assert.equal(res.body.receipt.metadata.trace.provider_verification.reference, 'prov_ref_123'); + assert.equal(JSON.stringify(res.body).includes('super-secret-token'), false); + + const verification = await verifyReceipt(res.body.receipt, { + ens: { + textResolver: async (name, key) => { + if (name !== 'runtime.commandlayer.eth') return null; + const records = { + 'cl.sig.pub': `ed25519:${pubRaw}`, + 'cl.sig.kid': 'x402-kid-1', + 'cl.sig.canonical': 'json.sorted_keys.v1', + 'cl.receipt.signer': 'runtime.commandlayer.eth', + }; + return records[key] || null; + }, + }, + }); + assert.equal(verification.ok, true); +}); + +test('provider mode rejection returns payment_invalid/payment_required', async () => { + setSigningEnv(); + process.env.X402_PROVIDER_VERIFICATION_URL = 'https://provider.example/verify'; + + global.fetch = async () => ({ ok: false, status: 400, async json() { return { status: 'invalid' }; } }); + const invalidRes = makeRes(); + await handler({ method: 'POST', headers: {}, body: validPayload() }, invalidRes); + assert.equal(invalidRes.statusCode, 400); + assert.equal(invalidRes.body.status, 'payment_invalid'); + + global.fetch = async () => ({ ok: false, status: 402, async json() { return { status: 'required' }; } }); + const requiredRes = makeRes(); + await handler({ method: 'POST', headers: {}, body: validPayload({ request_id: 'req_2', payment: { ...validPayload().payment, payment_id: 'pay_2' } }) }, requiredRes); + assert.equal(requiredRes.statusCode, 402); + assert.equal(requiredRes.body.status, 'payment_required'); +}); + +test('provider unavailable/malformed response returns 503 payment_provider_unavailable', async () => { + setSigningEnv(); + process.env.X402_PROVIDER_VERIFICATION_URL = 'https://provider.example/verify'; + + global.fetch = async () => { throw new Error('network'); }; + const networkRes = makeRes(); + await handler({ method: 'POST', headers: {}, body: validPayload() }, networkRes); + assert.equal(networkRes.statusCode, 503); + assert.equal(networkRes.body.status, 'payment_provider_unavailable'); + + global.fetch = async () => ({ ok: true, status: 200, async json() { return 'bad'; } }); + const malformedRes = makeRes(); + await handler({ method: 'POST', headers: {}, body: validPayload({ request_id: 'req_3', payment: { ...validPayload().payment, payment_id: 'pay_3' } }) }, malformedRes); + assert.equal(malformedRes.statusCode, 503); + assert.equal(malformedRes.body.status, 'payment_provider_unavailable'); +});