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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user