PolHunter v0.2: the hunt engine

Hand-off sign-in from InstantAdPay (lib/sso.js: HMAC token, five minutes, single use; no sign-up,
no mailer), missions with per-member per-visit proof codes (lib/missions.js: the embed is
answered only from the mission's own origin, only after the dwell; hiding place rotates by day
and member), weighted-low rewards with a daily cap that queues rather than refuses
(lib/rewards.js), the faucet sender on ethers with a low-balance alert (lib/faucet.js), the
one-line embed for the sites (public/embed.js), the hunter board, the public ledger, an
admin page, and the site's own look. Telegram is behind the OUTBOUND gate like everything else.
13-check end-to-end test in test/run.js.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-19 14:17:44 -05:00
parent 1a6166abc3
commit 523d57624e
15 changed files with 1001 additions and 75 deletions
+58
View File
@@ -0,0 +1,58 @@
// The faucet: pays due drips from the dedicated hot wallet. Amoy first, mainnet when Marty flips it.
//
// HUNT_WALLET_KEY the hot wallet's private key (env only; never on disk in the repo or volume)
// HUNT_RPC e.g. https://polygon-amoy-bor-rpc.publicnode.com (Amoy) or a mainnet RPC
// HUNT_CHAIN_ID 80002 (Amoy) or 137 (mainnet)
//
// Every send is one plain POL transfer, recorded on the ledger with its tx hash. A failure marks
// the entry 'failed' with the reason and never retries by itself (a human looks). The balance is
// read on every tick; below lowBalancePol the alert fires once per day.
'use strict';
const { ethers } = require('ethers');
const rewards = require('./rewards');
const store = require('./store');
let provider = null, wallet = null, ticking = false;
function enabled() { return !!(process.env.HUNT_WALLET_KEY && process.env.HUNT_RPC); }
function init() {
if (!enabled()) return false;
provider = new ethers.JsonRpcProvider(process.env.HUNT_RPC, Number(process.env.HUNT_CHAIN_ID) || undefined);
wallet = new ethers.Wallet(process.env.HUNT_WALLET_KEY.trim(), provider);
return true;
}
function address() { return wallet ? wallet.address : null; }
async function balance() { if (!provider || !wallet) return null; const b = await provider.getBalance(wallet.address); return Number(ethers.formatEther(b)); }
async function tick(notify) {
if (!wallet || ticking) return { paid: 0 };
ticking = true;
let paid = 0;
try {
const due = rewards.payable();
for (const p of due) {
if (!p.wallet || !/^0x[a-f0-9]{40}$/i.test(p.wallet)) { rewards.mark(p.id, { status: 'failed', error: 'no wallet on the account' }); continue; }
try {
const tx = await wallet.sendTransaction({ to: p.wallet, value: ethers.parseEther(String(p.pol)) });
rewards.mark(p.id, { status: 'sent', tx: tx.hash });
const rc = await tx.wait(1);
rewards.mark(p.id, { status: rc && rc.status === 1 ? 'paid' : 'failed', paidAt: Date.now(), error: rc && rc.status === 1 ? null : 'reverted' });
if (rc && rc.status === 1) { paid++; if (notify) await notify('paid', Object.assign({}, p, { tx: tx.hash })); }
} catch (e) {
rewards.mark(p.id, { status: 'failed', error: String(e.message || e).slice(0, 200) });
if (notify) await notify('failed', Object.assign({}, p, { error: String(e.message || e).slice(0, 200) }));
}
}
// low-balance alert, once a day
const bal = await balance();
if (bal != null) {
const s = rewards.settings(); const st = store.read('faucet-state', {});
const day = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' });
if (bal < Number(s.lowBalancePol) && st.lowAlertDay !== day) { st.lowAlertDay = day; store.write('faucet-state', st); if (notify) await notify('low', { balance: bal, threshold: s.lowBalancePol, address: wallet.address }); }
store.update('faucet-state', {}, x => Object.assign(x, { balance: bal, checkedAt: Date.now() }));
}
} finally { ticking = false; }
return { paid };
}
module.exports = { enabled, init, address, balance, tick };
+80
View File
@@ -0,0 +1,80 @@
// Missions, tokens and proof codes.
//
// A mission: { id, site, host, name, brief, url, dwell, reward: {min, max}, budget, active, created }
// host the origin the embed must call from (e.g. instantadpay.com); the proof only ever
// answers a request whose Origin header is that host
// brief what the hunter is told ("Open the ledger and find your code next to the newest payout")
// url where the mission link sends them; the token is appended as ?ph=<token>
// dwell seconds on the site before the code will render
// budget completions this mission will still pay (0 = unlimited)
//
// A token is issued when a hunter starts a mission: { t, memberId, missionId, iat, exp }. The
// code they must type is derived from the token with the server secret, so it is unique to that
// member and that sitting, worthless to anyone else, and different tomorrow. The hiding place
// (which page / which slot the embed renders in) is also derived, from the day and the member.
'use strict';
const crypto = require('crypto');
const store = require('./store');
const SECRET = String(process.env.HUNT_CODE_SECRET || process.env.HUNT_SSO_SECRET || '').trim();
const TOKEN_TTL = 20 * 60000;
const h = s => crypto.createHmac('sha256', SECRET).update(s).digest();
const ctDay = t => new Date(t === undefined ? Date.now() : t).toLocaleDateString('en-CA', { timeZone: 'America/Chicago' });
function list() { return store.read('missions', []); }
function get(id) { return list().find(m => m.id === id) || null; }
function save(m) {
return store.update('missions', [], all => {
const i = all.findIndex(x => x.id === m.id);
if (i >= 0) all[i] = Object.assign(all[i], m); else all.push(Object.assign({ created: Date.now(), active: true }, m));
return all;
});
}
function remove(id) { return store.update('missions', [], all => all.filter(m => m.id !== id)); }
// each hunter sees the active missions in their own order, so two people comparing notes are
// not looking at the same list; the order is stable for a member within a day
function forMember(memberId) {
const day = ctDay();
return list().filter(m => m.active).map(m => ({ m, k: h(day + ':' + memberId + ':' + m.id).readUInt32BE(0) }))
.sort((a, b) => a.k - b.k).map(x => x.m);
}
// ---- tokens ------------------------------------------------------------------------------
function issue(memberId, missionId) {
const t = crypto.randomBytes(16).toString('hex');
const rec = { t, memberId: Number(memberId), missionId, iat: Date.now(), exp: Date.now() + TOKEN_TTL };
store.update('tokens', {}, all => { for (const k of Object.keys(all)) if (all[k].exp < Date.now() - 3600000) delete all[k]; all[t] = rec; return all; });
return rec;
}
function token(t) { const rec = store.read('tokens', {})[String(t || '')]; return rec && rec.exp > Date.now() ? rec : null; }
// the proof: six characters from HMAC(secret, token); shown by the embed, typed by the hunter
function codeFor(rec) { return h('code:' + rec.t + ':' + rec.memberId + ':' + rec.missionId).toString('hex').slice(0, 6).toUpperCase(); }
// the hiding place: which slot on the page the embed renders in, by day and member
function slotFor(rec, slots) { const n = Math.max(1, Number(slots) || 1); return h('slot:' + ctDay() + ':' + rec.memberId + ':' + rec.missionId).readUInt32BE(0) % n; }
// The embed asks for the code. Answered only when: the token is live, the request's Origin is the
// mission's host, and the dwell has passed since the token was issued. Anything else is silence.
function codeForEmbed(t, origin) {
const rec = token(t); if (!rec) return { error: 'expired' };
const m = get(rec.missionId); if (!m || !m.active) return { error: 'gone' };
const host = String(origin || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/:\d+$/, '').toLowerCase(); // Origin carries a port; the mission host never does
const allowed = [m.host, 'www.' + m.host].map(x => x.toLowerCase());
if (!allowed.includes(host)) return { error: 'origin' };
const waited = (Date.now() - rec.iat) / 1000;
if (waited < (m.dwell || 30)) return { wait: Math.ceil((m.dwell || 30) - waited) };
return { code: codeFor(rec), slot: slotFor(rec, m.slots || 1) };
}
// The hunter submits what they found.
function check(t, memberId, typed) {
const rec = token(t); if (!rec) return { error: 'That mission timed out. Start it again from your board.' };
if (rec.memberId !== Number(memberId)) return { error: 'That mission belongs to a different account.' };
const want = codeFor(rec);
const got = String(typed || '').trim().toUpperCase();
if (got !== want) return { error: 'That is not the code. It is on the site, and it is yours alone.' };
return { ok: true, rec };
}
module.exports = { list, get, save, remove, forMember, issue, token, codeForEmbed, check, ctDay };
+75
View File
@@ -0,0 +1,75 @@
// Rewards: the weighted draw, the daily cap, the queue, and the ledger.
//
// settings (admin, on the volume): { minPol, maxPol, dailyCapPol, lowBalancePol }
// defaults 0.05 / 1 / 20 / 40 (Marty, 2026-09-19). The cap is a setting, not a constant,
// because POL's dollar price moves.
//
// The draw is weighted low: uniform on a log scale between min and max, so most drips sit near
// the floor and a 1 POL hit is rare enough to be talked about. Same range every day, whatever
// the cap.
//
// A completion is recorded the moment the proof checks out. If today's paid total plus this
// drip would cross the cap, the drip is queued (status 'queued') and paid on a later day in
// order; the hunter did the work and is never told no. The faucet pays 'due' entries.
'use strict';
const store = require('./store');
const { ctDay } = require('./missions');
const DEFAULTS = { minPol: 0.05, maxPol: 1, dailyCapPol: 20, lowBalancePol: 40 };
function settings() { return Object.assign({}, DEFAULTS, store.read('settings', {})); }
function setSettings(patch) { return store.update('settings', {}, s => Object.assign(s, patch)); }
function draw(min, max) {
const lo = Math.log(min), hi = Math.log(max);
const v = Math.exp(lo + Math.random() * (hi - lo));
return Math.round(Math.max(min, Math.min(max, v)) * 10000) / 10000;
}
function paidToday(day) {
return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed').reduce((n, p) => n + p.pol, 0);
}
// has this member completed this mission already?
function completed(memberId, missionId) {
return store.read('payouts', []).some(p => p.memberId === Number(memberId) && p.missionId === missionId && p.status !== 'failed');
}
// record a completion; decide paid-today vs queued
function grant(member, mission) {
const s = settings();
const pol = draw(Number(s.minPol), Number(s.maxPol));
const day = ctDay();
const budgetLeft = mission.budget ? mission.budget - store.read('payouts', []).filter(p => p.missionId === mission.id && p.status !== 'failed').length : Infinity;
if (budgetLeft <= 0) return { error: 'This mission has paid out all it was funded for.' };
const overCap = paidToday(day) + pol > Number(s.dailyCapPol);
const rec = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
memberId: Number(member.memberId), email: member.email, wallet: member.wallet, username: member.username || null,
missionId: mission.id, site: mission.site, pol, day, at: Date.now(),
status: overCap ? 'queued' : 'due', tx: null, paidAt: null, error: null,
};
store.update('payouts', [], all => { all.push(rec); return all; });
return { ok: true, rec, queued: overCap };
}
// what the faucet should pay now: due entries, then queued ones as long as today's cap allows
function payable() {
const s = settings(); const day = ctDay();
let room = Number(s.dailyCapPol) - paidToday(day);
const all = store.read('payouts', []);
const out = [];
for (const p of all.filter(p => p.status === 'due')) { out.push(p); }
for (const p of all.filter(p => p.status === 'queued').sort((a, b) => a.at - b.at)) { if (p.pol <= room) { room -= p.pol; out.push(p); } else break; }
return out;
}
function mark(id, patch) { return store.update('payouts', [], all => { const p = all.find(x => x.id === id); if (p) Object.assign(p, patch); return all; }); }
function ledger(n) { return store.read('payouts', []).filter(p => p.status === 'paid').sort((a, b) => b.paidAt - a.paidAt).slice(0, n || 50); }
function mine(memberId) { return store.read('payouts', []).filter(p => p.memberId === Number(memberId)).sort((a, b) => b.at - a.at); }
function totals() {
const all = store.read('payouts', []);
const paid = all.filter(p => p.status === 'paid');
return { paid: paid.length, pol: Math.round(paid.reduce((n, p) => n + p.pol, 0) * 10000) / 10000, queued: all.filter(p => p.status === 'queued').length, today: paidToday(ctDay()), hunters: new Set(paid.map(p => p.memberId)).size };
}
module.exports = { settings, setSettings, draw, grant, completed, payable, mark, ledger, mine, totals, paidToday };
+68
View File
@@ -0,0 +1,68 @@
// Sign-in by hand-off from InstantAdPay. There is no sign-up on PolHunter and never a mailer:
// a member clicks PolHunter inside their IAP dashboard, IAP signs a short-lived token with the
// shared secret, and this module turns it into a PolHunter session.
//
// Token: base64url(JSON payload) + '.' + base64url(HMAC-SHA256(secret, payload)).
// Payload: { memberId, email, wallet, username, iat, exp, nonce }. Single use, five minutes.
// Sessions: random id -> { memberId, email, wallet, username, since }, on the volume so a
// restart keeps people signed in. Cookie is HttpOnly, SameSite=Lax, Secure.
'use strict';
const crypto = require('crypto');
const store = require('./store');
const SECRET = String(process.env.HUNT_SSO_SECRET || '').trim();
const SESSION_TTL = 30 * 86400000;
const used = new Map(); // nonce -> exp, single-use guard (in memory is fine: tokens live five minutes)
const b64u = b => Buffer.from(b).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
const unb64u = s => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
const sign = payload => b64u(crypto.createHmac('sha256', SECRET).update(payload).digest());
function enabled() { return SECRET.length >= 32; }
// what IAP does to mint one (kept here so both sides read the same definition)
function mint(claims) {
const payload = JSON.stringify(Object.assign({ iat: Date.now(), exp: Date.now() + 5 * 60000, nonce: crypto.randomBytes(12).toString('hex') }, claims));
return b64u(payload) + '.' + sign(payload);
}
function verify(token) {
if (!enabled()) return { error: 'Sign-in from InstantAdPay is not configured on this server.' };
const [p, sig] = String(token || '').split('.');
if (!p || !sig) return { error: 'That sign-in link is not valid.' };
let payload; try { payload = unb64u(p).toString('utf8'); } catch (e) { return { error: 'That sign-in link is not valid.' }; }
const want = sign(payload);
if (want.length !== sig.length || !crypto.timingSafeEqual(Buffer.from(want), Buffer.from(sig))) return { error: 'That sign-in link is not valid.' };
let c; try { c = JSON.parse(payload); } catch (e) { return { error: 'That sign-in link is not valid.' }; }
if (!c.exp || Date.now() > c.exp) return { error: 'That sign-in link has expired. Open PolHunter from your InstantAdPay dashboard again.' };
if (!c.memberId || !c.email) return { error: 'That sign-in link is missing your account.' };
for (const [n, exp] of used) if (exp < Date.now()) used.delete(n);
if (used.has(c.nonce)) return { error: 'That sign-in link was already used.' };
used.set(c.nonce, c.exp);
return { ok: true, claims: c };
}
function startSession(claims) {
const id = crypto.randomBytes(24).toString('hex');
store.update('sessions', {}, s => {
const now = Date.now();
for (const k of Object.keys(s)) if ((s[k].since || 0) + SESSION_TTL < now) delete s[k];
s[id] = { memberId: Number(claims.memberId), email: String(claims.email).toLowerCase(), wallet: claims.wallet ? String(claims.wallet).toLowerCase() : null, username: claims.username || null, since: now };
return s;
});
return id;
}
function cookie(id) { return 'ph.sid=' + id + '; Path=/; Max-Age=' + Math.floor(SESSION_TTL / 1000) + '; HttpOnly; SameSite=Lax; Secure'; }
function clearCookie() { return 'ph.sid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax; Secure'; }
function fromRequest(req) {
const m = /(?:^|;\s*)ph\.sid=([a-f0-9]{48})/.exec(req.headers.cookie || '');
if (!m) return null;
const s = store.read('sessions', {})[m[1]];
if (!s || (s.since || 0) + SESSION_TTL < Date.now()) return null;
return Object.assign({ sid: m[1] }, s);
}
function endSession(sid) { store.update('sessions', {}, s => { delete s[sid]; return s; }); }
// a member's wallet can change on IAP; the next hand-off refreshes it here
function refresh(sid, claims) { store.update('sessions', {}, s => { if (s[sid]) { s[sid].wallet = claims.wallet ? String(claims.wallet).toLowerCase() : null; s[sid].username = claims.username || s[sid].username; } return s; }); }
module.exports = { enabled, mint, verify, startSession, cookie, clearCookie, fromRequest, endSession, refresh };
+28
View File
@@ -0,0 +1,28 @@
// Tiny JSON store on the volume. Every collection is one file; writes are atomic (tmp + rename).
// PolHunter's data is small (missions, completions, payouts) and a member's balance never lives
// here, so a database would be ceremony. If it ever grows, this is the one file to swap.
'use strict';
const fs = require('fs');
const path = require('path');
let DIR = null;
function init(dir) { DIR = dir; fs.mkdirSync(DIR, { recursive: true }); }
const file = name => path.join(DIR, name + '.json');
function read(name, fallback) {
try { return JSON.parse(fs.readFileSync(file(name), 'utf8')); } catch (e) { return fallback; }
}
function write(name, data) {
const f = file(name), tmp = f + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(data));
fs.renameSync(tmp, f);
}
// read-modify-write in one place so callers never race themselves
function update(name, fallback, fn) {
const cur = read(name, fallback);
const out = fn(cur) || cur;
write(name, out);
return out;
}
module.exports = { init, read, write, update };