Earn-and-spend credit economy + private rehearsal chain cutover

Members earn credits by attention (spec 8b): daily 5-ad set with dwell
timing and a too-fast guard, then a claimable daily batch. Earned credits
now FUND campaigns: banner/text budgets draw earned-first (free members can
advertise on welcome credits alone), purchased credits and the on-chain
burn queue only cover the remainder; earned-only campaigns pause when the
pool runs dry. New Earn credits section in the member menu with the viewer.
Explorer links degrade gracefully for the private chain (contract page
points at the audited Amoy verification). Site flipped to the anvil
rehearsal chain at rpc.instantadpay.com: unlimited test POL, no more
faucets. E2E: welcome->views->claim->earned-funded campaign->charged
serving, all green. Assets v=20260905d.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 05:59:42 -05:00
parent d5bb74db7a
commit cdeb59ed7f
11 changed files with 274 additions and 41 deletions
+126 -11
View File
@@ -21,7 +21,10 @@ function rates() {
textBatch: 10, textCreditsPerBatch: 1,
loginCreditsPerDay: 100,
burnBatchMin: 50,
welcomeCredits: 25 // PLACEHOLDER until Marty sets the rate card (spec §8b earned pool)
welcomeCredits: 25,
dailyViewTarget: 5, // ads to view for the daily claim (spec §8b attention-gated claim)
dailyClaimCredits: 5,
viewDwellSeconds: 5
}, saved);
}
function setRates(patch) {
@@ -110,10 +113,17 @@ const J = {
c.batchImps += 1;
if (c.batchImps >= b.n) {
c.batchImps = 0;
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;
// 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';
}
@@ -193,9 +203,20 @@ const D = {
const b = batchFor(type, r);
if (b) {
// atomic batch rollover: only one worker wins the WHERE guard
await db.q('UPDATE campaigns SET batch_imps=batch_imps-?, accrued=accrued+? WHERE id=? AND batch_imps>=?',
[b.n, b.cr, c.id, b.n]);
await this.rollBurn(c.id, r);
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);
},
@@ -278,6 +299,96 @@ async function grantWelcome(email) {
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(); }
@@ -288,8 +399,11 @@ async function availableCredits(memberId) {
async function createCampaign(owner, memberId, input) {
const v = validate(input);
if (v.error) return v;
const avail = await availableCredits(memberId);
if (v.c.budget > avail) return { error: 'Budget exceeds your available credits (' + avail + ').' };
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); }
@@ -304,4 +418,5 @@ 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, pendingBurns, markBurned };
serve, click, dailySweep, availableCredits, earnedBalance, grantWelcome,
viewStatus, recordView, claimDaily, pendingBurns, markBurned };