Files
polhunter/lib/referrals.js
T
martbost cca90bdd00 Referrals only pay for people who did not exist before
The new-member gate was refNewDays: 30, a rolling window, on a site whose
oldest account is 19 days old. It therefore excluded nobody. Every referral
payment made today, 8.67 POL across nine people, went to a sponsor for an
existing member hunting. Not one was an acquisition, which is the entire
purpose of the mission.

The gate is now an absolute date, refNewAfter, set to the morning the mission
went live. Anyone whose account predates that moment was already a member by
definition and can never earn their sponsor a bounty, a match or the launch
bonus, however much they hunt. The 30-day window stays as a secondary rule so a
dormant signup cannot trigger a bounty a year later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:30:04 -05:00

202 lines
10 KiB
JavaScript

// 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 };