Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions api/examples/x402-paid-action.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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',
Expand All @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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) });
Expand All @@ -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 });
Expand Down
21 changes: 19 additions & 2 deletions docs/integrations/x402-commandlayer-receipts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>` 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:
Expand All @@ -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.
99 changes: 99 additions & 0 deletions lib/x402ProviderVerification.js
Original file line number Diff line number Diff line change
@@ -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,
};
102 changes: 92 additions & 10 deletions tests/api-x402-paid-action.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function makeRes() {
}

const originalEnv = { ...process.env };
const originalFetch = global.fetch;

function validPayload(overrides = {}) {
return {
Expand All @@ -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;
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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: {
Expand All @@ -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');
});
Loading