OnChain Interface - x402

Version

Methods

1) Flow overview 1. Client/agent hits a paywalled route → your Gateway replies: • HTTP 402 with x402-* headers: price, asset, destination address, memo (includes rid, scope, nonce), and a x402-callback URL. 2. Payer sends USDM to your destination address with tx metadata embedding the memo (use your own numeric metadata label, e.g. 903402). 3. Your Facilitator (below) verifies: • Exact asset (USDM policyId + assetName) • Exact amount • recent tx to your address • metadata contains {rid, scope, nonce} (matches the offer) • nonce unused (replay protection) 4. Facilitator marks rid as covered → Gateway replays the original request upstream.

2) Headers (suggested)

These match what you already mocked earlier; keep them stable per route:

x402-price: 0.50 // USD x402-asset: cardano:{USDM_POLICY_ID}.USDM x402-chain: cardano-mainnet x402-destination: addr1…selfdriven x402-memo: <base64url(JSON({rid, scope, nonce}))> x402-callback: https://pay.selfdriven.network/_x402/settlement

3) Minimal data model (SQL-ish)

CREATE TABLE x402_offer ( rid VARCHAR(64) PRIMARY KEY, – request id scope VARCHAR(64) NOT NULL, – e.g., “ai.run” nonce VARCHAR(64) NOT NULL UNIQUE, asset_policy VARCHAR(64) NOT NULL, – USDM policy id asset_name VARCHAR(64) NOT NULL, – “USDM” amount_lovelace BIGINT NOT NULL, – price in “USDM decimals” scaled to policy (use raw integer amount) dest_addr TEXT NOT NULL, – bech32 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );

CREATE TABLE x402_receipt ( tx_hash VARCHAR(64) PRIMARY KEY, rid VARCHAR(64) NOT NULL, payer_addr TEXT, slot BIGINT, amount BIGINT, verified BOOLEAN NOT NULL, verified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );

4) Node facilitator (Express, Blockfrost, .then() style)

Replace YOUR_BLOCKFROST_KEY, USDM_POLICY_ID, MERCHANT_ADDR.

// package.json deps (example): // “express”, “axios”, “base64url”, “uuid”, “mysql2” (or pg), “dotenv”

const express = require(‘express’); const axios = require(‘axios’); const base64url = require(‘base64url’); const { v4: uuidv4 } = require(‘uuid’); require(‘dotenv’).config();

const app = express(); app.use(express.json({ limit: ‘256kb’ }));

// === CONFIG === const BLOCKFROST_API = ‘https://cardano-mainnet.blockfrost.io/api/v0’; const BF_HEADERS = { project_id: process.env.BLOCKFROST_KEY || ‘YOUR_BLOCKFROST_KEY’ };

const CHAIN = ‘cardano-mainnet’; const USDM_POLICY_ID = process.env.USDM_POLICY_ID || ‘USDM_POLICY_ID’; const USDM_ASSET_NAME = ‘USDM’; // raw on-chain name may be hex; see NOTE below const MEMO_LABEL = 903402; // your application-specific metadata label const MERCHANT_ADDR = process.env.MERCHANT_ADDR || ‘addr1…selfdriven’;

// === UTIL: DB STUBS (replace with real DB) === const offers = new Map(); // rid -> offer const receipts = new Map(); // tx_hash -> receipt

function saveOffer(o) { offers.set(o.rid, o); return Promise.resolve(o); } function getOffer(rid) { return Promise.resolve(offers.get(rid)); } function nonceUsed(nonce) { let used = false; offers.forEach(o => { if (o.nonce === nonce) used = true; }); return Promise.resolve(used); } function saveReceipt(r) { receipts.set(r.tx_hash, r); return Promise.resolve(r); }

// === 1) Issue an x402 offer (you call this from your gateway before 402) === app.post(‘/_x402/offer’, (req, res) => { const scope = (req.body && req.body.scope) || ‘ai.run’; const priceUsd = (req.body && req.body.priceUsd) || 0.5; const rid = uuidv4().replace(/-/g, ‘’).slice(0, 24); const nonce = uuidv4().replace(/-/g, ‘’).slice(0, 16);

// Convert price USD -> USDM raw amount. // If USDM is 6 decimals (check your asset), multiply by 1e6. const amountRaw = Math.round(priceUsd * 1e6);

const offer = { rid, scope, nonce, asset_policy: USDM_POLICY_ID, asset_name: USDM_ASSET_NAME, amount: amountRaw, dest_addr: MERCHANT_ADDR };

saveOffer(offer).then(() => { const memo = base64url.encode(JSON.stringify({ rid, scope, nonce })); res.json({ headers: { ‘x402-price’: String(priceUsd), ‘x402-asset’: cardano:${USDM_POLICY_ID}.${USDM_ASSET_NAME}, ‘x402-chain’: CHAIN, ‘x402-destination’: MERCHANT_ADDR, ‘x402-memo’: memo, ‘x402-callback’: ‘https://pay.selfdriven.network/_x402/settlement’ }, rid }); }); });

// === 2) Settlement callback (client/agent can POST txHash, or omit and we will poll) === app.post(‘/_x402/settlement’, (req, res) => { const memoB64 = req.body && req.body.memo; const txHash = req.body && req.body.txHash; // optional but preferred if (!memoB64) return res.status(400).json({ error: ‘missing memo’ });

let memo; try { memo = JSON.parse(base64url.decode(memoB64)); } catch (e) { return res.status(400).json({ error: ‘bad memo’ }); }

const { rid, scope, nonce } = memo; let offerRef;

getOffer(rid).then(o => { if (!o) return Promise.reject(new Error(‘unknown rid’)); offerRef = o; // replay protection (nonce must match this rid’s stored nonce) if (o.nonce !== nonce) return Promise.reject(new Error(‘nonce mismatch’)); return Promise.resolve(); }).then(() => { if (txHash) { // Verify a specific tx return verifyTxAgainstOffer(txHash, offerRef, memo); } else { // Poll recent txs to destination and try to match one return findMatchingTxForOffer(offerRef, memo); } }).then(result => { if (!result.verified) return res.status(402).json({ verified: false, reason: result.reason }); // Record receipt return saveReceipt({ tx_hash: result.txHash, rid, payer_addr: result.payer || null, slot: result.slot || null, amount: offerRef.amount, verified: true }).then(() => { // You’d also mark the RID “covered” in your gateway store here res.json({ verified: true, txHash: result.txHash }); }); }).catch(err => { res.status(400).json({ verified: false, error: err.message }); }); });

// === Blockfrost helpers ===

// Find a matching tx to MERCHANT_ADDR with USDM and memo = {rid,scope,nonce} function findMatchingTxForOffer(offer, memo) { // 1) List latest txs to address (narrow window — you can widen if needed) return axios.get(${BLOCKFROST_API}/addresses/${MERCHANT_ADDR}/transactions?count=20&order=desc, { headers: BF_HEADERS }) .then(r => r.data || []) .then(txs => { // Try each tx for a match const chain = Promise.resolve(); let found = null;

  return txs.reduce((p, t) => p.then(() => {
    if (found) return Promise.resolve();
    return verifyTxAgainstOffer(t.tx_hash, offer, memo).then(v => {
      if (v.verified) found = v;
    });
  }), chain).then(() => {
    return found || { verified: false, reason: 'no matching tx found' };
  });
}); }

// Verify single tx: asset, amount, dest output, and metadata memo function verifyTxAgainstOffer(txHash, offer, memo) { let tx, meta, utxos; return axios.get(${BLOCKFROST_API}/txs/${txHash}, { headers: BF_HEADERS }) .then(r => { tx = r.data; return axios.get(${BLOCKFROST_API}/txs/${txHash}/metadata, { headers: BF_HEADERS }); }) .then(r => { meta = r.data; return axios.get(${BLOCKFROST_API}/txs/${txHash}/utxos, { headers: BF_HEADERS }); }) .then(r => { utxos = r.data;

  // A) Metadata check
  const memoOk = metadataHasMemo(meta, memo);
  if (!memoOk) return { verified: false, reason: 'memo not found' };

  // B) Output check: ensure one output to MERCHANT_ADDR with the USDM amount >= offer.amount
  const match = outputMatches(utxos.outputs, MERCHANT_ADDR, offer.asset_policy, offer.asset_name, offer.amount);
  if (!match) return { verified: false, reason: 'output/amount/asset mismatch' };

  return { verified: true, txHash, slot: tx.slot, payer: (utxos.inputs && utxos.inputs[0] && utxos.inputs[0].address) || null };
})
.catch(e => ({ verified: false, reason: `lookup failed: ${e.message}` })); }

function metadataHasMemo(metaArr, memo) { if (!Array.isArray(metaArr)) return false; // Blockfrost returns [{label:”903402”, json_metadata:{…}}, …] const entry = metaArr.find(m => String(m.label) === String(MEMO_LABEL)); if (!entry || !entry.json_metadata) return false;

// Be tolerant to either an object or nested structure const j = entry.json_metadata; return j.rid === memo.rid && j.scope === memo.scope && j.nonce === memo.nonce; }

function outputMatches(outputs, destAddr, policyId, assetName, minAmountRaw) { if (!Array.isArray(outputs)) return false;

// NOTE: Blockfrost asset name is hex. If your “USDM” is hex-encoded on-chain, // set USDM_ASSET_NAME_HEX in env and compare against that instead. const assetNameHex = process.env.USDM_ASSET_NAME_HEX || Buffer.from(assetName, ‘utf8’).toString(‘hex’);

return outputs.some(o => { if (o.address !== destAddr) return false; if (!Array.isArray(o.amount)) return false; // amount is an array of { unit, quantity } where unit = policyId + assetNameHex (or “lovelace”) const unitId = policyId + assetNameHex; const asset = o.amount.find(a => a.unit === unitId); if (!asset) return false; const qty = BigInt(asset.quantity); return qty >= BigInt(minAmountRaw); }); }

// === 3) Example “paywalled” route wrapper (gateway side) ===

// This is what your gateway would do BEFORE returning 402. // In practice you embed this into your existing proxy/middleware. app.get(‘/api/ai/run’, (req, res) => { // (1) Check SSI VC entitlements & quota here… const covered = false; // say not covered → we generate an offer

if (!covered) { axios.post(‘http://localhost:3000/_x402/offer’, { scope: ‘ai.run’, priceUsd: 0.5 }) .then(r => { const h = r.data.headers; res.status(402) .set({ ‘x402-price’: h[‘x402-price’], ‘x402-asset’: h[‘x402-asset’], ‘x402-chain’: h[‘x402-chain’], ‘x402-destination’: h[‘x402-destination’], ‘x402-memo’: h[‘x402-memo’], ‘x402-callback’: h[‘x402-callback’] }) .send(‘Payment required’); }); } else { // proxy to origin… res.json({ ok: true }); } });

app.listen(3000, () => console.log(‘x402-cardano facilitator on :3000’));


Notes & switches • USDM asset name: On-chain asset names are hex. If USDM’s name is hex (likely), set USDM_ASSET_NAME_HEX and compare using policyId + assetNameHex. • Indexers: • Blockfrost: easiest; rate-limited; SLA via paid plans. • Ogmios + Kupo: self-hosted, low latency. Replace the 3 Blockfrost calls with: • Kupo: query UTXOs at MERCHANT_ADDR filtered by policyId.assetNameHex • Ogmios: fetch tx metadata by tx hash (or Kupo annotation if you store). • Latency: For better UX, allow optimistic unlock on mempool detection from your own node, and revoke on chain failure for high-value endpoints. • Security: • Exact-match policyId & asset name. • Exact amount (or ≥ if you allow tips). • Nonce single-use (store with rid). • Short expiry on offers (e.g., 15 min). • Same-destination check (must be your MERCHANT_ADDR). • Accounting: export x402_receipt to your books; if you want on-chain receipts, mint a tiny CIP-68 v2 “receipt NFT” referencing the tx_hash and rid.

Curl sanity checks

1) Create an offer

curl -sX POST http://localhost:3000/_x402/offer
-H ‘content-type: application/json’
-d ‘{“scope”:”ai.run”,”priceUsd”:0.5}’ | jq

2) After paying, call settlement (with tx hash if you have it)

curl -sX POST http://localhost:3000/_x402/settlement
-H ‘content-type: application/json’
-d ‘{“memo”:”","txHash":""}' | jq

Resources