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
+121 -6
View File
@@ -21,7 +21,10 @@ function rates() {
textBatch: 10, textCreditsPerBatch: 1, textBatch: 10, textCreditsPerBatch: 1,
loginCreditsPerDay: 100, loginCreditsPerDay: 100,
burnBatchMin: 50, 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); }, saved);
} }
function setRates(patch) { function setRates(patch) {
@@ -110,11 +113,18 @@ const J = {
c.batchImps += 1; c.batchImps += 1;
if (c.batchImps >= b.n) { if (c.batchImps >= b.n) {
c.batchImps = 0; 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; c.accrued = (c.accrued || 0) + b.cr;
if (c.accrued >= r.burnBatchMin) { if (c.accrued >= r.burnBatchMin) {
this.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() }); 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; c.spent += c.accrued; c.accrued = 0;
} }
}
if (c.spent + c.accrued >= c.budget) c.status = 'out'; if (c.spent + c.accrued >= c.budget) c.status = 'out';
} }
} }
@@ -193,10 +203,21 @@ const D = {
const b = batchFor(type, r); const b = batchFor(type, r);
if (b) { if (b) {
// atomic batch rollover: only one worker wins the WHERE guard // 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>=?', const won = await db.q('UPDATE campaigns SET batch_imps=batch_imps-? WHERE id=? AND batch_imps>=?',
[b.n, b.cr, c.id, b.n]); [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); await this.rollBurn(c.id, r);
} }
}
}
return served(c); return served(c);
}, },
async rollBurn(id, r) { async rollBurn(id, r) {
@@ -278,6 +299,96 @@ async function grantWelcome(email) {
return (EJ.db[e] && EJ.db[e].balance) || 0; 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; const impl = () => db.enabled() ? D : J;
function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; J.load(); } 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) { async function createCampaign(owner, memberId, input) {
const v = validate(input); const v = validate(input);
if (v.error) return v; if (v.error) return v;
const avail = await availableCredits(memberId); const purchased = await availableCredits(memberId);
if (v.c.budget > avail) return { error: 'Budget exceeds your available credits (' + avail + ').' }; 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) }; return { ok: true, campaign: await impl().create(owner, memberId, v.c) };
} }
async function listCampaigns(owner) { return impl().list(owner); } 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); } async function markBurned(id, tx) { return impl().markBurned(id, tx); }
module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus, 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 };
+1
View File
@@ -49,6 +49,7 @@ FACTS:
- Free to join with email only (6-digit code sign-in, no passwords). Wallet appears only at purchase or payout activation. Every new member gets a small welcome batch of ad credits at signup (engine-side bonus credits, separate from on-chain purchased credits). - Free to join with email only (6-digit code sign-in, no passwords). Wallet appears only at purchase or payout activation. Every new member gets a small welcome batch of ad credits at signup (engine-side bonus credits, separate from on-chain purchased credits).
- Ad packages: Micro $5/500 credits, Activation $20/2,000, Builder $50/5,500, Growth $100/12,000, Leader $250/32,500. Dollar-priced, settled in POL (Polygon) at the live Chainlink rate. 1 credit = 1 cent of ad delivery. - Ad packages: Micro $5/500 credits, Activation $20/2,000, Builder $50/5,500, Growth $100/12,000, Leader $250/32,500. Dollar-priced, settled in POL (Polygon) at the live Chainlink rate. 1 credit = 1 cent of ad delivery.
- Live formats: display banners (per impression), text ads (per impression), login ads (per day). Coming: inbox ads, featured rotation with disclosed rotation size, verified-visit packs. - Live formats: display banners (per impression), text ads (per impression), login ads (per day). Coming: inbox ads, featured rotation with disclosed rotation size, verified-visit packs.
- Members EARN credits by attention: view the daily set of member ads (a few seconds each) in the Earn credits section of Members, then claim a small daily credit batch. Earned credits spend on banner and text campaigns; attention earns advertising, referrals earn money, and viewer rewards are never cash.
- Every purchase is split by an immutable smart contract in the same transaction: 50% direct sponsor, 20% level 2, 10% level 3, 20% platform. No withdrawals exist; money lands in members' own wallets instantly. - Every purchase is split by an immutable smart contract in the same transaction: 50% direct sponsor, 20% level 2, 10% level 3, 20% platform. No withdrawals exist; money lands in members' own wallets instantly.
- Qualification: level 1 open to all; 2 buyers of $20+ unlock level 2; 5 unlock level 3. Unqualified shares pass up the sponsor line, checking up to 25 positions, else the platform receives them. Qualification cannot be bought and never expires. - Qualification: level 1 open to all; 2 buyers of $20+ unlock level 2; 5 unlock level 3. Unqualified shares pass up the sponsor line, checking up to 25 positions, else the platform receives them. Qualification cannot be bought and never expires.
- Every member gets a share link immediately (site code, /join/<code>); the contract locks a buyer to their sponsor at the buyer's FIRST purchase, so members should switch on payouts before their referrals buy. - Every member gets a share link immediately (site code, /join/<code>); the contract locks a buyer to their sponsor at the buyer's FIRST purchase, so members should switch on payouts before their referrals buy.
+8
View File
@@ -70,6 +70,14 @@ async function bootstrap() {
granted_welcome TINYINT NOT NULL DEFAULT 0, granted_welcome TINYINT NOT NULL DEFAULT 0,
updated BIGINT NOT NULL updated BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS daily_views (
email VARCHAR(190) NOT NULL,
day CHAR(10) NOT NULL,
views INT NOT NULL DEFAULT 0,
claimed TINYINT NOT NULL DEFAULT 0,
last_ts BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (email, day)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS burns ( await q(`CREATE TABLE IF NOT EXISTS burns (
id VARCHAR(32) PRIMARY KEY, id VARCHAR(32) PRIMARY KEY,
member_id INT NOT NULL, member_id INT NOT NULL,
+3 -1
View File
@@ -85,7 +85,9 @@ window.IAP = (function () {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'row t-' + ev.type; div.className = 'row t-' + ev.type;
div.innerHTML = '<span>' + describeEvent(ev, c) + '</span>' div.innerHTML = '<span>' + describeEvent(ev, c) + '</span>'
+ '<span class="tx"><a target="_blank" rel="noopener" href="' + c.explorer + '/tx/' + ev.tx + '">verify ↗</a></span>'; + (c.explorer
? '<span class="tx"><a target="_blank" rel="noopener" href="' + c.explorer + '/tx/' + ev.tx + '">verify ↗</a></span>'
: '<span class="tx mono">' + ev.tx.slice(0, 12) + '…</span>');
return div; return div;
} }
// Render one served ad into #<elId>. Silent if no inventory. // Render one served ad into #<elId>. Silent if no inventory.
+8
View File
@@ -6,8 +6,16 @@
$('cAddr').textContent = c.contract.slice(0, 10) + '…' + c.contract.slice(-6); $('cAddr').textContent = c.contract.slice(0, 10) + '…' + c.contract.slice(-6);
$('cChain').textContent = c.chainName; $('cChain').textContent = c.chainName;
$('mockAddr').textContent = c.contract.slice(0, 18) + '…'; $('mockAddr').textContent = c.contract.slice(0, 18) + '…';
if (c.explorer) {
$('lnkExplorer').href = c.explorer + '/address/' + c.contract + '#code'; $('lnkExplorer').href = c.explorer + '/address/' + c.contract + '#code';
const src = 'https://repo.sourcify.dev/contracts/full_match/' + c.chainId + '/' + c.contract + '/'; const src = 'https://repo.sourcify.dev/contracts/full_match/' + c.chainId + '/' + c.contract + '/';
$('lnkSource').href = src; $('lnkSource').href = src;
$('lnkSource2').href = src; $('lnkSource2').href = src;
} else {
// private rehearsal chain: point at the audited Amoy verification instead
const src = 'https://repo.sourcify.dev/contracts/full_match/80002/0xaF47EC401A9B0D570a553b1A1c95D6079083636e/';
$('lnkExplorer').href = src;
$('lnkSource').href = src;
$('lnkSource2').href = src;
}
})(); })();
+63 -4
View File
@@ -68,9 +68,9 @@
} }
// ── back-office menu: hash-routed panes ─────────────── // ── back-office menu: hash-routed panes ───────────────
const PANES = ['overview', 'line', 'campaigns', 'earnings', 'wallet']; const PANES = ['overview', 'line', 'campaigns', 'earn', 'earnings', 'wallet'];
const TITLES = { overview: 'Overview', line: 'My line', campaigns: 'Campaigns', const TITLES = { overview: 'Overview', line: 'My line', campaigns: 'Campaigns',
earnings: 'Earnings', wallet: 'Wallet & account' }; earn: 'Earn credits', earnings: 'Earnings', wallet: 'Wallet & account' };
function setPane(name) { function setPane(name) {
if (!PANES.includes(name)) name = 'overview'; if (!PANES.includes(name)) name = 'overview';
for (const p of PANES) { for (const p of PANES) {
@@ -113,8 +113,9 @@
$('linkCard').hidden = !!me.address; $('linkCard').hidden = !!me.address;
$('activateCard').hidden = !(me.address && !me.memberId); $('activateCard').hidden = !(me.address && !me.memberId);
$('activityArea').hidden = !me.memberId; $('activityArea').hidden = !me.memberId;
$('campaignCard').hidden = !me.memberId; $('campGate').hidden = true; // earned credits fund campaigns for everyone
if (me.memberId) loadCampaigns(); $('campaignCard').hidden = false;
loadCampaigns();
// the share link works from day one; codes resolve to your chain id later // the share link works from day one; codes resolve to your chain id later
if (me.refCode || me.memberId) { if (me.refCode || me.memberId) {
@@ -159,6 +160,7 @@
const r = await (await fetch('/api/my/campaigns')).json(); const r = await (await fetch('/api/my/campaigns')).json();
if (r.error) return; if (r.error) return;
$('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString() $('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString()
+ (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '')
+ ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch + ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch
+ ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch + ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch
+ ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day'; + ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day';
@@ -200,6 +202,63 @@
// defers the busy() lookup to click time (busy is declared below) // defers the busy() lookup to click time (busy is declared below)
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); } function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
// ── earn-by-viewing: daily ad set with dwell, then claim ──
const earnState = { types: ['banner', 'text'], i: 0, timer: null };
async function earnRefresh() {
try {
const st = await (await fetch('/api/my/earn')).json();
if (st.error) return null;
$('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed';
$('earnBalance').textContent = st.earned + ' earned credits';
const done = st.views >= st.target;
$('earnClaimBtn').hidden = !(done && !st.claimed);
if (st.claimed) $('earnHint').textContent = 'Claimed for today. Come back tomorrow, or put those credits to work in Campaigns.';
else if (done) $('earnHint').textContent = 'Set complete. Claim your ' + st.claimCredits + ' credits.';
return st;
} catch (e) { return null; }
}
async function earnShowAd() {
const st = await earnRefresh();
if (!st || st.views >= st.target) return;
const type = earnState.types[earnState.i++ % earnState.types.length];
let ad = null;
try { ad = (await (await fetch('/api/ads/slot?type=' + type)).json()).ad; } catch (e) {}
const box = $('earnAdBox');
if (!ad) {
box.innerHTML = '<span class="muted small">No member ads are live in rotation right now. '
+ 'Views resume the moment a campaign is running.</span>';
return;
}
box.innerHTML = ad.imageUrl
? '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow"><img src="' + ad.imageUrl + '" alt="member ad" style="max-height:200px"></a>'
: '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow" style="font-size:17px"><b>' + ad.title + '</b>' + (ad.body ? '<br><span class="muted">' + ad.body + '</span>' : '') + '</a>';
const btn = $('earnNextBtn');
btn.hidden = false;
btn.disabled = true;
let left = st.dwell;
btn.textContent = 'Next ad (' + left + 's)';
clearInterval(earnState.timer);
earnState.timer = setInterval(async () => {
left -= 1;
if (left > 0) { btn.textContent = 'Next ad (' + left + 's)'; return; }
clearInterval(earnState.timer);
btn.textContent = 'Next ad';
btn.disabled = false;
await fetch('/api/my/adview', { method: 'POST' }); // dwell served: count it
await earnRefresh();
}, 1000);
}
$('earnStartBtn').addEventListener('click', () => { $('earnStartBtn').hidden = true; earnShowAd(); });
$('earnNextBtn').addEventListener('click', earnShowAd);
$('earnClaimBtn').addEventListener('click', async () => {
try {
const r = await api('/api/my/claim');
IAP.status('+' + r.credited + ' credits earned. Spend them in Campaigns.', 'ok');
await earnRefresh();
loadDashboard();
} catch (e) { IAP.status(e.message, 'bad'); }
});
const busy = (btn, fn) => async () => { const busy = (btn, fn) => async () => {
try { btn.disabled = true; await fn(); } try { btn.disabled = true; await fn(); }
catch (e) { IAP.status((e && e.message) || String(e), 'bad'); } catch (e) { IAP.status((e && e.message) || String(e), 'bad'); }
+4 -4
View File
@@ -5,7 +5,7 @@
<title>The contract | InstantAdPay</title> <title>The contract | InstantAdPay</title>
<meta name="description" content="Plain-language review of the InstantAdPay settlement contract: what it does, what nobody can change, what the operator can and cannot touch, and how to verify all of it yourself."> <meta name="description" content="Plain-language review of the InstantAdPay settlement contract: what it does, what nobody can change, what the operator can and cannot touch, and how to verify all of it yourself.">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905b"> <link rel="stylesheet" href="/assets/site.css?v=20260905d">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
@@ -129,8 +129,8 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div> <div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
</footer> </footer>
</div> </div>
<script src="/assets/common.js?v=20260905b"></script> <script src="/assets/common.js?v=20260905d"></script>
<script src="/assets/contract.js?v=20260905b"></script> <script src="/assets/contract.js?v=20260905d"></script>
<script src="/assets/chat.js?v=20260905b"></script> <script src="/assets/chat.js?v=20260905d"></script>
</body> </body>
</html> </html>
+5 -5
View File
@@ -5,7 +5,7 @@
<title>InstantAdPay: advertise and earn, locked in code</title> <title>InstantAdPay: advertise and earn, locked in code</title>
<meta name="description" content="Real ad packages with instant on-chain settlement. Every purchase pays the sponsor line in the same transaction, verifiable by anyone on the live ledger."> <meta name="description" content="Real ad packages with instant on-chain settlement. Every purchase pays the sponsor line in the same transaction, verifiable by anyone on the live ledger.">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905b"> <link rel="stylesheet" href="/assets/site.css?v=20260905d">
</head> </head>
<body> <body>
@@ -399,9 +399,9 @@
</div> </div>
</section> </section>
<script src="/assets/common.js?v=20260905b"></script> <script src="/assets/common.js?v=20260905d"></script>
<script src="/assets/wallet.js?v=20260905b"></script> <script src="/assets/wallet.js?v=20260905d"></script>
<script src="/assets/home.js?v=20260905b"></script> <script src="/assets/home.js?v=20260905d"></script>
<script src="/assets/chat.js?v=20260905b"></script> <script src="/assets/chat.js?v=20260905d"></script>
</body> </body>
</html> </html>
+4 -4
View File
@@ -5,7 +5,7 @@
<title>Live ledger | InstantAdPay</title> <title>Live ledger | InstantAdPay</title>
<meta name="description" content="Every purchase, payout, and pass-up on InstantAdPay, streamed straight from the blockchain with a verify link on every line."> <meta name="description" content="Every purchase, payout, and pass-up on InstantAdPay, streamed straight from the blockchain with a verify link on every line.">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905b"> <link rel="stylesheet" href="/assets/site.css?v=20260905d">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
@@ -25,8 +25,8 @@
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div> <div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
</footer> </footer>
</div> </div>
<script src="/assets/common.js?v=20260905b"></script> <script src="/assets/common.js?v=20260905d"></script>
<script src="/assets/ledger.js?v=20260905b"></script> <script src="/assets/ledger.js?v=20260905d"></script>
<script src="/assets/chat.js?v=20260905b"></script> <script src="/assets/chat.js?v=20260905d"></script>
</body> </body>
</html> </html>
+27 -5
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Member area | InstantAdPay</title> <title>Member area | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905b"> <link rel="stylesheet" href="/assets/site.css?v=20260905d">
</head> </head>
<body class="bo-body"> <body class="bo-body">
@@ -55,6 +55,7 @@
<button data-pane="overview" class="on" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="8" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/><rect x="13" y="13" width="8" height="8" rx="2"/></svg>Overview</button> <button data-pane="overview" class="on" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="8" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/><rect x="13" y="13" width="8" height="8" rx="2"/></svg>Overview</button>
<button data-pane="line" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>My line</button> <button data-pane="line" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>My line</button>
<button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M3 11l14-5v12L3 13v-2z"/><path d="M17 8a4 4 0 0 1 0 8M7 13v5a2 2 0 0 0 4 0v-3"/></svg>Campaigns</button> <button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M3 11l14-5v12L3 13v-2z"/><path d="M17 8a4 4 0 0 1 0 8M7 13v5a2 2 0 0 0 4 0v-3"/></svg>Campaigns</button>
<button data-pane="earn" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5v9M9 10c0-1.1 1.3-1.8 3-1.8s3 .7 3 1.8-1.3 1.6-3 1.8-3 .7-3 1.8 1.3 1.8 3 1.8 3-.7 3-1.8"/></svg>Earn credits</button>
<button data-pane="earnings" type="button"><svg viewBox="0 0 24 24"><path d="M4 17l5-5 4 3 7-8"/><path d="M14 7h6v6"/></svg>Earnings</button> <button data-pane="earnings" type="button"><svg viewBox="0 0 24 24"><path d="M4 17l5-5 4 3 7-8"/><path d="M14 7h6v6"/></svg>Earnings</button>
<button data-pane="wallet" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><circle cx="16.5" cy="14.5" r="1.4"/></svg>Wallet</button> <button data-pane="wallet" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><circle cx="16.5" cy="14.5" r="1.4"/></svg>Wallet</button>
</nav> </nav>
@@ -155,6 +156,27 @@
</div> </div>
</div> </div>
<div class="pane" id="pane-earn" hidden>
<div class="card">
<h3>Earn credits by viewing ads</h3>
<p class="muted small">View today's set of member ads, each for a few seconds, and claim your
daily credits. Attention earns advertising here: your earned credits spend on banner and text
campaigns, just like purchased ones.</p>
<p><span class="badge" id="earnProgress">…</span>
<span class="badge" id="earnBalance">…</span></p>
<div class="card" style="background:rgba(4,8,7,.5)" id="earnStage">
<div id="earnAdBox" class="small muted" style="min-height:120px;display:grid;place-items:center">
Press start to see the first ad.</div>
<p style="margin-top:14px">
<button class="btn" id="earnStartBtn" type="button">Start viewing</button>
<button class="btn" id="earnNextBtn" type="button" hidden disabled>Next ad</button>
<button class="btn" id="earnClaimBtn" type="button" hidden>Claim today's credits</button>
</p>
<p class="small muted" id="earnHint"></p>
</div>
</div>
</div>
<div class="pane" id="pane-earnings" hidden> <div class="pane" id="pane-earnings" hidden>
<div id="activityArea" hidden> <div id="activityArea" hidden>
<div class="grid c2"> <div class="grid c2">
@@ -208,9 +230,9 @@
</div> </div>
</div> </div>
<script src="/assets/common.js?v=20260905b"></script> <script src="/assets/common.js?v=20260905d"></script>
<script src="/assets/wallet.js?v=20260905b"></script> <script src="/assets/wallet.js?v=20260905d"></script>
<script src="/assets/my.js?v=20260905b"></script> <script src="/assets/my.js?v=20260905d"></script>
<script src="/assets/chat.js?v=20260905b"></script> <script src="/assets/chat.js?v=20260905d"></script>
</body> </body>
</html> </html>
+21 -3
View File
@@ -345,6 +345,23 @@ const server = http.createServer(async (req, res) => {
})); }));
return json(res, 200, out); return json(res, 200, out);
} }
// -- earn credits by viewing ads (attention-gated daily claim)
if (p === '/api/my/earn' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await ads.viewStatus(s.email));
}
if (p === '/api/my/adview' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await ads.recordView(s.email));
}
if (p === '/api/my/claim' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = await ads.claimDaily(s.email);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/my/activity' && req.method === 'GET') { if (p === '/api/my/activity' && req.method === 'GET') {
const s = await auth.fromRequest(req); const s = await auth.fromRequest(req);
if (!s) return json(res, 401, { error: 'Sign in first.' }); if (!s) return json(res, 401, { error: 'Sign in first.' });
@@ -376,14 +393,15 @@ const server = http.createServer(async (req, res) => {
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s); const memberId = await auth.refreshMemberId(s);
const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates() }; const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates() };
out.availableCredits = memberId ? await ads.availableCredits(memberId) : 0; out.purchasedCredits = memberId ? await ads.availableCredits(memberId) : 0;
out.earnedCredits = await ads.earnedBalance(s.email);
out.availableCredits = out.purchasedCredits + out.earnedCredits;
return json(res, 200, out); return json(res, 200, out);
} }
if (p === '/api/my/campaigns' && req.method === 'POST') { if (p === '/api/my/campaigns' && req.method === 'POST') {
const s = await auth.fromRequest(req); const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s); const memberId = await auth.refreshMemberId(s); // 0 is fine: earned credits fund banner/text
if (!memberId) return json(res, 400, { error: 'Buy an ad package first. Campaigns spend the on-chain credits it mints.' });
const b = await readBody(req); const b = await readBody(req);
const r = await ads.createCampaign(s.email, memberId, b); const r = await ads.createCampaign(s.email, memberId, b);
return json(res, r.error ? 400 : 200, r); return json(res, r.error ? 400 : 200, r);