From bee13ba4b8ba7f00659668f3854f1889c0ea66b9 Mon Sep 17 00:00:00 2001 From: martbost Date: Fri, 4 Sep 2026 13:04:47 -0500 Subject: [PATCH] Ad engine v1: banner/text/login campaigns against on-chain credits Spec 8b types 1-3. Spend accrues per campaign in batches; burns queue for the engine signer (admin runs consume() on-chain, /api/admin/burns). Rates are volume config (adrates.json), rehearsal placeholders until Marty sets the real card. Public slots serve on the ledger page; campaign manager in the members area. Co-Authored-By: Claude Fable 5 --- ads.js | 185 ++++++++++++++++++++++++++++++++++++++++ public/assets/common.js | 19 ++++- public/assets/ledger.js | 3 + public/assets/my.js | 48 +++++++++++ public/ledger.html | 2 + public/my.html | 21 +++++ server.js | 57 +++++++++++++ 7 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 ads.js 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 #. Silent if no inventory. + async function adSlot(type, elId) { + try { + const { ad } = await (await fetch('/api/ads/slot?type=' + type)).json(); + const el = $(elId); + if (!ad || !el) return; + if (ad.imageUrl) { + el.innerHTML = '' + + 'advertisement' + + '
member ad
'; + } else { + el.innerHTML = '' + ad.title + '' + + (ad.body ? ' · ' + ad.body : '') + ' member ad'; + } + el.hidden = false; + } catch (e) {} + } + return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, adSlot, $ }; })(); diff --git a/public/assets/ledger.js b/public/assets/ledger.js index d187171..aa44c0e 100644 --- a/public/assets/ledger.js +++ b/public/assets/ledger.js @@ -15,6 +15,9 @@ IAP.$('statLine').textContent = stats.onchainMembers + ' on-chain member(s)'; } catch (e) {} + IAP.adSlot('banner', 'adSlotBanner'); + IAP.adSlot('text', 'adSlotText'); + const es = new EventSource('/api/feed/live'); es.onopen = () => { const b = IAP.$('liveBadge'); b.textContent = '● live'; }; es.onerror = () => { const b = IAP.$('liveBadge'); b.textContent = 'reconnecting…'; }; diff --git a/public/assets/my.js b/public/assets/my.js index 9d77d5e..785bc47 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -31,6 +31,8 @@ $('linkCard').hidden = !!me.address; $('activateCard').hidden = !(me.address && !me.memberId); $('activityArea').hidden = !me.memberId; + $('campaignCard').hidden = !me.memberId; + if (me.memberId) loadCampaigns(); if (me.memberId) { const bc = me.buyerCount || 0; @@ -66,6 +68,52 @@ } catch (e) {} } + async function loadCampaigns() { + try { + const r = await (await fetch('/api/my/campaigns')).json(); + if (r.error) return; + $('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString() + + ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch + + ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch + + ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day'; + const el = $('campList'); + el.innerHTML = ''; + if (!r.campaigns.length) { el.innerHTML = '

No campaigns yet. Launch your first below.

'; return; } + const tbl = document.createElement('div'); + tbl.className = 'tablewrap'; + tbl.innerHTML = '' + + '' + + r.campaigns.map(c => '' + + '' + + '' + + '' + + '').join('') + '
NameTypeViewsClicksSpentBudgetStatus
' + 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' ? '' : '') + + '
'; + 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 @@

connecting…

+
Loading recent history…
+ diff --git a/public/my.html b/public/my.html index 9260f93..872e45f 100644 --- a/public/my.html +++ b/public/my.html @@ -73,6 +73,27 @@ Purchases from your linked wallet automatically credit this account.

+ +