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
103 changes: 103 additions & 0 deletions api/examples/coinbase-webhook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
'use strict';

const { verifyCoinbaseWebhook } = require('../../lib/coinbaseWebhook');
const { signReceipt } = require('../../lib/receiptSigning');

const seenReceipts = new Map();

function missingSigningConfig() {
return !process.env.CL_RECEIPT_SIGNER_ID || !process.env.CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM || !process.env.CL_RECEIPT_SIGNING_KID;
}

function normalizeReceipt(event) {
const eventId = event?.id || event?.event_id || 'unknown';
const eventType = event?.type || 'coinbase.unknown';
const txHash = event?.data?.transactionHash || event?.transactionHash || null;
return {
receipt_id: `rcpt:coinbase_cdp:${eventId}`,
signer: process.env.CL_RECEIPT_SIGNER_ID,
verb: 'observe',
source: 'coinbase.cdp.webhook',
subject: {
type: eventType,
id: txHash || eventId,
},
input: {
raw_event_summary: {
id: eventId,
type: eventType,
transactionHash: txHash,
},
},
output: {
observation: {
accepted: true,
provider: 'coinbase_cdp',
event_type: eventType,
},
},
execution: {
status: 'succeeded',
},
ts: new Date().toISOString(),
metadata: {
trace: {
trace_id: `coinbase:${eventId}`,
span_id: 'coinbase.webhook.verified',
timestamp: new Date().toISOString(),
tags: {
provider: 'coinbase_cdp',
event_type: eventType,
},
},
},
};
}

module.exports = async function handler(req, res) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');

if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).json({ ok: false, status: 'METHOD_NOT_ALLOWED' });
}

const secret = process.env.COINBASE_WEBHOOK_SECRET;
if (!secret) {
return res.status(503).json({ ok: false, status: 'configuration_unavailable' });
}

const verified = verifyCoinbaseWebhook(req, secret);
if (!verified.ok) {
return res.status(verified.httpStatus).json({ ok: false, status: verified.code });
}

if (missingSigningConfig()) {
return res.status(503).json({ ok: false, status: 'signing_unavailable' });
}

const eventId = verified.event?.id || verified.event?.event_id;
if (!eventId) {
return res.status(400).json({ ok: false, status: 'malformed_payload' });
}

if (seenReceipts.has(eventId)) {
return res.status(200).json({ ok: true, status: 'WEBHOOK_VERIFIED_AND_SIGNED', duplicate: true, receipt: seenReceipts.get(eventId) });
}

const unsignedReceipt = normalizeReceipt(verified.event);
const receipt = await signReceipt(unsignedReceipt, {
signerId: process.env.CL_RECEIPT_SIGNER_ID,
privateKeyPem: process.env.CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM,
kid: process.env.CL_RECEIPT_SIGNING_KID,
});

seenReceipts.set(eventId, receipt);
return res.status(200).json({ ok: true, status: 'WEBHOOK_VERIFIED_AND_SIGNED', duplicate: false, receipt });
};

module.exports._internal = {
clearSeen: () => seenReceipts.clear(),
seenReceipts,
};
20 changes: 20 additions & 0 deletions docs/integrations/coinbase-cdp-webhook-receipts.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,23 @@ Design intent: payment-trigger logic should depend on CommandLayer receipt seman
- Third parties cannot independently validate Coinbase authenticity without the shared secret.
- Public verifiability begins when CommandLayer signs the receipt (Ed25519) and distributes that signed artifact.


## Signed example endpoint

A server-side example endpoint is available at `api/examples/coinbase-webhook.js`.

Processing order is strict:
1. Verify Coinbase webhook authenticity first (`X-Hook0-Signature` HMAC over raw body + signed headers).
2. Normalize the accepted event into a CLAS-style `observe` receipt.
3. Sign the normalized receipt with CommandLayer runtime Ed25519 signing material.

This ordering matters: receipt signing happens **only** for accepted, HMAC-verified Coinbase events. Invalid or stale webhook signatures are rejected before parsing JSON and before any receipt signature work.

Required environment variables:
- `COINBASE_WEBHOOK_SECRET`
- `COINBASE_WEBHOOK_MAX_AGE_SECONDS` (optional, defaults to 300)
- `CL_RECEIPT_SIGNER_ID`
- `CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM`
- `CL_RECEIPT_SIGNING_KID`

Public portability begins after CommandLayer signs the normalized receipt artifact. Third-party verification depends on signer public key distribution (for example ENS text records expected by local verifier logic).
85 changes: 85 additions & 0 deletions lib/coinbaseWebhook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
'use strict';

const crypto = require('node:crypto');

const DEFAULT_MAX_AGE_SECONDS = 300;

function getHeader(req, name) {
if (!req || !req.headers) return undefined;
const direct = req.headers[name];
if (direct !== undefined) return direct;
return req.headers[name.toLowerCase()];
}

function parseHookSignature(headerValue) {
if (!headerValue || typeof headerValue !== 'string') return null;
const parts = headerValue.split(',').map((p) => p.trim()).filter(Boolean);
const map = new Map();
for (const part of parts) {
const idx = part.indexOf('=');
if (idx <= 0) continue;
map.set(part.slice(0, idx), part.slice(idx + 1));
}
const t = map.get('t');
const h = map.get('h');
const v1 = map.get('v1');
if (!t || !h || !v1) return null;
const ts = Number.parseInt(t, 10);
if (!Number.isFinite(ts)) return null;
const signedHeaderNames = h.split(' ').map((v) => v.trim()).filter(Boolean);
if (!signedHeaderNames.length || !/^[0-9a-fA-F]+$/.test(v1)) return null;
return { timestamp: ts, signedHeaderNames, signatureHex: v1.toLowerCase() };
}

function buildSignedPayload({ timestamp, signedHeaderNames, req, rawBody }) {
const signedHeaderNamesStr = signedHeaderNames.join(' ');
const signedHeaderValues = signedHeaderNames
.map((headerName) => String(getHeader(req, headerName) ?? ''))
.join('.');
return `${timestamp}.${signedHeaderNamesStr}.${signedHeaderValues}.${rawBody}`;
}

function getRawBodyString(req) {
if (typeof req.rawBody === 'string') return req.rawBody;
if (Buffer.isBuffer(req.rawBody)) return req.rawBody.toString('utf8');
if (typeof req.body === 'string') return req.body;
if (Buffer.isBuffer(req.body)) return req.body.toString('utf8');
if (req.body && typeof req.body === 'object') return JSON.stringify(req.body);
return '';
}

function verifyCoinbaseWebhook(req, secret) {
const signatureHeader = getHeader(req, 'x-hook0-signature');
if (!signatureHeader) return { ok: false, code: 'missing_signature', httpStatus: 400 };

const parsed = parseHookSignature(signatureHeader);
if (!parsed) return { ok: false, code: 'malformed_signature', httpStatus: 400 };

const maxAge = Number.parseInt(process.env.COINBASE_WEBHOOK_MAX_AGE_SECONDS || '', 10);
const maxAgeSeconds = Number.isFinite(maxAge) && maxAge >= 0 ? maxAge : DEFAULT_MAX_AGE_SECONDS;
const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - parsed.timestamp) > maxAgeSeconds) {
return { ok: false, code: 'stale_signature', httpStatus: 400 };
}

const rawBody = getRawBodyString(req);
const signedPayload = buildSignedPayload({ ...parsed, req, rawBody });
const expectedHex = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');

const expectedBuf = Buffer.from(expectedHex, 'hex');
const actualBuf = Buffer.from(parsed.signatureHex, 'hex');
if (expectedBuf.length !== actualBuf.length || !crypto.timingSafeEqual(expectedBuf, actualBuf)) {
return { ok: false, code: 'invalid_signature', httpStatus: 400 };
}

let event;
try {
event = JSON.parse(rawBody);
} catch {
return { ok: false, code: 'malformed_payload', httpStatus: 400 };
}

return { ok: true, event, rawBody };
}

module.exports = { verifyCoinbaseWebhook, parseHookSignature, buildSignedPayload, getRawBodyString };
51 changes: 51 additions & 0 deletions lib/receiptSigning.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use strict';

const crypto = require('node:crypto');
const { webcrypto } = crypto;

function canonicalize(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(value[k])}`).join(',')}}`;
}

async function sha256Hex(text) {
const digest = await webcrypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return Buffer.from(digest).toString('hex');
}

async function signReceipt(receipt, cfg) {
const canonicalPayload = {
signer: receipt?.signer,
verb: receipt?.verb,
input: receipt?.input,
output: receipt?.output,
execution: receipt?.execution,
ts: receipt?.ts,
};
const canonicalStr = canonicalize(canonicalPayload);
const hashHex = await sha256Hex(canonicalStr);

const privateKey = crypto.createPrivateKey(cfg.privateKeyPem);
const signature = crypto.sign(null, Buffer.from(hashHex, 'utf8'), privateKey).toString('base64');

return {
...receipt,
metadata: {
...(receipt.metadata || {}),
proof: {
canonicalization: 'json.sorted_keys.v1',
hash: { alg: 'SHA-256', value: hashHex },
signature: {
alg: 'Ed25519',
kid: cfg.kid,
value: signature,
role: 'runtime',
},
signer_id: cfg.signerId,
},
},
};
}

module.exports = { signReceipt, canonicalize, sha256Hex };
Loading
Loading