e22021c266
Real people, not numbers: usernames (unique, profile pane to set them), shown across the ledger, activity, rosters, and the sidebar chip; vanity invite links (/join/<username>) with late chain binding intact. Promo tools pane ships Branded Voice share posts and an email swipe personalized with each member's link. The earn viewer excludes a member's own campaigns, so nobody earns from their own spend. Back office gains the cyan/violet/amber accent family over the green base. Three house campaigns seeded so every surface shows live inventory. Assets v=20260905i. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
425 lines
19 KiB
JavaScript
425 lines
19 KiB
JavaScript
// 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 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 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 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, dailySweep, availableCredits, earnedBalance, grantWelcome,
|
||
viewStatus, recordView, claimDaily, pendingBurns, markBurned };
|