Files
instantadpay/ads.js
T
2026-09-05 07:23:30 -05:00

434 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Ad engine v1 (spec §8b types 1-3). Dual-mode like accounts.js:
// MySQL (db.enabled) with guarded UPDATEs for the concurrent serving path,
// JSON volume file fallback for local dev.
// Spend accrues per campaign; burns queue for the engine signer to consume()
// on-chain (/api/admin/burns). All exported functions are async.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const db = require('./db');
let DATA_DIR = null;
let chain = null;
const RATES_FILE = () => path.join(DATA_DIR, 'adrates.json');
// REHEARSAL PLACEHOLDER RATES — admin-adjustable via /api/admin/rates.
function rates() {
let saved = {};
try { saved = JSON.parse(fs.readFileSync(RATES_FILE(), 'utf8')); } catch (e) {}
return Object.assign({
bannerBatch: 10, bannerCreditsPerBatch: 2,
textBatch: 10, textCreditsPerBatch: 1,
loginCreditsPerDay: 100,
burnBatchMin: 50,
welcomeCredits: 25,
dailyViewTarget: 5, // ads to view for the daily claim (spec §8b attention-gated claim)
dailyClaimCredits: 5,
viewDwellSeconds: 5
}, saved);
}
function setRates(patch) {
fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(rates(), patch), null, 2));
return rates();
}
const TYPES = ['banner', 'text', 'login'];
const URL_RE = /^https?:\/\/[^\s]+$/i;
const bid = () => crypto.randomBytes(8).toString('hex');
function batchFor(type, r) {
return type === 'banner' ? { n: r.bannerBatch, cr: r.bannerCreditsPerBatch }
: type === 'text' ? { n: r.textBatch, cr: r.textCreditsPerBatch } : null;
}
function validate(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 out = { type, name, targetUrl, budget, imageUrl: null, title: null, body: null };
if (type === 'banner' || type === 'login') {
out.imageUrl = String(input.imageUrl || '').trim();
if (!URL_RE.test(out.imageUrl)) return { error: (type === 'banner' ? 'Banner' : 'Login') + ' ads need an image URL starting with http(s)://' };
}
if (type === 'text') {
out.title = String(input.title || '').trim().slice(0, 60);
out.body = String(input.body || '').trim().slice(0, 140);
if (!out.title) return { error: 'Text ads need a headline.' };
}
return { ok: true, c: out };
}
const pubC = c => ({ 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 || 0) + (c.accrued || 0),
imps: c.imps || 0, clicks: c.clicks || 0, status: c.status, created: c.created });
const served = c => ({ id: c.id, type: c.type, targetUrl: '/api/ads/click/' + c.id,
imageUrl: c.imageUrl || null, title: c.title || null, body: c.body || null });
// ---- JSON fallback ----
const J = {
db: { v: 1, nextId: 1, campaigns: [], burnsPending: [] },
FILE: () => path.join(DATA_DIR, 'campaigns.json'),
load() {
try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) {}
if (!this.db || this.db.v !== 1) this.db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] };
},
save() {
try {
const tmp = this.FILE() + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(this.db));
fs.renameSync(tmp, this.FILE());
} catch (e) { console.error('ads save failed', e.message); }
},
async unburned(memberId) {
let s = 0;
for (const b of this.db.burnsPending) if (b.memberId === memberId && !b.burnedTx) s += b.amount;
for (const c of this.db.campaigns) if (c.memberId === memberId) s += c.accrued || 0;
return s;
},
async create(owner, memberId, c) {
const row = Object.assign({ id: this.db.nextId++, owner, memberId, spent: 0, accrued: 0,
imps: 0, clicks: 0, batchImps: 0, status: 'active', created: Date.now() }, c);
this.db.campaigns.push(row);
this.save();
return pubC(row);
},
async list(owner) { return this.db.campaigns.filter(c => c.owner === owner).map(pubC); },
async setStatus(owner, id, status) {
const c = this.db.campaigns.find(x => x.id === Number(id) && x.owner === owner);
if (!c) return { error: 'No such campaign.' };
c.status = status;
this.save();
return { ok: true, campaign: pubC(c) };
},
async serve(type, opts) {
const r = rates();
const ex = opts && opts.excludeEmail;
const pool = this.db.campaigns.filter(c => c.type === type && c.status === 'active' && (!ex || c.owner !== ex));
if (!pool.length) return null;
const c = pool[Math.floor(Math.random() * pool.length)];
c.imps += 1;
const b = batchFor(type, r);
if (b) {
c.batchImps += 1;
if (c.batchImps >= b.n) {
c.batchImps = 0;
// earned pool pays first (spec §8b); only the remainder burns on-chain
if (await spendEarned(c.owner, b.cr)) {
c.spent += b.cr;
} else if (!c.memberId) {
c.status = 'out'; // earned-only advertiser ran dry: no chain pool to fall to
} else {
c.accrued = (c.accrued || 0) + b.cr;
if (c.accrued >= r.burnBatchMin) {
this.db.burnsPending.push({ id: bid(), 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';
}
}
this.save();
return served(c);
},
async click(id) {
const c = this.db.campaigns.find(x => x.id === Number(id));
if (!c) return null;
c.clicks += 1;
this.save();
return c.targetUrl;
},
async targetOf(id) { // resolve without counting a click (earn-view frames the real url)
const c = this.db.campaigns.find(x => x.id === Number(id));
return c ? c.targetUrl : null;
},
async dailySweep() {
const r = rates();
const today = new Date().toISOString().slice(0, 10);
let n = 0;
for (const c of this.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) {
this.db.burnsPending.push({ id: bid(), 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';
n += 1;
}
if (n) this.save();
return n;
},
async pendingBurns() { return this.db.burnsPending.filter(b => !b.burnedTx); },
async markBurned(id, tx) {
const b = this.db.burnsPending.find(x => x.id === id);
if (!b) return { error: 'No such burn.' };
b.burnedTx = tx; b.burnedAt = Date.now();
this.save();
return { ok: true };
}
};
// ---- MySQL mode: guarded UPDATEs keep the serving path concurrency-safe ----
const rowC = r => ({ id: r.id, owner: r.owner_email, memberId: r.member_id, type: r.type, name: r.name,
targetUrl: r.target_url, imageUrl: r.image_url, title: r.title, body: r.body, budget: r.budget,
spent: r.spent, accrued: r.accrued, imps: r.imps, clicks: r.clicks, batchImps: r.batch_imps,
status: r.status, created: Number(r.created) });
const D = {
async unburned(memberId) {
const a = await db.q('SELECT COALESCE(SUM(amount),0) s FROM burns WHERE member_id=? AND burned_tx IS NULL', [memberId]);
const b = await db.q('SELECT COALESCE(SUM(accrued),0) s FROM campaigns WHERE member_id=?', [memberId]);
return Number(a[0].s) + Number(b[0].s);
},
async create(owner, memberId, c) {
const r = await db.q(`INSERT INTO campaigns (owner_email,member_id,type,name,target_url,image_url,title,body,budget,created)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
[owner, memberId, c.type, c.name, c.targetUrl, c.imageUrl, c.title, c.body, c.budget, Date.now()]);
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [r.insertId]);
return pubC(rowC(rows[0]));
},
async list(owner) {
const rows = await db.q('SELECT * FROM campaigns WHERE owner_email=? ORDER BY id DESC', [owner]);
return rows.map(r => pubC(rowC(r)));
},
async setStatus(owner, id, status) {
const r = await db.q('UPDATE campaigns SET status=? WHERE id=? AND owner_email=?', [status, Number(id), owner]);
if (!r.affectedRows) return { error: 'No such campaign.' };
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]);
return { ok: true, campaign: pubC(rowC(rows[0])) };
},
async serve(type, opts) {
const r = rates();
const ex = (opts && opts.excludeEmail) || '';
const rows = await db.q('SELECT * FROM campaigns WHERE type=? AND status=\'active\' AND owner_email<>? ORDER BY RAND() LIMIT 1', [type, ex]);
if (!rows.length) return null;
const c = rowC(rows[0]);
await db.q('UPDATE campaigns SET imps=imps+1, batch_imps=batch_imps+1 WHERE id=?', [c.id]);
const b = batchFor(type, r);
if (b) {
// atomic batch rollover: only one worker wins the WHERE guard
const won = await db.q('UPDATE campaigns SET batch_imps=batch_imps-? WHERE id=? AND batch_imps>=?',
[b.n, c.id, b.n]);
if (won.affectedRows) {
// earned pool pays first (spec §8b); only the remainder burns on-chain
if (await spendEarned(c.owner, b.cr)) {
await db.q('UPDATE campaigns SET spent=spent+? WHERE id=?', [b.cr, c.id]);
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=? AND status=\'active\' AND spent+accrued>=budget', [c.id]);
} else if (!c.memberId) {
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=?', [c.id]); // earned-only ran dry
} else {
await db.q('UPDATE campaigns SET accrued=accrued+? WHERE id=?', [b.cr, c.id]);
await this.rollBurn(c.id, r);
}
}
}
return served(c);
},
async rollBurn(id, r) {
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [id]);
if (!rows.length) return;
const c = rowC(rows[0]);
if (c.accrued >= r.burnBatchMin) {
const upd = await db.q('UPDATE campaigns SET spent=spent+?, accrued=accrued-? WHERE id=? AND accrued>=?',
[c.accrued, c.accrued, id, c.accrued]);
if (upd.affectedRows) {
await db.q('INSERT INTO burns (id,member_id,amount,ref,ts) VALUES (?,?,?,?,?)',
[bid(), c.memberId, c.accrued, 'campaign-' + id, Date.now()]);
}
}
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=? AND status=\'active\' AND spent+accrued>=budget', [id]);
},
async click(id) {
const rows = await db.q('SELECT target_url FROM campaigns WHERE id=?', [Number(id)]);
if (!rows.length) return null;
await db.q('UPDATE campaigns SET clicks=clicks+1 WHERE id=?', [Number(id)]);
return rows[0].target_url;
},
async targetOf(id) { // resolve without counting a click (earn-view frames the real url)
const rows = await db.q('SELECT target_url FROM campaigns WHERE id=?', [Number(id)]);
return rows.length ? rows[0].target_url : null;
},
async dailySweep() {
const r = rates();
const today = new Date().toISOString().slice(0, 10);
const upd = await db.q(`UPDATE campaigns SET last_day_charged=?, accrued=accrued+?
WHERE type='login' AND status='active' AND (last_day_charged IS NULL OR last_day_charged<>?)`,
[today, r.loginCreditsPerDay, today]);
if (upd.affectedRows) {
const rows = await db.q('SELECT id FROM campaigns WHERE type=\'login\' AND last_day_charged=?', [today]);
for (const row of rows) await this.rollBurn(row.id, r);
}
return upd.affectedRows || 0;
},
async pendingBurns() {
const rows = await db.q('SELECT * FROM burns WHERE burned_tx IS NULL ORDER BY ts');
return rows.map(b => ({ id: b.id, memberId: b.member_id, amount: b.amount, ref: b.ref, ts: Number(b.ts) }));
},
async markBurned(id, tx) {
const r = await db.q('UPDATE burns SET burned_tx=?, burned_at=? WHERE id=? AND burned_tx IS NULL', [String(tx), Date.now(), String(id)]);
return r.affectedRows ? { ok: true } : { error: 'No such burn.' };
}
};
// ---- earned/welcome credits (spec §8b second pool: engine-side, never
// on-chain; welcome grant is idempotent and lazy so existing accounts get
// theirs on next dashboard load) ----
const EJ = {
db: null,
FILE: () => path.join(DATA_DIR, 'earned.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = {}; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
async function earnedBalance(email) {
const e = String(email || '').toLowerCase();
if (!e) return 0;
if (db.enabled()) {
const r = await db.q('SELECT balance FROM earned_credits WHERE email=?', [e]);
return r.length ? r[0].balance : 0;
}
if (!EJ.db) EJ.load();
return (EJ.db[e] && EJ.db[e].balance) || 0;
}
async function grantWelcome(email) {
const e = String(email || '').toLowerCase();
if (!e) return 0;
const amount = rates().welcomeCredits || 0;
if (db.enabled()) {
await db.q(`INSERT INTO earned_credits (email,balance,granted_welcome,updated) VALUES (?,?,1,?)
ON DUPLICATE KEY UPDATE balance = balance + IF(granted_welcome=0, ?, 0),
granted_welcome = 1, updated = VALUES(updated)`, [e, amount, Date.now(), amount]);
return earnedBalance(e);
}
if (!EJ.db) EJ.load();
if (!EJ.db[e] || !EJ.db[e].welcomed) {
EJ.db[e] = { balance: ((EJ.db[e] && EJ.db[e].balance) || 0) + amount, welcomed: true };
EJ.save();
}
return (EJ.db[e] && EJ.db[e].balance) || 0;
}
// spend from the earned pool first (banner/text only); true = fully covered
async function spendEarned(email, amount) {
const e = String(email || '').toLowerCase();
if (!e || !amount) return false;
if (db.enabled()) {
const r = await db.q('UPDATE earned_credits SET balance=balance-? WHERE email=? AND balance>=?', [amount, e, amount]);
return !!r.affectedRows;
}
if (!EJ.db) EJ.load();
const rec = EJ.db[e];
if (!rec || rec.balance < amount) return false;
rec.balance -= amount;
EJ.save();
return true;
}
function addEarned(email, amount) {
const e = String(email || '').toLowerCase();
if (db.enabled()) {
return db.q(`INSERT INTO earned_credits (email,balance,granted_welcome,updated) VALUES (?,?,0,?)
ON DUPLICATE KEY UPDATE balance=balance+VALUES(balance), updated=VALUES(updated)`, [e, amount, Date.now()]);
}
if (!EJ.db) EJ.load();
EJ.db[e] = EJ.db[e] || { balance: 0 };
EJ.db[e].balance += amount;
EJ.save();
}
// ---- attention-gated daily claim (view N real ads -> claim earned credits) ----
const VJ = {
db: null,
FILE: () => path.join(DATA_DIR, 'dailyviews.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = {}; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
const today = () => new Date().toISOString().slice(0, 10);
async function viewStatus(email) {
const e = String(email || '').toLowerCase();
const r = rates();
let views = 0, claimed = false;
if (db.enabled()) {
const rows = await db.q('SELECT views, claimed FROM daily_views WHERE email=? AND day=?', [e, today()]);
if (rows.length) { views = rows[0].views; claimed = !!rows[0].claimed; }
} else {
if (!VJ.db) VJ.load();
const rec = VJ.db[e];
if (rec && rec.day === today()) { views = rec.views; claimed = !!rec.claimed; }
}
return { views, target: r.dailyViewTarget, claimCredits: r.dailyClaimCredits,
dwell: r.viewDwellSeconds, claimed, earned: await earnedBalance(e) };
}
async function recordView(email) {
const e = String(email || '').toLowerCase();
const r = rates();
const minGap = Math.max(2, r.viewDwellSeconds - 1) * 1000;
const now = Date.now();
if (db.enabled()) {
await db.q(`INSERT INTO daily_views (email,day,views,claimed,last_ts) VALUES (?,?,0,0,0)
ON DUPLICATE KEY UPDATE email=email`, [e, today()]);
const upd = await db.q(`UPDATE daily_views SET views=views+1, last_ts=? WHERE email=? AND day=? AND views<? AND last_ts<=?`,
[now, e, today(), r.dailyViewTarget, now - minGap]);
if (!upd.affectedRows) return Object.assign(await viewStatus(e), { tooFast: true });
} else {
if (!VJ.db) VJ.load();
let rec = VJ.db[e];
if (!rec || rec.day !== today()) rec = VJ.db[e] = { day: today(), views: 0, claimed: false, last: 0 };
if (rec.views >= r.dailyViewTarget || now - rec.last < minGap) return Object.assign(await viewStatus(e), { tooFast: true });
rec.views += 1; rec.last = now;
VJ.save();
}
return viewStatus(e);
}
async function claimDaily(email) {
const e = String(email || '').toLowerCase();
const r = rates();
if (db.enabled()) {
const upd = await db.q('UPDATE daily_views SET claimed=1 WHERE email=? AND day=? AND claimed=0 AND views>=?',
[e, today(), r.dailyViewTarget]);
if (!upd.affectedRows) return { error: 'View today’s ads first, then claim.' };
} else {
if (!VJ.db) VJ.load();
const rec = VJ.db[e];
if (!rec || rec.day !== today() || rec.claimed || rec.views < r.dailyViewTarget)
return { error: 'View today’s ads first, then claim.' };
rec.claimed = true;
VJ.save();
}
await addEarned(e, r.dailyClaimCredits);
return { ok: true, credited: r.dailyClaimCredits, status: await viewStatus(e) };
}
const impl = () => db.enabled() ? D : J;
function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; J.load(); }
async function availableCredits(memberId) {
const onchain = await chain.creditBalance(memberId, 0);
return Math.max(0, onchain - await impl().unburned(memberId));
}
async function createCampaign(owner, memberId, input) {
const v = validate(input);
if (v.error) return v;
const purchased = await availableCredits(memberId);
const earned = v.c.type !== 'login' ? await earnedBalance(owner) : 0; // earned pool: baseline formats only
const avail = purchased + earned;
if (v.c.budget > avail) return { error: 'Budget exceeds your available credits (' + avail
+ (earned ? ', including ' + earned + ' earned' : '') + ').' };
return { ok: true, campaign: await impl().create(owner, memberId, v.c) };
}
async function listCampaigns(owner) { return impl().list(owner); }
async function setStatus(owner, id, status) {
if (!['active', 'paused'].includes(status)) return { error: 'Bad status.' };
return impl().setStatus(owner, id, status);
}
async function serve(type, opts) { return TYPES.includes(type) ? impl().serve(type, opts) : null; }
async function click(id) { return impl().click(id); }
async function targetOf(id) { return impl().targetOf(id); }
async function dailySweep() { return impl().dailySweep(); }
async function pendingBurns() { return impl().pendingBurns(); }
async function markBurned(id, tx) { return impl().markBurned(id, tx); }
module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus,
serve, click, targetOf, dailySweep, availableCredits, earnedBalance, grantWelcome,
viewStatus, recordView, claimDaily, pendingBurns, markBurned };