Files
instantadpay/ads.js
T
martbost 9e05711f4e Achievement badges + milestone credit bonuses
- Idempotent one-time credit bonus per milestone (payouts/firstBuyer/level2/level3): 10/25/50/100 cr
- Overview badges strip (unlockable medals) with canvas share-to-image (username overlaid)
- Newly-unlocked milestones toast the bonus on next dashboard load

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-06 08:43:50 -05:00

934 lines
45 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');
const nas = require('./nas'); // NAS syndication (inert unless NAS_DB_* env is set)
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,
loginDwellSeconds: 10, // full-screen interstitial after sign-in
burnBatchMin: 50,
welcomeCredits: 25,
dailyViewTarget: 5, // ads to view for the daily claim (spec §8b attention-gated claim)
dailyClaimCredits: 5,
viewDwellSeconds: 5,
// onsite solo ads: full-message inbox delivery, charged per guaranteed recipient
soloCostPerRecipient: 5,
soloMinRecipients: 10,
soloReadCredits: 2, // earned by the reader per rewarded read
soloReadCapPerDay: 5,
soloReadDwellSeconds: 10,
// watch-to-earn video ads: advertiser picks a required watch length, which
// sets the per-view price; the viewer earns per completed watch
videoTiers: [
{ secs: 10, cost: 3, reward: 1 },
{ secs: 30, cost: 7, reward: 2 },
{ secs: 60, cost: 12, reward: 4 }
],
videoWatchCapPerDay: 8,
// credit bonuses paid once when a member reaches each milestone (the same
// ladder as the Overview stepper). Keys: payouts / firstBuyer / level2 / level3
milestoneBonus: { payouts: 10, firstBuyer: 25, level2: 50, level3: 100 }
}, saved);
}
function setRates(patch) {
fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(rates(), patch), null, 2));
return rates();
}
const TYPES = ['banner', 'text', 'login', 'solo', 'video'];
// standard IAB sizes — ids map straight to NAS sponsorads width/height (pid 2)
const BANNER_SIZES = [
{ id: '728x90', w: 728, h: 90, label: 'Leaderboard 728×90' },
{ id: '300x250', w: 300, h: 250, label: 'Medium rectangle 300×250' },
{ id: '468x60', w: 468, h: 60, label: 'Banner 468×60' },
{ id: '160x600', w: 160, h: 600, label: 'Wide skyscraper 160×600' },
{ id: '120x600', w: 120, h: 600, label: 'Skyscraper 120×600' },
{ id: '320x50', w: 320, h: 50, label: 'Mobile leaderboard 320×50' }
];
// solo bodies are member-authored rich text rendered in OTHER members'
// browsers: whitelist-sanitize server-side — known tags only, no attributes
// (except http(s) hrefs, rebuilt clean), every stray angle bracket escaped
const SOLO_TAGS = new Set(['b', 'strong', 'i', 'em', 'u', 's', 'p', 'br',
'ul', 'ol', 'li', 'h2', 'h3', 'h4', 'blockquote', 'div', 'span', 'figure']);
function sanitizeSolo(html) {
const src = String(html || '').replace(//g, '')
.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, '') // drop script/style content whole
.replace(/<(script|style)\b[^>]*>/gi, '') // and any unclosed opener
.slice(0, 12000).replace(/<!--[\s\S]*?-->/g, '');
const keep = [];
const tokenized = src.replace(/<\s*(\/?)\s*([a-zA-Z0-9]+)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (m, close, tag, attrs) => {
tag = tag.toLowerCase();
let out = '';
const safeSrc = s => /^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif|mp4|webm)|https:\/\/[^\s"'<>]+)$/i.test(s);
const srcOf = a => { const m2 = /src\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(a || ''); return (m2 && (m2[1] || m2[2])) || ''; };
if (tag === 'a') {
if (close) out = '</a>';
else {
const hm = /href\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs || '');
const href = (hm && (hm[1] || hm[2])) || '';
out = URL_RE.test(href) ? '<a href="' + href.replace(/"/g, '%22') + '" target="_blank" rel="noopener nofollow">' : '';
}
} else if (tag === 'img') { // inline images: only our uploads or https, no other attrs
const s = srcOf(attrs);
out = (!close && safeSrc(s)) ? '<img src="' + s.replace(/"/g, '%22') + '" alt="" loading="lazy">' : '';
} else if (tag === 'video') {
out = close ? '</video>' : (safeSrc(srcOf(attrs)) ? '<video src="' + srcOf(attrs).replace(/"/g, '%22') + '" controls playsinline>' : '<video controls playsinline>');
} else if (tag === 'source') {
const s = srcOf(attrs);
out = (!close && safeSrc(s)) ? '<source src="' + s.replace(/"/g, '%22') + '">' : '';
} else if (SOLO_TAGS.has(tag)) out = '<' + (close ? '/' : '') + tag + '>';
keep.push(out);
return '' + (keep.length - 1) + '';
});
return tokenized.replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/(\d+)/g, (m, i) => keep[Number(i)]).trim();
}
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') {
out.imageUrl = String(input.imageUrl || '').trim();
if (!URL_RE.test(out.imageUrl)) return { error: 'Banner ads need an image URL starting with http(s)://' };
const size = BANNER_SIZES.find(s => s.id === String(input.size || ''));
if (!size) return { error: 'Pick a banner size.' };
out.width = size.w; out.height = size.h;
}
// login ads are full-screen interstitials of the target URL itself: no creative needed
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.' };
}
if (type === 'solo') {
const r = rates();
out.title = String(input.title || '').trim().slice(0, 80);
out.body = sanitizeSolo(input.body);
const plain = out.body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (!out.title) return { error: 'Solo ads need a subject line.' };
if (plain.length < 40) return { error: 'Write the message — at least 40 characters.' };
if (plain.length > 2000) return { error: 'Keep the message under 2000 characters of text.' };
const mu = String(input.mediaUrl || '').trim();
if (mu) {
if (!/^\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif|mp4|webm)$/.test(mu))
return { error: 'Attach the image or video through the uploader.' };
out.imageUrl = mu; // solo media rides the image_url column
}
out.ctaLabel = String(input.ctaLabel || '').trim().slice(0, 30) || null;
const min = (r.soloCostPerRecipient || 5) * (r.soloMinRecipients || 10);
if (budget < min) return { error: 'Solo ads start at ' + min + ' credits (' + (r.soloMinRecipients || 10) + ' guaranteed deliveries).' };
}
if (type === 'video') {
const r = rates();
const src = String(input.videoUrl || '').trim();
if (!/^(\/uploads\/[a-z0-9]{24}\.(mp4|webm)|https:\/\/[^\s]+\.(mp4|webm)(\?[^\s]*)?)$/i.test(src))
return { error: 'Upload an MP4/WebM, or paste a direct https link ending in .mp4 or .webm.' };
out.imageUrl = src; // video source rides the image_url column
out.title = String(input.title || '').trim().slice(0, 80) || null;
out.ctaLabel = String(input.ctaLabel || '').trim().slice(0, 30) || null;
const tier = (r.videoTiers || []).find(t => t.secs === Number(input.watchSecs));
if (!tier) return { error: 'Pick a watch length (' + (r.videoTiers || []).map(t => t.secs + 's').join(', ') + ').' };
out.body = String(tier.secs); // required watch seconds ride the body column
const min = tier.cost * 10; // at least 10 views' worth
if (budget < min) return { error: 'A ' + tier.secs + 's video ad starts at ' + min + ' credits (' + tier.cost + ' per view, 10 views).' };
}
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),
width: c.width || null, height: c.height || null, ctaLabel: c.ctaLabel || null,
imps: c.imps || 0, impsNas: c.nasServed || 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,
width: c.width || null, height: c.height || 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,
width: r.width || null, height: r.height || null, ctaLabel: r.cta_label || null,
nasAdId: r.nas_ad_id || null, nasServed: r.nas_served || 0,
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,cta_label,width,height)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[owner, memberId, c.type, c.name, c.targetUrl, c.imageUrl, c.title, c.body, c.budget, Date.now(), c.ctaLabel || null, c.width || null, c.height || null]);
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 welcomeGranted(email) { // read-only check, never grants
const e = String(email || '').toLowerCase();
if (!e) return true;
if (db.enabled()) {
const r = await db.q('SELECT granted_welcome FROM earned_credits WHERE email=?', [e]);
return r.length ? !!r[0].granted_welcome : false;
}
if (!EJ.db) EJ.load();
return !!(EJ.db[e] && EJ.db[e].welcomed);
}
// idempotent milestone bonuses: grant the credit bonus for each newly-reached
// milestone once. `reached` is the list of currently-true milestone keys.
// Returns [{ key, credited }] for any that were newly granted this call.
async function grantMilestones(email, reached) {
const e = String(email || '').toLowerCase();
if (!e || !reached || !reached.length) return [];
const bonus = rates().milestoneBonus || {};
let have = [];
if (db.enabled()) {
const r = await db.q('SELECT milestones FROM earned_credits WHERE email=?', [e]);
have = (r.length && r[0].milestones ? r[0].milestones : '').split(',').filter(Boolean);
} else {
if (!EJ.db) EJ.load();
have = (EJ.db[e] && EJ.db[e].milestones) || [];
}
const granted = [];
for (const key of reached) {
if (have.includes(key) || !(bonus[key] > 0)) continue;
await addEarned(e, bonus[key]);
have.push(key);
granted.push({ key, credited: bonus[key] });
}
if (granted.length) {
if (db.enabled()) {
await db.q(`INSERT INTO earned_credits (email,balance,granted_welcome,updated,milestones) VALUES (?,0,0,?,?)
ON DUPLICATE KEY UPDATE milestones=VALUES(milestones), updated=VALUES(updated)`, [e, Date.now(), have.join(',')]);
} else {
if (!EJ.db) EJ.load();
EJ.db[e] = EJ.db[e] || { balance: 0 };
EJ.db[e].milestones = have;
EJ.save();
}
}
return granted;
}
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();
}
// ---- onsite solo ads: full-message ads delivered into member inboxes,
// charged per guaranteed delivery; readers earn credits for dwelled reads ----
const SJ = {
db: null,
FILE: () => path.join(DATA_DIR, 'inbox.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = { nextId: 1, items: [] }; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
// lazy guaranteed delivery: whenever a member touches their inbox (or the
// dashboard asks for their unread count), pending solos fill toward their
// recipient guarantee — never the sender's own, never twice to one member,
// and the advertiser is charged per delivery through the same earned-first
// then burn-accrual path every other format uses
async function deliverSolos(email, max = 3) {
const e = String(email || '').toLowerCase();
if (!e) return 0;
const r = rates();
const cost = r.soloCostPerRecipient || 5;
let n = 0;
if (db.enabled()) {
const rows = await db.q(`SELECT c.* FROM campaigns c
WHERE c.type='solo' AND c.status='active' AND c.owner_email<>?
AND c.budget - c.spent - c.accrued >= ?
AND NOT EXISTS (SELECT 1 FROM solo_inbox s WHERE s.campaign_id=c.id AND s.email=?)
ORDER BY c.created LIMIT ?`, [e, cost, e, max]);
for (const row of rows) {
try { await db.q('INSERT INTO solo_inbox (campaign_id,email,delivered) VALUES (?,?,?)', [row.id, e, Date.now()]); }
catch (er) { continue; } // unique key lost a race: already delivered
if (await spendEarned(row.owner_email, cost)) {
await db.q('UPDATE campaigns SET spent=spent+?, imps=imps+1 WHERE id=?', [cost, row.id]);
} else if (row.member_id) {
await db.q('UPDATE campaigns SET accrued=accrued+?, imps=imps+1 WHERE id=?', [cost, row.id]);
await D.rollBurn(row.id, r);
} else { // earned-only advertiser ran dry: undo the delivery, close the campaign
await db.q('DELETE FROM solo_inbox WHERE campaign_id=? AND email=?', [row.id, e]);
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=?', [row.id]);
continue;
}
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=? AND status=\'active\' AND spent+accrued>=budget', [row.id]);
n++;
}
} else {
if (!SJ.db) SJ.load();
const have = new Set(SJ.db.items.filter(i => i.email === e).map(i => i.cid));
for (const c of J.db.campaigns) {
if (n >= max) break;
if (c.type !== 'solo' || c.status !== 'active' || c.owner === e || have.has(c.id)) continue;
if (c.budget - c.spent - (c.accrued || 0) < cost) continue;
if (await spendEarned(c.owner, cost)) c.spent += cost;
else if (c.memberId) {
c.accrued = (c.accrued || 0) + cost;
if (c.accrued >= r.burnBatchMin) {
J.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;
}
} else { c.status = 'out'; continue; } // earned-only ran dry
c.imps += 1;
if (c.spent + (c.accrued || 0) >= c.budget) c.status = 'out';
SJ.db.items.push({ id: SJ.db.nextId++, cid: c.id, email: e, delivered: Date.now(), readTs: 0, rewarded: 0, rewardedDay: null });
n++;
}
if (n) { J.save(); SJ.save(); }
}
return n;
}
async function inboxList(email) {
const e = String(email || '').toLowerCase();
await deliverSolos(e);
const r = rates();
let items = [];
if (db.enabled()) {
const rows = await db.q(`SELECT s.id, s.campaign_id cid, s.delivered, s.read_ts, s.rewarded,
c.title, c.member_id mid FROM solo_inbox s JOIN campaigns c ON c.id = s.campaign_id
WHERE s.email=? ORDER BY s.delivered DESC LIMIT 100`, [e]);
items = rows.map(x => ({ id: x.id, cid: x.cid, subject: x.title, fromMemberId: x.mid,
delivered: Number(x.delivered), read: !!x.read_ts, rewarded: !!x.rewarded }));
} else {
if (!SJ.db) SJ.load();
items = SJ.db.items.filter(i => i.email === e).sort((a, b) => b.delivered - a.delivered).slice(0, 100)
.map(i => {
const c = J.db.campaigns.find(x => x.id === i.cid) || {};
return { id: i.id, cid: i.cid, subject: c.title || c.name, fromMemberId: c.memberId || 0,
delivered: i.delivered, read: !!i.readTs, rewarded: !!i.rewarded };
});
}
return { items, unread: items.filter(i => !i.read).length,
readCredits: r.soloReadCredits || 2, readDwell: r.soloReadDwellSeconds || 10, readCap: r.soloReadCapPerDay || 5 };
}
async function inboxOpen(email, id) {
const e = String(email || '').toLowerCase();
const r = rates();
if (db.enabled()) {
const rows = await db.q(`SELECT s.*, c.title, c.body, c.member_id mid, c.image_url media, c.cta_label cta
FROM solo_inbox s JOIN campaigns c ON c.id = s.campaign_id WHERE s.id=? AND s.email=?`, [Number(id), e]);
if (!rows.length) return { error: 'No such message.' };
const x = rows[0];
if (!x.read_ts) await db.q('UPDATE solo_inbox SET read_ts=? WHERE id=? AND read_ts IS NULL', [Date.now(), x.id]);
return { id: x.id, cid: x.campaign_id, subject: x.title, body: x.body || '', fromMemberId: x.mid,
mediaUrl: x.media || null, mediaType: x.media ? (/\.(mp4|webm)$/.test(x.media) ? 'video' : 'image') : null,
ctaLabel: x.cta || null, visited: !!x.visited_ts,
url: '/api/ads/click/' + x.campaign_id, delivered: Number(x.delivered),
rewarded: !!x.rewarded, dwell: r.soloReadDwellSeconds || 10, reward: r.soloReadCredits || 2 };
}
if (!SJ.db) SJ.load();
const i = SJ.db.items.find(x => x.id === Number(id) && x.email === e);
if (!i) return { error: 'No such message.' };
if (!i.readTs) { i.readTs = Date.now(); SJ.save(); }
const c = J.db.campaigns.find(x => x.id === i.cid) || {};
return { id: i.id, cid: i.cid, subject: c.title || c.name, body: c.body || '', fromMemberId: c.memberId || 0,
mediaUrl: c.imageUrl || null, mediaType: c.imageUrl ? (/\.(mp4|webm)$/.test(c.imageUrl) ? 'video' : 'image') : null,
ctaLabel: c.ctaLabel || null, visited: !!i.visitedTs,
url: '/api/ads/click/' + i.cid, delivered: i.delivered,
rewarded: !!i.rewarded, dwell: r.soloReadDwellSeconds || 10, reward: r.soloReadCredits || 2 };
}
// the reader clicked the CTA and the click relay opened the advertiser: record
// it so the read reward requires an actual visit, not just sitting on the page
async function markSoloVisit(email, id) {
const e = String(email || '').toLowerCase();
if (db.enabled()) {
const r = await db.q('UPDATE solo_inbox SET visited_ts=? WHERE id=? AND email=? AND visited_ts IS NULL', [Date.now(), Number(id), e]);
return { ok: true, changed: !!r.affectedRows };
}
if (!SJ.db) SJ.load();
const i = SJ.db.items.find(x => x.id === Number(id) && x.email === e);
if (!i) return { error: 'No such message.' };
if (!i.visitedTs) { i.visitedTs = Date.now(); SJ.save(); }
return { ok: true };
}
async function claimSoloRead(email, id) {
const e = String(email || '').toLowerCase();
const r = rates();
const dwellMs = (r.soloReadDwellSeconds || 10) * 1000;
const cap = r.soloReadCapPerDay || 5;
const reward = r.soloReadCredits || 2;
const day = today();
if (db.enabled()) {
const rows = await db.q('SELECT * FROM solo_inbox WHERE id=? AND email=?', [Number(id), e]);
if (!rows.length) return { error: 'No such message.' };
const x = rows[0];
if (x.rewarded) return { error: 'Already claimed for this one.' };
if (!x.read_ts || Date.now() - Number(x.read_ts) < dwellMs - 400) return { error: 'Give it a real read first.' };
if (!x.visited_ts) return { error: 'Click through to the advertiser first — that visit is what earns the credits.' };
const cnt = await db.q('SELECT COUNT(*) n FROM solo_inbox WHERE email=? AND rewarded=1 AND rewarded_day=?', [e, day]);
if (cnt[0].n >= cap) return { error: 'Daily read-reward cap reached (' + cap + '). Reading still works; rewards resume tomorrow.' };
const upd = await db.q('UPDATE solo_inbox SET rewarded=1, rewarded_day=? WHERE id=? AND rewarded=0', [day, x.id]);
if (!upd.affectedRows) return { error: 'Already claimed for this one.' };
await addEarned(e, reward);
return { ok: true, credited: reward };
}
if (!SJ.db) SJ.load();
const i = SJ.db.items.find(x => x.id === Number(id) && x.email === e);
if (!i) return { error: 'No such message.' };
if (i.rewarded) return { error: 'Already claimed for this one.' };
if (!i.readTs || Date.now() - i.readTs < dwellMs - 400) return { error: 'Give it a real read first.' };
if (!i.visitedTs) return { error: 'Click through to the advertiser first — that visit is what earns the credits.' };
const nToday = SJ.db.items.filter(x => x.email === e && x.rewarded && x.rewardedDay === day).length;
if (nToday >= cap) return { error: 'Daily read-reward cap reached (' + cap + '). Reading still works; rewards resume tomorrow.' };
i.rewarded = 1;
i.rewardedDay = day;
SJ.save();
addEarned(e, reward);
return { ok: true, credited: reward };
}
async function unreadCount(email) {
const e = String(email || '').toLowerCase();
if (!e) return 0;
await deliverSolos(e);
if (db.enabled()) {
const r = await db.q('SELECT COUNT(*) n FROM solo_inbox WHERE email=? AND read_ts IS NULL', [e]);
return r[0].n;
}
if (!SJ.db) SJ.load();
return SJ.db.items.filter(i => i.email === e && !i.readTs).length;
}
// ---- 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);
}
// ---- watch-to-earn video ads: serve a video, enforce watch on server clock,
// charge the advertiser the tier cost per completed watch, credit the viewer ----
function videoTierFor(secs) { return (rates().videoTiers || []).find(t => t.secs === Number(secs)); }
function servedVideo(c) {
const t = videoTierFor(c.body) || { secs: 10, reward: 1 };
return { id: c.id, videoUrl: c.imageUrl, title: c.title || null, ctaUrl: '/api/ads/click/' + c.id,
ctaLabel: c.ctaLabel || null, watchSecs: t.secs, reward: t.reward };
}
async function serveVideo(opts) {
const ex = (opts && opts.excludeEmail) || '';
if (db.enabled()) {
const rows = await db.q(`SELECT * FROM campaigns WHERE type='video' AND status='active'
AND owner_email<>? AND spent+accrued<budget ORDER BY RAND() LIMIT 1`, [ex]);
return rows.length ? servedVideo(rowC(rows[0])) : null;
}
const pool = J.db.campaigns.filter(c => c.type === 'video' && c.status === 'active'
&& (!ex || c.owner !== ex) && (c.spent + (c.accrued || 0) < c.budget));
return pool.length ? servedVideo(pool[Math.floor(Math.random() * pool.length)]) : null;
}
async function chargeVideoView(id) {
const r = rates();
if (db.enabled()) {
const rows = await db.q("SELECT * FROM campaigns WHERE id=? AND type='video' AND status='active'", [Number(id)]);
if (!rows.length) return null;
const c = rowC(rows[0]); const t = videoTierFor(c.body); if (!t) return null;
if (await spendEarned(c.owner, t.cost)) {
await db.q('UPDATE campaigns SET spent=spent+?, imps=imps+1 WHERE id=?', [t.cost, c.id]);
} else if (c.memberId) {
await db.q('UPDATE campaigns SET accrued=accrued+?, imps=imps+1 WHERE id=?', [t.cost, c.id]);
await D.rollBurn(c.id, r);
} else { await db.q("UPDATE campaigns SET status='out' WHERE id=?", [c.id]); return null; }
await db.q("UPDATE campaigns SET status='out' WHERE id=? AND status='active' AND spent+accrued>=budget", [c.id]);
return t;
}
const c = J.db.campaigns.find(x => x.id === Number(id) && x.type === 'video');
if (!c || c.status !== 'active') return null;
const t = videoTierFor(c.body); if (!t) return null;
if (await spendEarned(c.owner, t.cost)) { c.spent += t.cost; }
else if (c.memberId) {
c.accrued = (c.accrued || 0) + t.cost;
if (c.accrued >= r.burnBatchMin) {
J.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;
}
} else { c.status = 'out'; J.save(); return null; }
c.imps += 1;
if (c.spent + (c.accrued || 0) >= c.budget) c.status = 'out';
J.save();
return t;
}
async function videoStatus(email) {
const e = String(email || '').toLowerCase(); const r = rates(); const cap = r.videoWatchCapPerDay || 8;
let count = 0;
if (db.enabled()) {
const rows = await db.q('SELECT video_count FROM daily_views WHERE email=? AND day=?', [e, today()]);
if (rows.length) count = rows[0].video_count || 0;
} else {
if (!VJ.db) VJ.load();
const rec = VJ.db[e];
if (rec && rec.day === today()) count = rec.videoCount || 0;
}
return { count, cap, left: Math.max(0, cap - count) };
}
async function recordVideoWatch(email) {
const e = String(email || '').toLowerCase();
if (db.enabled()) {
await db.q(`INSERT INTO daily_views (email,day,views,claimed,last_ts,video_count) VALUES (?,?,0,0,0,1)
ON DUPLICATE KEY UPDATE video_count=video_count+1`, [e, today()]);
} 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, videoCount: 0 };
rec.videoCount = (rec.videoCount || 0) + 1;
VJ.save();
}
return videoStatus(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' : '') + ').' };
const campaign = await impl().create(owner, memberId, v.c);
// syndicate banner/text out to NAS (inert unless NAS_DB_* is configured);
// never let a NAS hiccup block the IAP campaign from going live
if (nas.enabled() && nas.nasKind(campaign.type)) {
try {
const r = await nas.pushCampaign(campaign, { email: owner, name: input.advName || 'InstantAdPay member' });
if (r && r.nasAdId) await setNasLink(campaign.id, r.nasAdId);
} catch (e) { console.error('nas push', campaign.id, e.message); }
}
return { ok: true, campaign };
}
// charge an arbitrary credit amount against a campaign's budget, earned-pool
// first then on-chain accrual — the shared spend path for NAS reconciliation
async function chargeCredits(id, credits) {
const r = rates();
if (!credits || credits <= 0) return { charged: 0 };
if (db.enabled()) {
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]);
if (!rows.length) return { charged: 0 };
const c = rowC(rows[0]);
if (await spendEarned(c.owner, credits)) await db.q('UPDATE campaigns SET spent=spent+? WHERE id=?', [credits, id]);
else if (c.memberId) { await db.q('UPDATE campaigns SET accrued=accrued+? WHERE id=?', [credits, id]); await D.rollBurn(id, r); }
else { await db.q("UPDATE campaigns SET status='out' WHERE id=?", [id]); return { charged: 0, exhausted: true }; }
const after = rowC((await db.q('SELECT * FROM campaigns WHERE id=?', [id]))[0]);
const exhausted = (after.spent + after.accrued) >= after.budget;
if (exhausted) await db.q("UPDATE campaigns SET status='out' WHERE id=? AND status='active'", [id]);
return { charged: credits, exhausted };
}
const c = J.db.campaigns.find(x => x.id === Number(id));
if (!c) return { charged: 0 };
if (await spendEarned(c.owner, credits)) c.spent += credits;
else if (c.memberId) {
c.accrued = (c.accrued || 0) + credits;
if (c.accrued >= r.burnBatchMin) { J.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; }
} else { c.status = 'out'; J.save(); return { charged: 0, exhausted: true }; }
const exhausted = (c.spent + (c.accrued || 0)) >= c.budget;
if (exhausted) c.status = 'out';
J.save();
return { charged: credits, exhausted };
}
async function setNasLink(id, nasAdId) {
if (db.enabled()) await db.q('UPDATE campaigns SET nas_ad_id=? WHERE id=?', [nasAdId, Number(id)]);
else { const c = J.db.campaigns.find(x => x.id === Number(id)); if (c) { c.nasAdId = nasAdId; J.save(); } }
}
async function setNasServed(id, served) {
if (db.enabled()) await db.q('UPDATE campaigns SET nas_served=? WHERE id=?', [served, Number(id)]);
else { const c = J.db.campaigns.find(x => x.id === Number(id)); if (c) { c.nasServed = served; J.save(); } }
}
// reconcile NAS delivery into the unified credit pool: read each syndicated
// ad's served count (assigned - remaining, counts DOWN), charge the new
// impressions to the campaign's budget, and stop the NAS ad when budget is dry
async function reconcileNas() {
if (!nas.enabled()) return { done: 0 };
let camps;
if (db.enabled()) {
const rows = await db.q("SELECT * FROM campaigns WHERE nas_ad_id IS NOT NULL AND type IN ('banner','text')");
camps = rows.map(rowC);
} else {
if (!J.db) J.load();
camps = J.db.campaigns.filter(c => c.nasAdId && ['banner', 'text'].includes(c.type));
}
let n = 0;
for (const c of camps) {
let st;
try { st = await nas.readServed(c.nasAdId); } catch (e) { continue; }
if (!st) continue;
const ipc = nas.impressionsPerCredit(c.type) || 1;
const prev = c.nasServed || 0;
if (st.served > prev) {
const creditsDue = Math.floor(st.served / ipc) - Math.floor(prev / ipc);
if (creditsDue > 0) {
const res = await chargeCredits(c.id, creditsDue);
if (res.exhausted) { try { await nas.deactivate(c.nasAdId); } catch (e) {} }
}
await setNasServed(c.id, st.served);
n++;
}
}
return { done: n };
}
async function listCampaigns(owner) { return impl().list(owner); }
async function setStatus(owner, id, status) {
if (!['active', 'paused'].includes(status)) return { error: 'Bad status.' };
const r = await impl().setStatus(owner, id, status);
// mirror pause/resume to the syndicated NAS ad, if any
if (!r.error && nas.enabled()) {
try {
const c = (await impl().list(owner)).find(x => x.id === Number(id));
const raw = db.enabled() ? rowC((await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]))[0])
: J.db.campaigns.find(x => x.id === Number(id));
if (raw && raw.nasAdId) {
if (status === 'paused') await nas.deactivate(raw.nasAdId);
else { // resume: restore remaining impressions for the budget still left
const left = (c.budget - c.spent) * (nas.impressionsPerCredit(c.type) || 0);
if (left > 0) await nas.topUp(raw.nasAdId, Math.floor(left), 30);
}
}
} catch (e) { console.error('nas setStatus', id, e.message); }
}
return r;
}
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, bannerSizes: () => BANNER_SIZES, sanitizeRich: sanitizeSolo, createCampaign, listCampaigns, setStatus,
serve, click, targetOf, dailySweep, availableCredits, earnedBalance, grantWelcome, welcomeGranted,
viewStatus, recordView, claimDaily, pendingBurns, markBurned, grantMilestones,
inboxList, inboxOpen, markSoloVisit, claimSoloRead, unreadCount,
serveVideo, chargeVideoView, videoStatus, recordVideoWatch, addEarned,
reconcileNas, nasEnabled: () => nas.enabled() };