// Referral rewards (Marty, 2026-09-23). // // PolHunter retains well and recruits badly: on 2026-09-22 it had its best day ever, 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. This makes // bringing someone the best-paid thing on the board. // // Two payments, both to the REFERRER, both out of a pool of their own so a good referral day can // never starve the finds: // bounty a flat drip the first time someone they brought completes a find, paid once per person // match a slice of every find that person makes while they are still new // // Who counts as "brought by me" is not a cookie PolHunter guessed at. It is the sponsor the member // actually joined InstantAdPay under, handed over in the sign-in payload as sponsorRef, so the // bounty and the InstantAdPay commission always land on the same person. 'use strict'; const store = require('./store'); const rewards = require('./rewards'); const { ctDay } = require('./missions'); const DEFAULTS = { refEnabled: 1, refBountyPol: 3, // flat, not a draw: a referral is work someone did, not a lottery refMatchPct: 20, // paid ON TOP of the newcomer's find, never taken out of it refMatchDays: 30, // how long the match runs, from the newcomer's InstantAdPay join date refNewDays: 30, // the bounty only pays for a genuinely new member, not an old one who // happens to start hunting years after their sponsor signed them up refCapPol: 20, // referral pool per Central day, separate from the find pool refMatchMinPol: 0.005,// do not write a transaction for dust }; function cfg() { return Object.assign({}, DEFAULTS, store.read('settings', {})); } const round4 = n => Math.round(Number(n) * 10000) / 10000; const norm = s => String(s || '').trim().toLowerCase(); // ---- the hunter directory ------------------------------------------------------------------- // Every sign-in upserts the member here, so the referral graph survives session expiry and we // always have a wallet to pay the referrer at. function seen(claims) { const id = Number(claims.memberId); if (!id) return null; const all = store.update('hunters', {}, h => { const cur = h[id] || { memberId: id, firstSeen: Date.now() }; if (claims.email) cur.email = norm(claims.email); if (claims.username) cur.username = String(claims.username); if (claims.wallet) cur.wallet = norm(claims.wallet); if (claims.sponsorRef) cur.sponsorRef = norm(claims.sponsorRef); if (claims.joinedAt) cur.joinedAt = Number(claims.joinedAt); cur.lastSeen = Date.now(); h[id] = cur; return h; }); return all[id]; } function hunter(memberId) { return store.read('hunters', {})[Number(memberId)] || null; } function keysOf(h) { return [norm(h.username), String(h.memberId)].filter(Boolean); } // the referrer has to be a hunter themselves: we pay into a wallet we have seen, and nobody // earns from a board they have never opened function referrerOf(memberId) { const me = hunter(memberId); if (!me || !me.sponsorRef) return null; const all = store.read('hunters', {}); for (const k of Object.keys(all)) { const x = all[k]; if (Number(x.memberId) === Number(memberId)) continue; if (keysOf(x).includes(me.sponsorRef)) return x; } return null; } function broughtBy(memberId) { const me = hunter(memberId); if (!me) return []; const keys = keysOf(me); return Object.values(store.read('hunters', {})) .filter(x => Number(x.memberId) !== Number(memberId) && x.sponsorRef && keys.includes(x.sponsorRef)) .sort((a, b) => (a.firstSeen || 0) - (b.firstSeen || 0)); } // ---- the pool ------------------------------------------------------------------------------- const refRows = () => store.read('payouts', []).filter(p => p.ref && p.status !== 'failed'); function paidRefToday(day) { return refRows().filter(p => p.day === (day || ctDay())).reduce((n, p) => n + p.pol, 0); } function refPool() { const c = cfg(); const cap = Number(c.refCapPol), today = round4(paidRefToday()); return { capPol: cap, todayPol: today, left: round4(Math.max(0, cap - today)), resetsAt: rewards.nextResetAt() }; } function write(referrer, pol, ref) { const rec = { id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8), memberId: Number(referrer.memberId), email: referrer.email || null, wallet: referrer.wallet, username: referrer.username || null, missionId: 'ref:' + ref.kind, site: ref.kind === 'bounty' ? 'Referral bounty' : 'Referral match', pol: round4(pol), day: ctDay(), at: Date.now(), status: 'due', tx: null, paidAt: null, error: null, ref, }; store.update('payouts', [], all => { all.push(rec); return all; }); return rec; } const bountyPaidFor = id => refRows().some(p => p.ref.kind === 'bounty' && Number(p.ref.forMemberId) === Number(id)); // ---- the hook: called right after a find is recorded ------------------------------------------ // Returns the drips it created, so the caller can post the bounty to Telegram. function onFind(member, find) { const c = cfg(); if (String(c.refEnabled) !== '1') return []; const id = Number(member.memberId); const me = hunter(id) || {}; const referrer = referrerOf(id); if (!referrer || !referrer.wallet || Number(referrer.memberId) === id) return []; const out = []; let room = Number(c.refCapPol) - paidRefToday(); const ageDays = me.joinedAt ? (Date.now() - Number(me.joinedAt)) / 86400000 : 0; const who = { forMemberId: id, forName: me.username || ('#' + id) }; // the bounty, once, on their first find, and only while they are genuinely a new member const finds = store.read('payouts', []).filter(p => Number(p.memberId) === id && !p.ref && !p.prize && p.status !== 'failed'); if (finds.length <= 1 && ageDays <= Number(c.refNewDays) && !bountyPaidFor(id)) { const pol = Number(c.refBountyPol); if (pol <= room) { out.push(write(referrer, pol, Object.assign({ kind: 'bounty' }, who))); room -= pol; } } // the match, on every find inside the window, paid on top and never deducted from the newcomer if (ageDays <= Number(c.refMatchDays)) { const pol = round4(Number(find.pol) * Number(c.refMatchPct) / 100); if (pol >= Number(c.refMatchMinPol) && pol <= room) out.push(write(referrer, pol, Object.assign({ kind: 'match', findId: find.id, findPol: find.pol }, who))); } return out; } // ---- what the board shows --------------------------------------------------------------------- function state(member) { const c = cfg(); const id = Number(member.memberId); const me = hunter(id) || { memberId: id }; const rows = refRows().filter(p => Number(p.memberId) === id); const today = ctDay(); const mine = broughtBy(id).map(x => { const theirFinds = store.read('payouts', []).filter(p => Number(p.memberId) === Number(x.memberId) && !p.ref && !p.prize && p.status !== 'failed'); const paid = bountyPaidFor(x.memberId); return { name: x.username || ('#' + x.memberId), // when they actually joined InstantAdPay, not when this directory first noticed them: // the backfill stamped firstSeen at seeding time and made everyone look brought-in today joinedAt: x.joinedAt || x.firstSeen || null, finds: theirFinds.length, state: paid ? 'earning' : theirFinds.length ? 'hunting' : 'signed up, no find yet', stillNew: x.joinedAt ? (Date.now() - Number(x.joinedAt)) / 86400000 <= Number(c.refMatchDays) : false, }; }); return { on: String(c.refEnabled) === '1', bountyPol: Number(c.refBountyPol), matchPct: Number(c.refMatchPct), matchDays: Number(c.refMatchDays), brought: mine.length, broughtToday: mine.filter(x => x.joinedAt && ctDay(x.joinedAt) === today).length, earnedPol: round4(rows.reduce((n, p) => n + p.pol, 0)), earnedTodayPol: round4(rows.filter(p => p.day === today).reduce((n, p) => n + p.pol, 0)), people: mine.slice(0, 25), pool: refPool(), }; } function totals() { const rows = refRows(); const paid = rows.filter(p => p.status === 'paid'); return { bounties: rows.filter(p => p.ref.kind === 'bounty').length, matches: rows.filter(p => p.ref.kind === 'match').length, pol: round4(rows.reduce((n, p) => n + p.pol, 0)), paidPol: round4(paid.reduce((n, p) => n + p.pol, 0)), referrers: new Set(rows.map(p => p.memberId)).size, }; } module.exports = { seen, hunter, referrerOf, broughtBy, onFind, state, totals, refPool, cfg, paidRefToday };