From 44bcb1ee4dd76e4e504bce515cc646e3938a39f6 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Fri, 22 May 2026 16:32:44 -0400 Subject: [PATCH] Add x402 paid action to CLAS receipt example --- .../x402-paid-action-receipt/.env.example | 6 + examples/x402-paid-action-receipt/README.md | 123 ++++++++++++++++++ .../mockAgentAction.js | 23 ++++ .../mockX402Payment.js | 34 +++++ .../receiptBuilder.js | 62 +++++++++ .../sample-paid-action-request.json | 13 ++ .../sample-payment-accepted.json | 9 ++ examples/x402-paid-action-receipt/server.js | 92 +++++++++++++ tests/x402-paid-action-receipt.test.js | 70 ++++++++++ 9 files changed, 432 insertions(+) create mode 100644 examples/x402-paid-action-receipt/.env.example create mode 100644 examples/x402-paid-action-receipt/README.md create mode 100644 examples/x402-paid-action-receipt/mockAgentAction.js create mode 100644 examples/x402-paid-action-receipt/mockX402Payment.js create mode 100644 examples/x402-paid-action-receipt/receiptBuilder.js create mode 100644 examples/x402-paid-action-receipt/sample-paid-action-request.json create mode 100644 examples/x402-paid-action-receipt/sample-payment-accepted.json create mode 100644 examples/x402-paid-action-receipt/server.js create mode 100644 tests/x402-paid-action-receipt.test.js diff --git a/examples/x402-paid-action-receipt/.env.example b/examples/x402-paid-action-receipt/.env.example new file mode 100644 index 0000000..eb77c08 --- /dev/null +++ b/examples/x402-paid-action-receipt/.env.example @@ -0,0 +1,6 @@ +# Optional server settings +PORT=4000 +WORKFLOW_ID=wf_example_local + +# Placeholder key id only. Do not place private signing keys in this example. +RUNTIME_SIGNING_KEY_ID=cl_runtime_key_example_2026 diff --git a/examples/x402-paid-action-receipt/README.md b/examples/x402-paid-action-receipt/README.md new file mode 100644 index 0000000..757e9fb --- /dev/null +++ b/examples/x402-paid-action-receipt/README.md @@ -0,0 +1,123 @@ +# x402 Paid Action → CLAS Receipt (Example Only) + +This example demonstrates a **mock** integration flow where a paid action request is linked to a simulated x402 payment acceptance event and emitted as a CLAS-style action receipt. + +> This is not production settlement or production signing. It is an educational example. + +## What this example does + +- Accepts a mock paid action request. +- Simulates an x402 `payment.accepted` event input. +- Executes a mock agent action (`summarize.text`). +- Emits a CLAS-style receipt containing: + - `metadata.trace` for correlation. + - `metadata.proof.payment` and `metadata.proof.execution`. + - `proof.signature` placeholders for `payer`, `agent`, `runtime`, and `verifier`. + +## Setup + +```bash +cp examples/x402-paid-action-receipt/.env.example .env +``` + +No secrets are required for this mock example. Do not add private keys to `.env`. + +## Environment variables + +- `PORT` (default `4000`): local server port. +- `WORKFLOW_ID` (optional): trace workflow correlation id. +- `RUNTIME_SIGNING_KEY_ID` (optional): key identifier string used in placeholder runtime signature metadata. + +## Run locally + +```bash +node examples/x402-paid-action-receipt/server.js +``` + +Health check: + +```bash +curl -s http://localhost:4000/health +``` + +## Sample curl command + +```bash +curl -s -X POST http://localhost:4000/paid-action \ + -H 'content-type: application/json' \ + -d '{ + "paid_action_request": { + "request_id": "req_9f2f5f25", + "action": "summarize.text", + "input": {"text": "CommandLayer receipts prove execution attestation separate from payment settlement."}, + "payment": {"required": true, "plan": "pro", "max_amount": "0.05", "currency": "USD"} + }, + "payment_accepted": { + "event": "payment.accepted", + "request_id": "req_9f2f5f25", + "payment_id": "pay_x402_7f31", + "provider": "x402-compatible", + "settled_amount": "0.05", + "currency": "USD", + "accepted_at": "2026-05-22T12:00:01Z" + } + }' +``` + +## Expected output + +A `200` JSON response containing: + +- `duplicate: false` on first execution. +- `receipt.receipt_id`, `request_id`, `payment_id`. +- `metadata.trace` fields including `request_id`, `payment_id`, `receipt_id`, `workflow_id`. +- `metadata.proof.commandlayer_signing_hook` placeholder to replace with real CommandLayer signing. +- `proof.signature` role entries for `payer`, `agent`, `runtime`, `verifier`. + +If the same `request_id + payment_id` is sent again, response includes `duplicate: true` and returns the original receipt. + +## Trust boundary + +- **x402/payment provider proves settlement**: payment requirement, acceptance/rejection, settlement status. +- **CommandLayer proves execution**: action request, runtime execution output, and signed receipt artifact. +- **Do not conflate them**: payment acceptance does not prove execution correctness. + +## Failure modes + +The API documents and returns error states for: + +- missing payment +- invalid payment +- duplicate `request_id` / `payment_id` (idempotent replay returns canonical receipt) +- action execution failed +- receipt signing failed +- verifier unavailable (documented operational dependency; this mock does not call an external verifier) + +## Idempotency model + +Use and persist three IDs: + +- `request_id`: semantic request identity. +- `payment_id`: payment-settlement identity. +- `receipt_id`: emitted CLAS receipt identity. + +Dedupe key in this example is `request_id + payment_id`. + +## Production-readiness path + +To move this example to production: + +1. Replace mock payment validation with real x402 provider verification. +2. Persist idempotency state in durable storage (DB/cache), not in-memory maps. +3. Implement canonical receipt signing using CommandLayer keys/HSM/KMS (replace placeholders). +4. Add verifier invocation and signed verifier attestations for `verifier` role. +5. Add schema validation for incoming request/event payloads. +6. Add audit logging, retries, and alerting for `RECEIPT_SIGNING_FAILED` and verifier outages. +7. Protect endpoints with authn/authz and rate limiting. + +## Validation commands + +```bash +npm test +node --test tests/x402-paid-action-receipt.test.js +``` diff --git a/examples/x402-paid-action-receipt/mockAgentAction.js b/examples/x402-paid-action-receipt/mockAgentAction.js new file mode 100644 index 0000000..946891f --- /dev/null +++ b/examples/x402-paid-action-receipt/mockAgentAction.js @@ -0,0 +1,23 @@ +function summarizeText(input) { + if (!input || typeof input.text !== 'string' || input.text.trim().length === 0) { + throw new Error('ACTION_EXECUTION_FAILED: input.text is required for summarize.text.'); + } + + const normalized = input.text.replace(/\s+/g, ' ').trim(); + const sentence = normalized.split(/(?<=[.!?])\s+/)[0] || normalized; + return { + summary: sentence.length > 200 ? `${sentence.slice(0, 197)}...` : sentence + }; +} + +function executeMockAgentAction(action, input) { + if (action !== 'summarize.text') { + throw new Error(`ACTION_EXECUTION_FAILED: unsupported action ${action}.`); + } + + return summarizeText(input); +} + +module.exports = { + executeMockAgentAction +}; diff --git a/examples/x402-paid-action-receipt/mockX402Payment.js b/examples/x402-paid-action-receipt/mockX402Payment.js new file mode 100644 index 0000000..aef2cf8 --- /dev/null +++ b/examples/x402-paid-action-receipt/mockX402Payment.js @@ -0,0 +1,34 @@ +const crypto = require('node:crypto'); + +function assertPaymentAccepted(paymentEvent, paidActionRequest) { + if (!paymentEvent) { + throw new Error('MISSING_PAYMENT: payment accepted event is required before execution.'); + } + + if (paymentEvent.event !== 'payment.accepted') { + throw new Error('INVALID_PAYMENT: expected event to be payment.accepted.'); + } + + if (paymentEvent.request_id !== paidActionRequest.request_id) { + throw new Error('INVALID_PAYMENT: request_id mismatch between action request and payment event.'); + } + + if (!paymentEvent.payment_id || !paymentEvent.provider) { + throw new Error('INVALID_PAYMENT: payment_id and provider are required.'); + } + + return { + settlement_status: 'accepted', + payment_id: paymentEvent.payment_id, + payment_ref: paymentEvent.payment_id, + provider: paymentEvent.provider, + settled_amount: paymentEvent.settled_amount, + currency: paymentEvent.currency, + accepted_at: paymentEvent.accepted_at, + verification_token: `x402v1:${crypto.createHash('sha256').update(JSON.stringify(paymentEvent)).digest('hex').slice(0, 24)}` + }; +} + +module.exports = { + assertPaymentAccepted +}; diff --git a/examples/x402-paid-action-receipt/receiptBuilder.js b/examples/x402-paid-action-receipt/receiptBuilder.js new file mode 100644 index 0000000..00a45a4 --- /dev/null +++ b/examples/x402-paid-action-receipt/receiptBuilder.js @@ -0,0 +1,62 @@ +const crypto = require('node:crypto'); + +function makeId(prefix) { + return `${prefix}_${crypto.randomUUID().replace(/-/g, '').slice(0, 20)}`; +} + +function buildClasReceipt({ paidActionRequest, paymentAcceptance, executionResult, signingKeyId, workflowId }) { + if (!paidActionRequest?.request_id || !paymentAcceptance?.payment_id) { + throw new Error('RECEIPT_SIGNING_FAILED: missing request_id or payment_id in receipt build input.'); + } + + const requestedAt = new Date().toISOString(); + const executedAt = new Date().toISOString(); + const receiptId = makeId('rcpt_clas_act'); + + const receipt = { + receipt_id: receiptId, + request_id: paidActionRequest.request_id, + payment_id: paymentAcceptance.payment_id, + action: paidActionRequest.action, + status: 'succeeded', + requested_at: requestedAt, + executed_at: executedAt, + result: executionResult, + metadata: { + trace: { + request_id: paidActionRequest.request_id, + payment_id: paymentAcceptance.payment_id, + receipt_id: receiptId, + workflow_id: workflowId || makeId('wf'), + provider: paymentAcceptance.provider + }, + proof: { + payment: { + scheme: 'x402', + settlement_status: paymentAcceptance.settlement_status, + payment_ref: paymentAcceptance.payment_ref + }, + execution: { + runtime_id: 'runtime_example_local', + agent_id: 'agent_mock_summarizer_v1', + policy_hash: 'sha256:example-policy-hash' + }, + commandlayer_signing_hook: 'Replace proof.signature placeholders with real CommandLayer signing in production.' + } + }, + proof: { + signature: [ + { role: 'payer', alg: 'Ed25519', key_id: 'payer_key_placeholder', sig: 'PLACEHOLDER_PAYER_SIG' }, + { role: 'agent', alg: 'Ed25519', key_id: 'agent_key_placeholder', sig: 'PLACEHOLDER_AGENT_SIG' }, + { role: 'runtime', alg: 'Ed25519', key_id: signingKeyId || 'runtime_key_placeholder', sig: 'PLACEHOLDER_RUNTIME_SIG' }, + { role: 'verifier', alg: 'Ed25519', key_id: 'verifier_key_placeholder', sig: 'PLACEHOLDER_VERIFIER_SIG' } + ] + } + }; + + return receipt; +} + +module.exports = { + buildClasReceipt +}; diff --git a/examples/x402-paid-action-receipt/sample-paid-action-request.json b/examples/x402-paid-action-receipt/sample-paid-action-request.json new file mode 100644 index 0000000..0cc1fb0 --- /dev/null +++ b/examples/x402-paid-action-receipt/sample-paid-action-request.json @@ -0,0 +1,13 @@ +{ + "request_id": "req_9f2f5f25", + "action": "summarize.text", + "input": { + "text": "CommandLayer receipts provide portable attestations of what an agent action executed and returned." + }, + "payment": { + "required": true, + "plan": "pro", + "max_amount": "0.05", + "currency": "USD" + } +} diff --git a/examples/x402-paid-action-receipt/sample-payment-accepted.json b/examples/x402-paid-action-receipt/sample-payment-accepted.json new file mode 100644 index 0000000..c55046e --- /dev/null +++ b/examples/x402-paid-action-receipt/sample-payment-accepted.json @@ -0,0 +1,9 @@ +{ + "event": "payment.accepted", + "request_id": "req_9f2f5f25", + "payment_id": "pay_x402_7f31", + "provider": "x402-compatible", + "settled_amount": "0.05", + "currency": "USD", + "accepted_at": "2026-05-22T12:00:01Z" +} diff --git a/examples/x402-paid-action-receipt/server.js b/examples/x402-paid-action-receipt/server.js new file mode 100644 index 0000000..bfe5b67 --- /dev/null +++ b/examples/x402-paid-action-receipt/server.js @@ -0,0 +1,92 @@ +const http = require('node:http'); +const { assertPaymentAccepted } = require('./mockX402Payment'); +const { executeMockAgentAction } = require('./mockAgentAction'); +const { buildClasReceipt } = require('./receiptBuilder'); + +const dedupe = new Map(); + +function dedupeKey(requestId, paymentId) { + return `${requestId}:${paymentId}`; +} + +function sendJson(res, statusCode, body) { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body, null, 2)); +} + +function createServer() { + return http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/health') { + return sendJson(res, 200, { ok: true }); + } + + if (req.method === 'POST' && req.url === '/paid-action') { + let raw = ''; + req.on('data', (chunk) => { + raw += chunk; + }); + + req.on('end', () => { + let body; + try { + body = raw ? JSON.parse(raw) : {}; + } catch { + return sendJson(res, 400, { error: 'INVALID_JSON', message: 'Request body must be valid JSON.' }); + } + + const { paid_action_request: paidActionRequest, payment_accepted: paymentAccepted } = body || {}; + if (!paidActionRequest) { + return sendJson(res, 400, { error: 'MISSING_REQUEST', message: 'paid_action_request is required.' }); + } + + let paymentAcceptance; + try { + paymentAcceptance = assertPaymentAccepted(paymentAccepted, paidActionRequest); + } catch (error) { + return sendJson(res, 402, { error: 'PAYMENT_REQUIRED_OR_INVALID', message: error.message }); + } + + const key = dedupeKey(paidActionRequest.request_id, paymentAcceptance.payment_id); + if (dedupe.has(key)) { + return sendJson(res, 200, { duplicate: true, receipt: dedupe.get(key) }); + } + + let actionResult; + try { + actionResult = executeMockAgentAction(paidActionRequest.action, paidActionRequest.input); + } catch (error) { + return sendJson(res, 500, { error: 'ACTION_EXECUTION_FAILED', message: error.message }); + } + + let receipt; + try { + receipt = buildClasReceipt({ + paidActionRequest, + paymentAcceptance, + executionResult: actionResult, + signingKeyId: process.env.RUNTIME_SIGNING_KEY_ID, + workflowId: process.env.WORKFLOW_ID + }); + } catch (error) { + return sendJson(res, 500, { error: 'RECEIPT_SIGNING_FAILED', message: error.message }); + } + + dedupe.set(key, receipt); + return sendJson(res, 200, { duplicate: false, receipt }); + }); + return; + } + + sendJson(res, 404, { error: 'NOT_FOUND' }); + }); +} + +if (require.main === module) { + const port = Number(process.env.PORT || 4000); + createServer().listen(port, () => { + // eslint-disable-next-line no-console + console.log(`x402 paid action receipt example listening on http://localhost:${port}`); + }); +} + +module.exports = { createServer, dedupeKey }; diff --git a/tests/x402-paid-action-receipt.test.js b/tests/x402-paid-action-receipt.test.js new file mode 100644 index 0000000..1212a92 --- /dev/null +++ b/tests/x402-paid-action-receipt.test.js @@ -0,0 +1,70 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); + +const { createServer } = require('../examples/x402-paid-action-receipt/server'); + +function postJson(port, path, body) { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path, + method: 'POST', + headers: { 'content-type': 'application/json' } + }, + (res) => { + let raw = ''; + res.on('data', (chunk) => { + raw += chunk; + }); + res.on('end', () => { + resolve({ statusCode: res.statusCode, body: JSON.parse(raw) }); + }); + } + ); + req.on('error', reject); + req.write(JSON.stringify(body)); + req.end(); + }); +} + +test('paid-action emits receipt and enforces idempotency', async () => { + const server = createServer(); + await new Promise((resolve) => server.listen(0, resolve)); + const port = server.address().port; + + const payload = { + paid_action_request: { + request_id: 'req_test_1', + action: 'summarize.text', + input: { text: 'A long text used to create a short summary.' }, + payment: { required: true, plan: 'pro', max_amount: '0.05', currency: 'USD' } + }, + payment_accepted: { + event: 'payment.accepted', + request_id: 'req_test_1', + payment_id: 'pay_test_1', + provider: 'x402-compatible', + settled_amount: '0.05', + currency: 'USD', + accepted_at: '2026-05-22T12:00:01Z' + } + }; + + const first = await postJson(port, '/paid-action', payload); + assert.equal(first.statusCode, 200); + assert.equal(first.body.duplicate, false); + assert.equal(first.body.receipt.request_id, 'req_test_1'); + assert.equal(first.body.receipt.payment_id, 'pay_test_1'); + assert.equal(first.body.receipt.metadata.trace.request_id, 'req_test_1'); + assert.equal(first.body.receipt.metadata.trace.payment_id, 'pay_test_1'); + + const second = await postJson(port, '/paid-action', payload); + assert.equal(second.statusCode, 200); + assert.equal(second.body.duplicate, true); + assert.equal(second.body.receipt.receipt_id, first.body.receipt.receipt_id); + + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); +});