Referral gate fails closed: a broken setting pays nobody

The rule is that a bounty is only ever earned by somebody who did not exist in
InstantAdPay before the mission did. The check was written so that an absent or
unparseable refNewAfter meant "no floor, pay everyone", which is the same shape
as the rolling window that paid 8.67 POL for members who already existed.

Now a blank, mistyped, zero or missing floor pays nobody at all, and so does a
member with no InstantAdPay join date on file. The gate can only ever be opened
on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-23 11:36:59 -05:00
parent cca90bdd00
commit d04474fd7b
+206 -201
View File
@@ -1,201 +1,206 @@
// Referral rewards (Marty, 2026-09-23). // Referral rewards (Marty, 2026-09-23).
// //
// PolHunter retains well and recruits badly: on 2026-09-22 it had its best day ever, 105 finds // 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 // 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 // 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. // 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 // Two payments, both to the REFERRER, both out of a pool of their own so a good referral day can
// never starve the finds: // never starve the finds:
// bounty a flat drip the first time someone they brought completes a find, paid once per person // 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 // 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 // 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 // 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. // bounty and the InstantAdPay commission always land on the same person.
'use strict'; 'use strict';
const store = require('./store'); const store = require('./store');
const rewards = require('./rewards'); const rewards = require('./rewards');
const { ctDay } = require('./missions'); const { ctDay } = require('./missions');
const DEFAULTS = { const DEFAULTS = {
refEnabled: 1, refEnabled: 1,
refBountyPol: 3, // flat, not a draw: a referral is work someone did, not a lottery 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 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 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 // 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 // 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 // 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 // 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. // already a member, by definition, and can never earn their sponsor a bounty or a match.
refNewAfter: '2026-09-23T10:00:00Z', refNewAfter: '2026-09-23T10:00:00Z',
refNewDays: 30, // secondary: a newcomer only earns their sponsor a bounty in their first 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 // month, so a dormant signup cannot trigger one a year later
refCapPol: 20, // referral pool per Central day, separate from the find pool refCapPol: 20, // referral pool per Central day, separate from the find pool
refMatchMinPol: 0.005,// do not write a transaction for dust 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 // 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. // the way through. Once ever, never again. Set to 0 to retire it.
refFirstBonusPol: 5, refFirstBonusPol: 5,
}; };
function cfg() { return Object.assign({}, DEFAULTS, store.read('settings', {})); } function cfg() { return Object.assign({}, DEFAULTS, store.read('settings', {})); }
const round4 = n => Math.round(Number(n) * 10000) / 10000; const round4 = n => Math.round(Number(n) * 10000) / 10000;
const norm = s => String(s || '').trim().toLowerCase(); const norm = s => String(s || '').trim().toLowerCase();
// ---- the hunter directory ------------------------------------------------------------------- // ---- the hunter directory -------------------------------------------------------------------
// Every sign-in upserts the member here, so the referral graph survives session expiry and we // 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. // always have a wallet to pay the referrer at.
function seen(claims) { function seen(claims) {
const id = Number(claims.memberId); const id = Number(claims.memberId);
if (!id) return null; if (!id) return null;
const all = store.update('hunters', {}, h => { const all = store.update('hunters', {}, h => {
const cur = h[id] || { memberId: id, firstSeen: Date.now() }; const cur = h[id] || { memberId: id, firstSeen: Date.now() };
if (claims.email) cur.email = norm(claims.email); if (claims.email) cur.email = norm(claims.email);
if (claims.username) cur.username = String(claims.username); if (claims.username) cur.username = String(claims.username);
if (claims.wallet) cur.wallet = norm(claims.wallet); if (claims.wallet) cur.wallet = norm(claims.wallet);
if (claims.sponsorRef) cur.sponsorRef = norm(claims.sponsorRef); if (claims.sponsorRef) cur.sponsorRef = norm(claims.sponsorRef);
if (claims.joinedAt) cur.joinedAt = Number(claims.joinedAt); if (claims.joinedAt) cur.joinedAt = Number(claims.joinedAt);
cur.lastSeen = Date.now(); cur.lastSeen = Date.now();
h[id] = cur; h[id] = cur;
return h; return h;
}); });
return all[id]; return all[id];
} }
function hunter(memberId) { return store.read('hunters', {})[Number(memberId)] || null; } function hunter(memberId) { return store.read('hunters', {})[Number(memberId)] || null; }
function keysOf(h) { return [norm(h.username), String(h.memberId)].filter(Boolean); } 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 // 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 // earns from a board they have never opened
function referrerOf(memberId) { function referrerOf(memberId) {
const me = hunter(memberId); const me = hunter(memberId);
if (!me || !me.sponsorRef) return null; if (!me || !me.sponsorRef) return null;
const all = store.read('hunters', {}); const all = store.read('hunters', {});
for (const k of Object.keys(all)) { for (const k of Object.keys(all)) {
const x = all[k]; const x = all[k];
if (Number(x.memberId) === Number(memberId)) continue; if (Number(x.memberId) === Number(memberId)) continue;
if (keysOf(x).includes(me.sponsorRef)) return x; if (keysOf(x).includes(me.sponsorRef)) return x;
} }
return null; return null;
} }
function broughtBy(memberId) { function broughtBy(memberId) {
const me = hunter(memberId); const me = hunter(memberId);
if (!me) return []; if (!me) return [];
const keys = keysOf(me); const keys = keysOf(me);
return Object.values(store.read('hunters', {})) return Object.values(store.read('hunters', {}))
.filter(x => Number(x.memberId) !== Number(memberId) && x.sponsorRef && keys.includes(x.sponsorRef)) .filter(x => Number(x.memberId) !== Number(memberId) && x.sponsorRef && keys.includes(x.sponsorRef))
.sort((a, b) => (a.firstSeen || 0) - (b.firstSeen || 0)); .sort((a, b) => (a.firstSeen || 0) - (b.firstSeen || 0));
} }
// ---- the pool ------------------------------------------------------------------------------- // ---- the pool -------------------------------------------------------------------------------
const refRows = () => store.read('payouts', []).filter(p => p.ref && p.status !== 'failed'); 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 paidRefToday(day) { return refRows().filter(p => p.day === (day || ctDay())).reduce((n, p) => n + p.pol, 0); }
function refPool() { function refPool() {
const c = cfg(); const c = cfg();
const cap = Number(c.refCapPol), today = round4(paidRefToday()); const cap = Number(c.refCapPol), today = round4(paidRefToday());
return { capPol: cap, todayPol: today, left: round4(Math.max(0, cap - today)), resetsAt: rewards.nextResetAt() }; return { capPol: cap, todayPol: today, left: round4(Math.max(0, cap - today)), resetsAt: rewards.nextResetAt() };
} }
function write(referrer, pol, ref) { function write(referrer, pol, ref) {
const rec = { const rec = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8), 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, memberId: Number(referrer.memberId), email: referrer.email || null, wallet: referrer.wallet, username: referrer.username || null,
missionId: 'ref:' + ref.kind, missionId: 'ref:' + ref.kind,
site: ref.kind === 'bounty' ? 'Referral bounty' : ref.kind === 'first-bonus' ? 'First referral bonus' : 'Referral match', site: ref.kind === 'bounty' ? 'Referral bounty' : ref.kind === 'first-bonus' ? 'First referral bonus' : 'Referral match',
pol: round4(pol), day: ctDay(), at: Date.now(), pol: round4(pol), day: ctDay(), at: Date.now(),
status: 'due', tx: null, paidAt: null, error: null, ref, status: 'due', tx: null, paidAt: null, error: null, ref,
}; };
store.update('payouts', [], all => { all.push(rec); return all; }); store.update('payouts', [], all => { all.push(rec); return all; });
return rec; return rec;
} }
const bountyPaidFor = id => refRows().some(p => p.ref.kind === 'bounty' && Number(p.ref.forMemberId) === Number(id)); 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 ------------------------------------------ // ---- the hook: called right after a find is recorded ------------------------------------------
// Returns the drips it created, so the caller can post the bounty to Telegram. // Returns the drips it created, so the caller can post the bounty to Telegram.
function onFind(member, find) { function onFind(member, find) {
const c = cfg(); const c = cfg();
if (String(c.refEnabled) !== '1') return []; if (String(c.refEnabled) !== '1') return [];
const id = Number(member.memberId); const id = Number(member.memberId);
const me = hunter(id) || {}; const me = hunter(id) || {};
const referrer = referrerOf(id); const referrer = referrerOf(id);
if (!referrer || !referrer.wallet || Number(referrer.memberId) === id) return []; if (!referrer || !referrer.wallet || Number(referrer.memberId) === id) return [];
const out = []; const out = [];
let room = Number(c.refCapPol) - paidRefToday(); let room = Number(c.refCapPol) - paidRefToday();
const ageDays = me.joinedAt ? (Date.now() - Number(me.joinedAt)) / 86400000 : 0; const ageDays = me.joinedAt ? (Date.now() - Number(me.joinedAt)) / 86400000 : 0;
// the floor: did this person exist before the mission did? // the floor: did this person exist before the mission did?
const floor = Date.parse(String(c.refNewAfter || '')) || 0; // This gate FAILS CLOSED and must stay that way. No floor configured, a blank or mistyped
const isNew = !floor || (me.joinedAt && Number(me.joinedAt) >= floor); // date, or a member with no InstantAdPay join date on file: nobody gets paid. The last version
if (!isNew) return []; // an existing member hunting earns their sponsor nothing, ever // of this line paid out whenever the setting did not exclude anyone, which is how 8.67 POL went
const who = { forMemberId: id, forName: me.username || ('#' + id) }; // to sponsors of members who already existed. A referral only counts if the person did not
// exist in InstantAdPay before the mission did.
// the bounty, once, on their first find, and only while they are genuinely a new member const floor = Date.parse(String(c.refNewAfter || ''));
const finds = store.read('payouts', []).filter(p => Number(p.memberId) === id && !p.ref && !p.prize && p.status !== 'failed'); if (!Number.isFinite(floor) || floor <= 0) return [];
if (finds.length <= 1 && ageDays <= Number(c.refNewDays) && !bountyPaidFor(id)) { if (!me.joinedAt || Number(me.joinedAt) < floor) return [];
const pol = Number(c.refBountyPol); const who = { forMemberId: id, forName: me.username || ('#' + id) };
if (pol <= room) {
const firstEver = !refRows().some(p => p.ref.kind === 'bounty'); // the bounty, once, on their first find, and only while they are genuinely a new member
out.push(write(referrer, pol, Object.assign({ kind: 'bounty' }, who))); room -= pol; const finds = store.read('payouts', []).filter(p => Number(p.memberId) === id && !p.ref && !p.prize && p.status !== 'failed');
// the launch bonus rides along with the very first bounty anyone earns, once ever. It is if (finds.length <= 1 && ageDays <= Number(c.refNewDays) && !bountyPaidFor(id)) {
// deliberately outside the daily referral ceiling: it is a single payment, not a rate. const pol = Number(c.refBountyPol);
const bonus = Number(c.refFirstBonusPol) || 0; if (pol <= room) {
if (firstEver && bonus > 0 && !refRows().some(p => p.ref.kind === 'first-bonus')) { const firstEver = !refRows().some(p => p.ref.kind === 'bounty');
out.push(write(referrer, bonus, Object.assign({ kind: 'first-bonus' }, who))); 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;
// the match, on every find inside the window, paid on top and never deducted from the newcomer if (firstEver && bonus > 0 && !refRows().some(p => p.ref.kind === 'first-bonus')) {
if (ageDays <= Number(c.refMatchDays)) { out.push(write(referrer, bonus, Object.assign({ kind: 'first-bonus' }, who)));
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; // 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);
// ---- what the board shows --------------------------------------------------------------------- if (pol >= Number(c.refMatchMinPol) && pol <= room) out.push(write(referrer, pol, Object.assign({ kind: 'match', findId: find.id, findPol: find.pol }, who)));
function state(member) { }
const c = cfg(); return out;
const id = Number(member.memberId); }
const me = hunter(id) || { memberId: id };
const rows = refRows().filter(p => Number(p.memberId) === id); // ---- what the board shows ---------------------------------------------------------------------
const today = ctDay(); function state(member) {
const mine = broughtBy(id).map(x => { const c = cfg();
const theirFinds = store.read('payouts', []).filter(p => Number(p.memberId) === Number(x.memberId) && !p.ref && !p.prize && p.status !== 'failed'); const id = Number(member.memberId);
const paid = bountyPaidFor(x.memberId); const me = hunter(id) || { memberId: id };
return { const rows = refRows().filter(p => Number(p.memberId) === id);
name: x.username || ('#' + x.memberId), const today = ctDay();
// when they actually joined InstantAdPay, not when this directory first noticed them: const mine = broughtBy(id).map(x => {
// the backfill stamped firstSeen at seeding time and made everyone look brought-in today const theirFinds = store.read('payouts', []).filter(p => Number(p.memberId) === Number(x.memberId) && !p.ref && !p.prize && p.status !== 'failed');
joinedAt: x.joinedAt || x.firstSeen || null, const paid = bountyPaidFor(x.memberId);
finds: theirFinds.length, return {
state: paid ? 'earning' : theirFinds.length ? 'hunting' : 'signed up, no find yet', name: x.username || ('#' + x.memberId),
stillNew: x.joinedAt ? (Date.now() - Number(x.joinedAt)) / 86400000 <= Number(c.refMatchDays) : false, // 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,
return { finds: theirFinds.length,
on: String(c.refEnabled) === '1', state: paid ? 'earning' : theirFinds.length ? 'hunting' : 'signed up, no find yet',
bountyPol: Number(c.refBountyPol), stillNew: x.joinedAt ? (Date.now() - Number(x.joinedAt)) / 86400000 <= Number(c.refMatchDays) : false,
firstBonusPol: refRows().some(p => p.ref.kind === 'first-bonus') ? 0 : Number(c.refFirstBonusPol) || 0, };
matchPct: Number(c.refMatchPct), });
matchDays: Number(c.refMatchDays), return {
brought: mine.length, on: String(c.refEnabled) === '1',
broughtToday: mine.filter(x => x.joinedAt && ctDay(x.joinedAt) === today).length, bountyPol: Number(c.refBountyPol),
earnedPol: round4(rows.reduce((n, p) => n + p.pol, 0)), firstBonusPol: refRows().some(p => p.ref.kind === 'first-bonus') ? 0 : Number(c.refFirstBonusPol) || 0,
earnedTodayPol: round4(rows.filter(p => p.day === today).reduce((n, p) => n + p.pol, 0)), matchPct: Number(c.refMatchPct),
people: mine.slice(0, 25), matchDays: Number(c.refMatchDays),
pool: refPool(), 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)),
function totals() { people: mine.slice(0, 25),
const rows = refRows(); pool: refPool(),
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, function totals() {
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; })(), const rows = refRows();
pol: round4(rows.reduce((n, p) => n + p.pol, 0)), const paid = rows.filter(p => p.status === 'paid');
paidPol: round4(paid.reduce((n, p) => n + p.pol, 0)), return {
referrers: new Set(rows.map(p => p.memberId)).size, 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)),
module.exports = { seen, hunter, referrerOf, broughtBy, onFind, state, totals, refPool, cfg, paidRefToday }; 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 };