Files
instantadpay/ads.js
T
martbost bee13ba4b8 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 <noreply@anthropic.com>
2026-09-04 13:04:47 -05:00

186 lines
7.3 KiB
JavaScript

// 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 };