Skip to content

Commit 08c4096

Browse files
authored
Merge pull request #285 from commandlayer/codex/create-x402-paid-action-receipt-example
Add x402 paid-action → CLAS receipt example
2 parents a6d1b85 + 44bcb1e commit 08c4096

9 files changed

Lines changed: 432 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Optional server settings
2+
PORT=4000
3+
WORKFLOW_ID=wf_example_local
4+
5+
# Placeholder key id only. Do not place private signing keys in this example.
6+
RUNTIME_SIGNING_KEY_ID=cl_runtime_key_example_2026
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# x402 Paid Action → CLAS Receipt (Example Only)
2+
3+
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.
4+
5+
> This is not production settlement or production signing. It is an educational example.
6+
7+
## What this example does
8+
9+
- Accepts a mock paid action request.
10+
- Simulates an x402 `payment.accepted` event input.
11+
- Executes a mock agent action (`summarize.text`).
12+
- Emits a CLAS-style receipt containing:
13+
- `metadata.trace` for correlation.
14+
- `metadata.proof.payment` and `metadata.proof.execution`.
15+
- `proof.signature` placeholders for `payer`, `agent`, `runtime`, and `verifier`.
16+
17+
## Setup
18+
19+
```bash
20+
cp examples/x402-paid-action-receipt/.env.example .env
21+
```
22+
23+
No secrets are required for this mock example. Do not add private keys to `.env`.
24+
25+
## Environment variables
26+
27+
- `PORT` (default `4000`): local server port.
28+
- `WORKFLOW_ID` (optional): trace workflow correlation id.
29+
- `RUNTIME_SIGNING_KEY_ID` (optional): key identifier string used in placeholder runtime signature metadata.
30+
31+
## Run locally
32+
33+
```bash
34+
node examples/x402-paid-action-receipt/server.js
35+
```
36+
37+
Health check:
38+
39+
```bash
40+
curl -s http://localhost:4000/health
41+
```
42+
43+
## Sample curl command
44+
45+
```bash
46+
curl -s -X POST http://localhost:4000/paid-action \
47+
-H 'content-type: application/json' \
48+
-d '{
49+
"paid_action_request": {
50+
"request_id": "req_9f2f5f25",
51+
"action": "summarize.text",
52+
"input": {"text": "CommandLayer receipts prove execution attestation separate from payment settlement."},
53+
"payment": {"required": true, "plan": "pro", "max_amount": "0.05", "currency": "USD"}
54+
},
55+
"payment_accepted": {
56+
"event": "payment.accepted",
57+
"request_id": "req_9f2f5f25",
58+
"payment_id": "pay_x402_7f31",
59+
"provider": "x402-compatible",
60+
"settled_amount": "0.05",
61+
"currency": "USD",
62+
"accepted_at": "2026-05-22T12:00:01Z"
63+
}
64+
}'
65+
```
66+
67+
## Expected output
68+
69+
A `200` JSON response containing:
70+
71+
- `duplicate: false` on first execution.
72+
- `receipt.receipt_id`, `request_id`, `payment_id`.
73+
- `metadata.trace` fields including `request_id`, `payment_id`, `receipt_id`, `workflow_id`.
74+
- `metadata.proof.commandlayer_signing_hook` placeholder to replace with real CommandLayer signing.
75+
- `proof.signature` role entries for `payer`, `agent`, `runtime`, `verifier`.
76+
77+
If the same `request_id + payment_id` is sent again, response includes `duplicate: true` and returns the original receipt.
78+
79+
## Trust boundary
80+
81+
- **x402/payment provider proves settlement**: payment requirement, acceptance/rejection, settlement status.
82+
- **CommandLayer proves execution**: action request, runtime execution output, and signed receipt artifact.
83+
- **Do not conflate them**: payment acceptance does not prove execution correctness.
84+
85+
## Failure modes
86+
87+
The API documents and returns error states for:
88+
89+
- missing payment
90+
- invalid payment
91+
- duplicate `request_id` / `payment_id` (idempotent replay returns canonical receipt)
92+
- action execution failed
93+
- receipt signing failed
94+
- verifier unavailable (documented operational dependency; this mock does not call an external verifier)
95+
96+
## Idempotency model
97+
98+
Use and persist three IDs:
99+
100+
- `request_id`: semantic request identity.
101+
- `payment_id`: payment-settlement identity.
102+
- `receipt_id`: emitted CLAS receipt identity.
103+
104+
Dedupe key in this example is `request_id + payment_id`.
105+
106+
## Production-readiness path
107+
108+
To move this example to production:
109+
110+
1. Replace mock payment validation with real x402 provider verification.
111+
2. Persist idempotency state in durable storage (DB/cache), not in-memory maps.
112+
3. Implement canonical receipt signing using CommandLayer keys/HSM/KMS (replace placeholders).
113+
4. Add verifier invocation and signed verifier attestations for `verifier` role.
114+
5. Add schema validation for incoming request/event payloads.
115+
6. Add audit logging, retries, and alerting for `RECEIPT_SIGNING_FAILED` and verifier outages.
116+
7. Protect endpoints with authn/authz and rate limiting.
117+
118+
## Validation commands
119+
120+
```bash
121+
npm test
122+
node --test tests/x402-paid-action-receipt.test.js
123+
```
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
function summarizeText(input) {
2+
if (!input || typeof input.text !== 'string' || input.text.trim().length === 0) {
3+
throw new Error('ACTION_EXECUTION_FAILED: input.text is required for summarize.text.');
4+
}
5+
6+
const normalized = input.text.replace(/\s+/g, ' ').trim();
7+
const sentence = normalized.split(/(?<=[.!?])\s+/)[0] || normalized;
8+
return {
9+
summary: sentence.length > 200 ? `${sentence.slice(0, 197)}...` : sentence
10+
};
11+
}
12+
13+
function executeMockAgentAction(action, input) {
14+
if (action !== 'summarize.text') {
15+
throw new Error(`ACTION_EXECUTION_FAILED: unsupported action ${action}.`);
16+
}
17+
18+
return summarizeText(input);
19+
}
20+
21+
module.exports = {
22+
executeMockAgentAction
23+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
const crypto = require('node:crypto');
2+
3+
function assertPaymentAccepted(paymentEvent, paidActionRequest) {
4+
if (!paymentEvent) {
5+
throw new Error('MISSING_PAYMENT: payment accepted event is required before execution.');
6+
}
7+
8+
if (paymentEvent.event !== 'payment.accepted') {
9+
throw new Error('INVALID_PAYMENT: expected event to be payment.accepted.');
10+
}
11+
12+
if (paymentEvent.request_id !== paidActionRequest.request_id) {
13+
throw new Error('INVALID_PAYMENT: request_id mismatch between action request and payment event.');
14+
}
15+
16+
if (!paymentEvent.payment_id || !paymentEvent.provider) {
17+
throw new Error('INVALID_PAYMENT: payment_id and provider are required.');
18+
}
19+
20+
return {
21+
settlement_status: 'accepted',
22+
payment_id: paymentEvent.payment_id,
23+
payment_ref: paymentEvent.payment_id,
24+
provider: paymentEvent.provider,
25+
settled_amount: paymentEvent.settled_amount,
26+
currency: paymentEvent.currency,
27+
accepted_at: paymentEvent.accepted_at,
28+
verification_token: `x402v1:${crypto.createHash('sha256').update(JSON.stringify(paymentEvent)).digest('hex').slice(0, 24)}`
29+
};
30+
}
31+
32+
module.exports = {
33+
assertPaymentAccepted
34+
};
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
const crypto = require('node:crypto');
2+
3+
function makeId(prefix) {
4+
return `${prefix}_${crypto.randomUUID().replace(/-/g, '').slice(0, 20)}`;
5+
}
6+
7+
function buildClasReceipt({ paidActionRequest, paymentAcceptance, executionResult, signingKeyId, workflowId }) {
8+
if (!paidActionRequest?.request_id || !paymentAcceptance?.payment_id) {
9+
throw new Error('RECEIPT_SIGNING_FAILED: missing request_id or payment_id in receipt build input.');
10+
}
11+
12+
const requestedAt = new Date().toISOString();
13+
const executedAt = new Date().toISOString();
14+
const receiptId = makeId('rcpt_clas_act');
15+
16+
const receipt = {
17+
receipt_id: receiptId,
18+
request_id: paidActionRequest.request_id,
19+
payment_id: paymentAcceptance.payment_id,
20+
action: paidActionRequest.action,
21+
status: 'succeeded',
22+
requested_at: requestedAt,
23+
executed_at: executedAt,
24+
result: executionResult,
25+
metadata: {
26+
trace: {
27+
request_id: paidActionRequest.request_id,
28+
payment_id: paymentAcceptance.payment_id,
29+
receipt_id: receiptId,
30+
workflow_id: workflowId || makeId('wf'),
31+
provider: paymentAcceptance.provider
32+
},
33+
proof: {
34+
payment: {
35+
scheme: 'x402',
36+
settlement_status: paymentAcceptance.settlement_status,
37+
payment_ref: paymentAcceptance.payment_ref
38+
},
39+
execution: {
40+
runtime_id: 'runtime_example_local',
41+
agent_id: 'agent_mock_summarizer_v1',
42+
policy_hash: 'sha256:example-policy-hash'
43+
},
44+
commandlayer_signing_hook: 'Replace proof.signature placeholders with real CommandLayer signing in production.'
45+
}
46+
},
47+
proof: {
48+
signature: [
49+
{ role: 'payer', alg: 'Ed25519', key_id: 'payer_key_placeholder', sig: 'PLACEHOLDER_PAYER_SIG' },
50+
{ role: 'agent', alg: 'Ed25519', key_id: 'agent_key_placeholder', sig: 'PLACEHOLDER_AGENT_SIG' },
51+
{ role: 'runtime', alg: 'Ed25519', key_id: signingKeyId || 'runtime_key_placeholder', sig: 'PLACEHOLDER_RUNTIME_SIG' },
52+
{ role: 'verifier', alg: 'Ed25519', key_id: 'verifier_key_placeholder', sig: 'PLACEHOLDER_VERIFIER_SIG' }
53+
]
54+
}
55+
};
56+
57+
return receipt;
58+
}
59+
60+
module.exports = {
61+
buildClasReceipt
62+
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"request_id": "req_9f2f5f25",
3+
"action": "summarize.text",
4+
"input": {
5+
"text": "CommandLayer receipts provide portable attestations of what an agent action executed and returned."
6+
},
7+
"payment": {
8+
"required": true,
9+
"plan": "pro",
10+
"max_amount": "0.05",
11+
"currency": "USD"
12+
}
13+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"event": "payment.accepted",
3+
"request_id": "req_9f2f5f25",
4+
"payment_id": "pay_x402_7f31",
5+
"provider": "x402-compatible",
6+
"settled_amount": "0.05",
7+
"currency": "USD",
8+
"accepted_at": "2026-05-22T12:00:01Z"
9+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
const http = require('node:http');
2+
const { assertPaymentAccepted } = require('./mockX402Payment');
3+
const { executeMockAgentAction } = require('./mockAgentAction');
4+
const { buildClasReceipt } = require('./receiptBuilder');
5+
6+
const dedupe = new Map();
7+
8+
function dedupeKey(requestId, paymentId) {
9+
return `${requestId}:${paymentId}`;
10+
}
11+
12+
function sendJson(res, statusCode, body) {
13+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
14+
res.end(JSON.stringify(body, null, 2));
15+
}
16+
17+
function createServer() {
18+
return http.createServer((req, res) => {
19+
if (req.method === 'GET' && req.url === '/health') {
20+
return sendJson(res, 200, { ok: true });
21+
}
22+
23+
if (req.method === 'POST' && req.url === '/paid-action') {
24+
let raw = '';
25+
req.on('data', (chunk) => {
26+
raw += chunk;
27+
});
28+
29+
req.on('end', () => {
30+
let body;
31+
try {
32+
body = raw ? JSON.parse(raw) : {};
33+
} catch {
34+
return sendJson(res, 400, { error: 'INVALID_JSON', message: 'Request body must be valid JSON.' });
35+
}
36+
37+
const { paid_action_request: paidActionRequest, payment_accepted: paymentAccepted } = body || {};
38+
if (!paidActionRequest) {
39+
return sendJson(res, 400, { error: 'MISSING_REQUEST', message: 'paid_action_request is required.' });
40+
}
41+
42+
let paymentAcceptance;
43+
try {
44+
paymentAcceptance = assertPaymentAccepted(paymentAccepted, paidActionRequest);
45+
} catch (error) {
46+
return sendJson(res, 402, { error: 'PAYMENT_REQUIRED_OR_INVALID', message: error.message });
47+
}
48+
49+
const key = dedupeKey(paidActionRequest.request_id, paymentAcceptance.payment_id);
50+
if (dedupe.has(key)) {
51+
return sendJson(res, 200, { duplicate: true, receipt: dedupe.get(key) });
52+
}
53+
54+
let actionResult;
55+
try {
56+
actionResult = executeMockAgentAction(paidActionRequest.action, paidActionRequest.input);
57+
} catch (error) {
58+
return sendJson(res, 500, { error: 'ACTION_EXECUTION_FAILED', message: error.message });
59+
}
60+
61+
let receipt;
62+
try {
63+
receipt = buildClasReceipt({
64+
paidActionRequest,
65+
paymentAcceptance,
66+
executionResult: actionResult,
67+
signingKeyId: process.env.RUNTIME_SIGNING_KEY_ID,
68+
workflowId: process.env.WORKFLOW_ID
69+
});
70+
} catch (error) {
71+
return sendJson(res, 500, { error: 'RECEIPT_SIGNING_FAILED', message: error.message });
72+
}
73+
74+
dedupe.set(key, receipt);
75+
return sendJson(res, 200, { duplicate: false, receipt });
76+
});
77+
return;
78+
}
79+
80+
sendJson(res, 404, { error: 'NOT_FOUND' });
81+
});
82+
}
83+
84+
if (require.main === module) {
85+
const port = Number(process.env.PORT || 4000);
86+
createServer().listen(port, () => {
87+
// eslint-disable-next-line no-console
88+
console.log(`x402 paid action receipt example listening on http://localhost:${port}`);
89+
});
90+
}
91+
92+
module.exports = { createServer, dedupeKey };

0 commit comments

Comments
 (0)