diff --git a/ads.js b/ads.js
new file mode 100644
index 0000000..9b7e950
--- /dev/null
+++ b/ads.js
@@ -0,0 +1,185 @@
+// 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 };
diff --git a/public/assets/common.js b/public/assets/common.js
index 8697dbd..3b0b4c9 100644
--- a/public/assets/common.js
+++ b/public/assets/common.js
@@ -84,5 +84,22 @@ window.IAP = (function () {
+ 'verify ↗';
return div;
}
- return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, $ };
+ // Render one served ad into # No campaigns yet. Launch your first below. connecting…
'
+ + '
';
+ el.appendChild(tbl);
+ el.querySelectorAll('button[data-camp]').forEach(b => b.addEventListener('click', async () => {
+ try { await api('/api/my/campaigns/' + b.dataset.camp + '/' + b.dataset.act); await loadCampaigns(); }
+ catch (e) { IAP.status(e.message, 'bad'); }
+ }));
+ } catch (e) {}
+ }
+ $('cType').addEventListener('change', () => {
+ const t = $('cType').value;
+ $('cImageRow').hidden = t === 'text';
+ $('cTitleRow').hidden = t !== 'text';
+ $('cBodyRow').hidden = t !== 'text';
+ });
+ $('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => {
+ await api('/api/my/campaigns', { type: $('cType').value, name: $('cName').value,
+ targetUrl: $('cTarget').value, imageUrl: $('cImage').value,
+ title: $('cTitle').value, body: $('cBody').value, budget: Number($('cBudget').value) });
+ IAP.status('Campaign is live. It starts serving right away.', 'ok');
+ $('cName').value = ''; $('cBudget').value = '';
+ await loadCampaigns();
+ }));
+ // defers the busy() lookup to click time (busy is declared below)
+ function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
+
const busy = (btn, fn) => async () => {
try { btn.disabled = true; await fn(); }
catch (e) { IAP.status((e && e.message) || String(e), 'bad'); }
diff --git a/public/ledger.html b/public/ledger.html
index da5beca..55e1040 100644
--- a/public/ledger.html
+++ b/public/ledger.html
@@ -15,9 +15,11 @@
'
+ + r.campaigns.map(c => 'Name Type Views Clicks '
+ + 'Spent Budget Status ').join('') + '' + c.name + ' ' + c.type + ' '
+ + '' + c.imps.toLocaleString() + ' ' + c.clicks + ' '
+ + '' + c.spent + ' ' + c.budget + ' '
+ + '' + (c.status === 'out' ? 'budget spent' : c.status) + ' '
+ + '' + (c.status === 'active' ? ''
+ : c.status === 'paused' ? '' : '')
+ + '
…
+ +