diff --git a/lib/referrals.js b/lib/referrals.js index eddcdc9..7c1205e 100644 --- a/lib/referrals.js +++ b/lib/referrals.js @@ -1,201 +1,206 @@ -// 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 - // THE gate, and it has to be an absolute date, not a rolling window. It was refNewDays: 30 on a - // site whose oldest account is 19 days old, so it excluded nobody and 8.67 POL went to sponsors - // for existing members hunting — zero acquisitions (Marty, 2026-09-23: "I need people that never - // existed before. That's why we're doing it."). Anyone whose account predates this moment was - // already a member, by definition, and can never earn their sponsor a bounty or a match. - refNewAfter: '2026-09-23T10:00:00Z', - refNewDays: 30, // secondary: a newcomer only earns their sponsor a bounty in their first - // month, so a dormant signup cannot trigger one a year later - refCapPol: 20, // referral pool per Central day, separate from the find pool - refMatchMinPol: 0.005,// do not write a transaction for dust - // Marty's launch bonus (2026-09-23): an extra drip to the FIRST hunter whose referral gets all - // the way through. Once ever, never again. Set to 0 to retire it. - refFirstBonusPol: 5, -}; -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' : ref.kind === 'first-bonus' ? 'First referral bonus' : '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; - // the floor: did this person exist before the mission did? - const floor = Date.parse(String(c.refNewAfter || '')) || 0; - const isNew = !floor || (me.joinedAt && Number(me.joinedAt) >= floor); - if (!isNew) return []; // an existing member hunting earns their sponsor nothing, ever - 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) { - const firstEver = !refRows().some(p => p.ref.kind === 'bounty'); - out.push(write(referrer, pol, Object.assign({ kind: 'bounty' }, who))); room -= pol; - // the launch bonus rides along with the very first bounty anyone earns, once ever. It is - // deliberately outside the daily referral ceiling: it is a single payment, not a rate. - const bonus = Number(c.refFirstBonusPol) || 0; - if (firstEver && bonus > 0 && !refRows().some(p => p.ref.kind === 'first-bonus')) { - out.push(write(referrer, bonus, Object.assign({ kind: 'first-bonus' }, who))); - } - } - } - // 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), - firstBonusPol: refRows().some(p => p.ref.kind === 'first-bonus') ? 0 : Number(c.refFirstBonusPol) || 0, - 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, - firstBonus: (() => { const b = rows.find(p => p.ref.kind === 'first-bonus'); return b ? { who: b.username || ('#' + b.memberId), pol: b.pol, at: b.at, status: b.status } : null; })(), - 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 }; +// 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 + // THE gate, and it has to be an absolute date, not a rolling window. It was refNewDays: 30 on a + // site whose oldest account is 19 days old, so it excluded nobody and 8.67 POL went to sponsors + // for existing members hunting — zero acquisitions (Marty, 2026-09-23: "I need people that never + // existed before. That's why we're doing it."). Anyone whose account predates this moment was + // already a member, by definition, and can never earn their sponsor a bounty or a match. + refNewAfter: '2026-09-23T10:00:00Z', + refNewDays: 30, // secondary: a newcomer only earns their sponsor a bounty in their first + // month, so a dormant signup cannot trigger one a year later + refCapPol: 20, // referral pool per Central day, separate from the find pool + refMatchMinPol: 0.005,// do not write a transaction for dust + // Marty's launch bonus (2026-09-23): an extra drip to the FIRST hunter whose referral gets all + // the way through. Once ever, never again. Set to 0 to retire it. + refFirstBonusPol: 5, +}; +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' : ref.kind === 'first-bonus' ? 'First referral bonus' : '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; + // the floor: did this person exist before the mission did? + // This gate FAILS CLOSED and must stay that way. No floor configured, a blank or mistyped + // date, or a member with no InstantAdPay join date on file: nobody gets paid. The last version + // of this line paid out whenever the setting did not exclude anyone, which is how 8.67 POL went + // to sponsors of members who already existed. A referral only counts if the person did not + // exist in InstantAdPay before the mission did. + const floor = Date.parse(String(c.refNewAfter || '')); + if (!Number.isFinite(floor) || floor <= 0) return []; + if (!me.joinedAt || Number(me.joinedAt) < floor) return []; + 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) { + const firstEver = !refRows().some(p => p.ref.kind === 'bounty'); + out.push(write(referrer, pol, Object.assign({ kind: 'bounty' }, who))); room -= pol; + // the launch bonus rides along with the very first bounty anyone earns, once ever. It is + // deliberately outside the daily referral ceiling: it is a single payment, not a rate. + const bonus = Number(c.refFirstBonusPol) || 0; + if (firstEver && bonus > 0 && !refRows().some(p => p.ref.kind === 'first-bonus')) { + out.push(write(referrer, bonus, Object.assign({ kind: 'first-bonus' }, who))); + } + } + } + // 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), + firstBonusPol: refRows().some(p => p.ref.kind === 'first-bonus') ? 0 : Number(c.refFirstBonusPol) || 0, + 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, + firstBonus: (() => { const b = rows.find(p => p.ref.kind === 'first-bonus'); return b ? { who: b.username || ('#' + b.memberId), pol: b.pol, at: b.at, status: b.status } : null; })(), + 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 };