Referral rewards: pay a hunter for bringing someone who actually hunts

PolHunter retains well and recruits badly. On 2026-09-22 it had its best day,
105 finds from 37 hunters, and had sent three people to InstantAdPay in its
whole life, all three on one member's link. Earning and sharing were separate
actions and only one of them paid.

Bringing someone is now the best-paid thing on the board, and the card sits
first in the mission list rather than on a promo page nobody opens. A flat 3
POL the first time someone they brought finds a code, then 20% on top of that
person's finds for their first 30 days, paid from the pool and never taken out
of the newcomer's drip.

Who counts as "brought by me" is the sponsor they actually joined
InstantAdPay under, handed over in the sign-in payload, so the bounty and the
commission always land on the same person. The bounty only pays for a member
who is genuinely new, once per person, and referral drips have their own daily
ceiling so a good referral day can never starve the finds.

Also: findsToday defaulted its day argument to undefined and silently returned
zero for every caller that omitted it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-23 04:34:22 -05:00
parent bed75d1b0f
commit 1c9e99f91e
7 changed files with 231 additions and 11 deletions
+14 -4
View File
@@ -20,6 +20,7 @@ const missions = require('./lib/missions');
const rewards = require('./lib/rewards');
const social = require('./lib/social');
const badge = require('./lib/badge');
const referrals = require('./lib/referrals'); // pays a hunter for bringing someone who turns up and hunts
const faucet = require('./lib/faucet');
const PORT = Number(process.env.PORT || 3000);
@@ -71,6 +72,8 @@ async function notifyPaid(p) {
async function notifyOther(kind, p) {
if (kind === 'low') return telegram('⚠️ <b>PolHunter faucet is low</b>: ' + fmt(p.balance) + ' POL left in ' + p.address + ' (alert threshold ' + fmt(p.threshold) + '). Top up from Receiver B.');
if (kind === 'failed') return telegram('❌ <b>PolHunter</b> · drip to #' + p.memberId + ' failed: ' + p.error);
// a bounty is the one payout that advertises the mission itself, so it goes to the room
if (kind === 'referral') { const d = p.drip; return telegram('\u{1F91D} <b>PolHunter</b> · ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' brought ' + d.ref.forName + ' in and they are hunting · <b>' + fmt(d.pol) + ' POL</b> bounty on the way\n<a href="' + SITE + '">Bring yours</a>'); }
if (kind === 'prize') { const medal = ['\u{1F947}', '\u{1F948}', '\u{1F949}']; return telegram('\u{1F3C6} <b>PolHunter weekly prizes</b> for the week of ' + p.week + '\n' + p.winners.map(w => (medal[w.rank - 1] || '#' + w.rank) + ' ' + w.who + ' \u00b7 ' + w.finds + ' finds \u00b7 <b>' + fmt(w.prizePol) + ' POL</b>').join('\n') + '\n<a href="' + SITE + '/leaders">Leaderboard</a>'); }
return false;
}
@@ -198,6 +201,7 @@ const server = http.createServer(async (req, res) => {
const cur = sso.fromRequest(req);
let sid = cur && cur.memberId === Number(v.claims.memberId) ? cur.sid : null;
if (sid) sso.refresh(sid, v.claims); else sid = sso.startSession(v.claims);
referrals.seen(v.claims); // keep the hunter directory (and the sponsor behind it) current on every sign-in
res.writeHead(302, { Location: '/app', 'Set-Cookie': sso.cookie(sid), 'Cache-Control': 'no-store' }); return res.end();
}
if (p === '/logout') { const s = sso.fromRequest(req); if (s) sso.endSession(s.sid); res.writeHead(302, { Location: '/', 'Set-Cookie': sso.clearCookie() }); return res.end(); }
@@ -209,16 +213,18 @@ const server = http.createServer(async (req, res) => {
if (p === '/api/prizes') return json(res, 200, social.prizes(), { 'Cache-Control': 'public, max-age=60' });
if (p === '/leaders') return sendFile(res, path.join(PUBLIC_DIR, 'leaders.html'));
if (p === '/promo') return sendFile(res, path.join(PUBLIC_DIR, 'promo.html'));
if (p === '/api/ledger') { const t = rewards.totals(); return json(res, 200, { totals: t, recent: rewards.ledger(30).map(x => ({ who: x.username ? '@' + x.username : '#' + x.memberId, site: x.site, pol: x.pol, tx: x.tx, at: x.paidAt })) }); }
if (p === '/api/ledger') { const t = rewards.totals(); return json(res, 200, { totals: t, referrals: referrals.totals(), pools: { finds: rewards.pool(), referral: referrals.refPool() }, recent: rewards.ledger(30).map(x => ({ who: x.username ? '@' + x.username : '#' + x.memberId, site: x.site, pol: x.pol, tx: x.tx, at: x.paidAt })) }); }
// ---- hunter (session required)
if (p.startsWith('/api/my/')) {
const me = sso.fromRequest(req); if (!me) return json(res, 401, { error: 'Open PolHunter from your InstantAdPay dashboard to sign in.' });
if (p === '/api/my/board') {
referrals.seen(me); // hunters already signed in never pass through /auth again, so keep the directory fresh here too
const done = rewards.doneToday(me.memberId, me.wallet); const wdone = done; // today's finds, by member id or wallet
return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn }, pool: rewards.pool(), daily: rewards.daily(me.memberId),
badges: social.badgesFor(me.memberId), badgeCards: (() => { const got = new Set(social.badgesFor(me.memberId).map(b => b.id)); const who = social.nameOf(me); return social.BADGES.map(b => ({ id: b.id, name: b.name, icon: b.icon, why: b.why, art: '/badges/badge-' + b.id + '.jpg', earned: got.has(b.id), page: got.has(b.id) ? SITE + '/b/' + who + '/' + b.id : null, image: got.has(b.id) ? SITE + '/badge-img/' + who + '/' + b.id + '.jpg' : null })); })(), rank: { week: social.rankOf(me.memberId, 'week'), month: social.rankOf(me.memberId, 'month'), all: social.rankOf(me.memberId, 'all') },
share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) + '?from=polhunter' } });
share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) + '?from=polhunter' },
referral: referrals.state(me) });
}
if (p === '/api/my/start' && req.method === 'POST') {
const b = await readBody(req); const m = missions.get(String(b.missionId || '')); if (!m || !m.active) return json(res, 404, { error: 'That mission is not open.' });
@@ -244,6 +250,10 @@ const server = http.createServer(async (req, res) => {
{ const d = rewards.daily(me.memberId); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); }
const before = new Set(social.badgesFor(me.memberId).map(b => b.id));
const g = rewards.grant(me, m); if (g.error) return json(res, 400, g);
// whoever brought this hunter gets paid for it: the bounty on their first find, a match while they are new
let refDrips = [];
try { refDrips = referrals.onFind(me, g.rec); } catch (e) { console.error('referral', e.message); }
for (const d of refDrips) if (d.ref.kind === 'bounty') notify('referral', { drip: d }).catch(() => {});
// achievements unlocked by this find: told to the board, posted to Telegram (gated)
const unlocked = social.badgesFor(me.memberId).filter(b => !before.has(b.id));
for (const b of unlocked) notify('badge', { me, id: b.id }).catch(() => {});
@@ -255,7 +265,7 @@ const server = http.createServer(async (req, res) => {
// ---- admin (key)
if (p.startsWith('/api/admin/')) {
if (!admin(req)) return json(res, 401, { error: 'Admin key required.' });
if (p === '/api/admin/state') return json(res, 200, { missions: missions.list(), settings: rewards.settings(), totals: rewards.totals(), faucet: { on: faucetOn, address: faucet.address(), state: store.read('faucet-state', {}) }, payouts: store.read('payouts', []).slice(-100).reverse(), gates: { outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN, sso: sso.enabled() } });
if (p === '/api/admin/state') return json(res, 200, { missions: missions.list(), settings: rewards.settings(), totals: rewards.totals(), referrals: referrals.totals(), referralPool: referrals.refPool(), faucet: { on: faucetOn, address: faucet.address(), state: store.read('faucet-state', {}) }, payouts: store.read('payouts', []).slice(-100).reverse(), gates: { outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN, sso: sso.enabled() } });
if (p === '/api/admin/mission' && req.method === 'POST') {
const b = await readBody(req);
const id = String(b.id || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40); if (!id) return json(res, 400, { error: 'id required' });
@@ -266,7 +276,7 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { ok: true, missions: missions.list() });
}
if (p === '/api/admin/mission' && req.method === 'DELETE') { const b = await readBody(req); missions.remove(String(b.id || '')); return json(res, 200, { ok: true, missions: missions.list() }); }
if (p === '/api/admin/settings' && req.method === 'POST') { const b = await readBody(req); const patch = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol', 'weeklyMinFinds', 'drawSkew', 'missionsPerDay']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); for (const k of ['weeklyPrizes', 'leaderboardExclude']) if (Array.isArray(b[k])) patch[k] = b[k].map(Number).filter(n => n >= 0); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); }
if (p === '/api/admin/settings' && req.method === 'POST') { const b = await readBody(req); const patch = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol', 'weeklyMinFinds', 'drawSkew', 'missionsPerDay', 'refEnabled', 'refBountyPol', 'refMatchPct', 'refMatchDays', 'refNewDays', 'refCapPol', 'refMatchMinPol']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); for (const k of ['weeklyPrizes', 'leaderboardExclude']) if (Array.isArray(b[k])) patch[k] = b[k].map(Number).filter(n => n >= 0); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); }
// award a week by hand (its Monday key); already-awarded weeks are skipped
if (p === '/api/admin/prizes/award' && req.method === 'POST') { const b = await readBody(req); const r = social.awardWeek(String(b.week || '')); if (r && r.winners && r.winners.length) notify('prize', r).catch(() => {}); return json(res, r && r.error ? 400 : 200, r); }
if (p === '/api/admin/drip/retry' && req.method === 'POST') { const b = await readBody(req); rewards.mark(String(b.id || ''), { status: 'due', error: null }); return json(res, 200, { ok: true }); }