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:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user