// This app's own records: purchased credits, the receipts already credited, allowance used per cycle, and // actions in progress. sub3 keeps receipts on chain; balances are the merchant's to keep. // Amounts are decimal strings and BigInt, never Number. // // The ledger needs only synchronous SQL with transactions: get (first row), run (rows changed), exec and // transaction. sqlite.mjs gives it a SQLite file (node:sqlite, openLedger); ledgerOn a Cloudflare Durable // Object's SQLite. import { createHash } from 'node:crypto'; /** An error carrying the HTTP status to answer with. */ export const problem = (status, message) => Object.assign(new Error(message), { status }); const SCHEMA = [ 'CREATE TABLE IF NOT EXISTS scope (id INTEGER PRIMARY KEY CHECK (id = 1), value TEXT NOT NULL)', 'CREATE TABLE IF NOT EXISTS credits (user TEXT PRIMARY KEY, balance TEXT NOT NULL)', `CREATE TABLE IF NOT EXISTS receipts (chain_id INTEGER, contract TEXT, payer TEXT, order_id TEXT, user TEXT NOT NULL, credits TEXT NOT NULL, PRIMARY KEY (chain_id, contract, payer, order_id))`, 'CREATE TABLE IF NOT EXISTS cursors (user TEXT PRIMARY KEY, next INTEGER NOT NULL)', 'CREATE TABLE IF NOT EXISTS usage (user TEXT, cycle TEXT, used TEXT NOT NULL, PRIMARY KEY (user, cycle))', `CREATE TABLE IF NOT EXISTS operations (user TEXT, id TEXT, request TEXT NOT NULL, cycle TEXT, from_allowance TEXT NOT NULL, from_credits TEXT NOT NULL, state TEXT NOT NULL, result TEXT, PRIMARY KEY (user, id))` ]; /** A Durable Object's SQLite storage (`ctx.storage`), for Cloudflare Workers. */ export function ledgerOn(storage, scope) { const run = (sql, ...args) => { const cursor = storage.sql.exec(sql, ...args); cursor.toArray(); // run it to the end before reading rowsWritten return cursor.rowsWritten; }; return setUp( { get: (sql, ...args) => storage.sql.exec(sql, ...args).toArray()[0], run, exec: (sql) => run(sql), transaction: (fn) => storage.transactionSync(fn), close() {} }, scope, 'This Durable Object' ); } /** Creates the tables and checks the scope, on any store with get/run/exec/transaction/close. */ export function setUp(db, scope, where) { for (const statement of SCHEMA) db.run(statement); // One database per environment and product: sandbox payments must never become production credits. db.run('INSERT OR IGNORE INTO scope (id, value) VALUES (1, ?)', scope); if (db.get('SELECT value FROM scope').value !== scope) { db.close(); throw new Error(`${where} belongs to another environment or product`); } return db; } const credits = (db, user) => BigInt(db.get('SELECT balance FROM credits WHERE user = ?', user)?.balance ?? 0); const setCredits = (db, user, balance) => db.run( 'INSERT INTO credits (user, balance) VALUES (?, ?) ON CONFLICT (user) DO UPDATE SET balance = ?', user, balance.toString(), balance.toString() ); const used = (db, user, cycle) => BigInt(db.get('SELECT used FROM usage WHERE user = ? AND cycle = ?', user, cycle)?.used ?? 0); const setUsed = (db, user, cycle, value) => db.run( 'INSERT INTO usage (user, cycle, used) VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET used = ?', user, cycle, value.toString(), value.toString() ); // Adds a confirmed pack receipt's credits exactly once, keyed by (chainId, contract, payer, orderId). // The receipt and the balance commit together; a second call for the same receipt changes nothing. export function creditOrder(db, order) { if (!/^[1-9]\d*$/.test(order.credits)) throw problem(503, 'Unexpected receipt from sub3'); const user = order.user.toLowerCase(); return db.transaction(() => { const changes = db.run( `INSERT INTO receipts (chain_id, contract, payer, order_id, user, credits) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`, order.chainId, order.contract.toLowerCase(), order.payer.toLowerCase(), order.orderId.toLowerCase(), user, order.credits ); if (changes === 0) return false; // already credited setCredits(db, user, credits(db, user) + BigInt(order.credits)); return true; }); } /** What the user has left: this cycle's allowance (granted by the tier) and purchased credits. */ export function balances(db, user, cycle, allowance) { const left = cycle ? allowance - used(db, user, cycle) : 0n; return { allowance: (left > 0n ? left : 0n).toString(), credits: credits(db, user).toString() }; } /** How many of the user's orders (oldest first) are settled, so recovery can resume there. */ export const ordersSeen = (db, user) => db.get('SELECT next FROM cursors WHERE user = ?', user)?.next ?? 0; export const setOrdersSeen = (db, user, next) => db.run( 'INSERT INTO cursors (user, next) VALUES (?, ?) ON CONFLICT DO UPDATE SET next = ?', user, next, next ); // Takes `cost` for action `id`: this cycle's allowance first, then purchased credits, in one transaction. // Repeating an id returns the first outcome instead of charging again. export function reserve(db, user, id, request, cost, cycle, allowance) { const fingerprint = createHash('sha256').update(JSON.stringify(request)).digest('hex'); return db.transaction(() => { const old = db.get('SELECT * FROM operations WHERE user = ? AND id = ?', user, id); if (old) { if (old.request !== fingerprint) throw problem(409, 'This Idempotency-Key was used for another request'); return { fresh: false, state: old.state, result: old.result && JSON.parse(old.result) }; } const have = balances(db, user, cycle, allowance); const fromAllowance = cost < BigInt(have.allowance) ? cost : BigInt(have.allowance); const fromCredits = cost - fromAllowance; if (fromCredits > BigInt(have.credits)) throw problem(402, 'Not enough credits: buy a pack to continue'); if (fromAllowance) setUsed(db, user, cycle, used(db, user, cycle) + fromAllowance); if (fromCredits) setCredits(db, user, credits(db, user) - fromCredits); db.run( `INSERT INTO operations (user, id, request, cycle, from_allowance, from_credits, state) VALUES (?, ?, ?, ?, ?, ?, 'reserved')`, user, id, fingerprint, cycle, fromAllowance.toString(), fromCredits.toString() ); return { fresh: true, charged: { allowance: fromAllowance.toString(), credits: fromCredits.toString() } }; }); } /** Records the outcome. A known failure gives back what `reserve` took (to the cycle it came from). */ export function finish(db, user, id, result, { failed = false } = {}) { return db.transaction(() => { const op = db.get('SELECT * FROM operations WHERE user = ? AND id = ?', user, id); if (op?.state !== 'reserved') return false; if (failed) { if (op.cycle) setUsed(db, user, op.cycle, used(db, user, op.cycle) - BigInt(op.from_allowance)); setCredits(db, user, credits(db, user) + BigInt(op.from_credits)); } db.run( 'UPDATE operations SET state = ?, result = ? WHERE user = ? AND id = ?', failed ? 'refunded' : 'done', JSON.stringify(result), user, id ); return true; }); }