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