// Ad engine v1 (spec §8b): banners, text ads, login ads served against // PURCHASED on-chain credits. The chain is the money truth: spend accrues // here, and burns are queued for the engine signer to consume() on-chain // (admin runs the burner; see /api/admin/burns). Earned-credit pool and the // richer §8b types (inbox, surf, rotation, directory) come later. const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); let DATA_DIR = null; let chain = null; const FILE = () => path.join(DATA_DIR, 'campaigns.json'); const RATES_FILE = () => path.join(DATA_DIR, 'adrates.json'); let db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] }; // REHEARSAL PLACEHOLDER RATES — admin-adjustable via /api/admin/rates. // Integer math: impressions accrue per campaign; credits deduct per BATCH. function rates() { let saved = {}; try { saved = JSON.parse(fs.readFileSync(RATES_FILE(), 'utf8')); } catch (e) {} return Object.assign({ bannerBatch: 10, bannerCreditsPerBatch: 2, // $2.00 CPM equivalent textBatch: 10, textCreditsPerBatch: 1, // $1.00 CPM equivalent loginCreditsPerDay: 100, // $1.00/day burnBatchMin: 50 // queue a burn every 50cr of spend }, saved); } function load() { try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) {} if (!db || db.v !== 1) db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] }; } function save() { try { const tmp = FILE() + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(db)); fs.renameSync(tmp, FILE()); } catch (e) { console.error('ads save failed', e.message); } } function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; load(); } const TYPES = ['banner', 'text', 'login']; const URL_RE = /^https?:\/\/[^\s]+$/i; // unburned spend per member = credits owed to the burn queue + campaign accruals function unburnedSpend(memberId) { let s = 0; for (const b of db.burnsPending) if (b.memberId === memberId && !b.burnedTx) s += b.amount; for (const c of db.campaigns) if (c.memberId === memberId) s += c.accrued || 0; return s; } async function availableCredits(memberId) { const onchain = await chain.creditBalance(memberId, 0); return Math.max(0, onchain - unburnedSpend(memberId)); } async function createCampaign(owner, memberId, input) { const type = String(input.type || ''); if (!TYPES.includes(type)) return { error: 'Unknown ad type.' }; const name = String(input.name || '').trim().slice(0, 60); if (!name) return { error: 'Give the campaign a name.' }; const targetUrl = String(input.targetUrl || '').trim(); if (!URL_RE.test(targetUrl)) return { error: 'Target URL must start with http(s)://' }; const budget = Math.floor(Number(input.budget) || 0); if (budget < 10) return { error: 'Minimum budget is 10 credits.' }; const avail = await availableCredits(memberId); if (budget > avail) return { error: 'Budget exceeds your available credits (' + avail + ').' }; const c = { id: db.nextId++, owner, memberId, type, name, targetUrl, budget, spent: 0, accrued: 0, imps: 0, clicks: 0, batchImps: 0, status: 'active', created: Date.now() }; if (type === 'banner') { const imageUrl = String(input.imageUrl || '').trim(); if (!URL_RE.test(imageUrl)) return { error: 'Banner image URL must start with http(s)://' }; c.imageUrl = imageUrl; } if (type === 'text') { c.title = String(input.title || '').trim().slice(0, 60); c.body = String(input.body || '').trim().slice(0, 140); if (!c.title) return { error: 'Text ads need a headline.' }; } if (type === 'login') { c.imageUrl = String(input.imageUrl || '').trim(); if (!URL_RE.test(c.imageUrl)) return { error: 'Login ads need an image URL.' }; } db.campaigns.push(c); save(); return { ok: true, campaign: pub(c) }; } function listCampaigns(owner) { return db.campaigns.filter(c => c.owner === owner).map(pub); } function setStatus(owner, id, status) { const c = db.campaigns.find(x => x.id === Number(id) && x.owner === owner); if (!c) return { error: 'No such campaign.' }; if (!['active', 'paused'].includes(status)) return { error: 'Bad status.' }; if (c.status === 'out' && status === 'active' && c.spent + c.accrued >= c.budget) return { error: 'Budget exhausted. Raise it first.' }; c.status = status; save(); return { ok: true, campaign: pub(c) }; } function pub(c) { return { id: c.id, type: c.type, name: c.name, targetUrl: c.targetUrl, imageUrl: c.imageUrl || null, title: c.title || null, body: c.body || null, budget: c.budget, spent: c.spent + (c.accrued || 0), imps: c.imps, clicks: c.clicks, status: c.status, created: c.created }; } // ---- serving ---- function chargeBatch(c, batch, credits) { c.batchImps += 1; if (c.batchImps >= batch) { c.batchImps = 0; c.accrued = (c.accrued || 0) + credits; // roll accruals into the burn queue in chunks const r = rates(); if (c.accrued >= r.burnBatchMin) { db.burnsPending.push({ id: crypto.randomBytes(8).toString('hex'), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() }); c.spent += c.accrued; c.accrued = 0; } if (c.spent + c.accrued >= c.budget) c.status = 'out'; } } function serve(type) { const r = rates(); const pool = db.campaigns.filter(c => c.type === type && c.status === 'active'); if (!pool.length) return null; const c = pool[Math.floor(Math.random() * pool.length)]; c.imps += 1; if (type === 'banner') chargeBatch(c, r.bannerBatch, r.bannerCreditsPerBatch); if (type === 'text') chargeBatch(c, r.textBatch, r.textCreditsPerBatch); // login ads are per-day; impressions tracked, charged by the daily sweep save(); return { id: c.id, type: c.type, targetUrl: '/api/ads/click/' + c.id, imageUrl: c.imageUrl || null, title: c.title || null, body: c.body || null }; } function click(id) { const c = db.campaigns.find(x => x.id === Number(id)); if (!c) return null; c.clicks += 1; save(); return c.targetUrl; } // daily charge for login ads (called by a boot + interval sweep) function dailySweep() { const r = rates(); const today = new Date().toISOString().slice(0, 10); let charged = 0; for (const c of db.campaigns) { if (c.type !== 'login' || c.status !== 'active' || c.lastDayCharged === today) continue; c.lastDayCharged = today; c.accrued = (c.accrued || 0) + r.loginCreditsPerDay; if (c.accrued >= r.burnBatchMin) { db.burnsPending.push({ id: crypto.randomBytes(8).toString('hex'), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() }); c.spent += c.accrued; c.accrued = 0; } if (c.spent + c.accrued >= c.budget) c.status = 'out'; charged += 1; } if (charged) save(); return charged; } // ---- burn queue (admin/engine) ---- function pendingBurns() { return db.burnsPending.filter(b => !b.burnedTx); } function markBurned(id, tx) { const b = db.burnsPending.find(x => x.id === id); if (!b) return { error: 'No such burn.' }; b.burnedTx = tx; b.burnedAt = Date.now(); save(); return { ok: true }; } function setRates(patch) { const cur = rates(); fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(cur, patch), null, 2)); return rates(); } module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus, serve, click, dailySweep, availableCredits, pendingBurns, markBurned };