// InstantAdPay anti-fraud signals (Marty, 2026-09-16, after @megamol created megamol2/megamol3 under // his own link and bought $20 on each to fake his two qualifying buyers). // // Per account: sign-up IP + user agent + browser device id (first-party cookie iap.dev), and the // last-seen IP/UA/device on every sign-in. At sign-up: // HARD BLOCK dup-device another account already used this browser // HARD BLOCK ip-burst more than fraudMaxSignupsPerIpDay accounts from this IP in 24h // HARD BLOCK sponsor-device the sponsor's account used this same browser // FLAG sponsor-ip the sponsor signed up from / was last seen on this IP (households are legal, so flag only) // FLAG shared-ip another account used this IP in the last 30 days // Flagged accounts stay usable but never count on the leaderboard, cannot adopt from the holding tank, // and show in Admin > Members. Suspended accounts (admin switch) cannot sign in at all. // The contract's on-chain payments are outside all of this and are never reversed. 'use strict'; const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const db = require('./db'); let DATA_DIR = null; const FILE = () => path.join(DATA_DIR, 'account-signals.json'); const J = { db: null, load() { try { this.db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { this.db = {}; } }, save() { try { fs.writeFileSync(FILE(), JSON.stringify(this.db)); } catch (e) {} } }; let suspendedSet = new Set(); // refreshed on init and on every suspend/unsuspend let flaggedSet = new Set(); // accounts carrying a hard flag (excluded from leaderboard / adoption) const QS = ' To hold more than one position, sign in to your account and use Qualified Start on the Buy packages tab: link another wallet of your own as a position under your account. Each linked wallet that buys a $20 or larger package counts as one of YOUR qualifying buyers, its credits pool with yours, and half of that purchase comes straight back to your main wallet. That is the built-in way to self-qualify.'; const HARD = new Set(['dup-device', 'ip-burst', 'sponsor-device', 'sponsor-ip', 'multi-account']); let seenAt = new Map(); // email -> ts of last recordSeen (throttle writes) function norm(e) { return String(e || '').trim().toLowerCase(); } function ipOf(req) { return String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim().replace(/^::ffff:/, '').slice(0, 45); } function uaOf(req) { return String(req.headers['user-agent'] || '').slice(0, 200); } function cookies(req) { const out = {}; String(req.headers.cookie || '').split(';').forEach(p => { const i = p.indexOf('='); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); }); return out; } function deviceOf(req) { const d = cookies(req)['iap.dev'] || ''; return /^[a-f0-9]{32}$/.test(d) ? d : ''; } function newDeviceId() { return crypto.randomBytes(16).toString('hex'); } function deviceCookie(id, isProd) { return 'iap.dev=' + id + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' + (400 * 86400) + (isProd ? '; Secure' : ''); } async function init(opts) { DATA_DIR = opts.dataDir; if (db.enabled()) { await db.q(`CREATE TABLE IF NOT EXISTS account_signals ( email VARCHAR(190) PRIMARY KEY, signup_ip VARCHAR(45) NULL, signup_ua VARCHAR(200) NULL, device_id CHAR(32) NULL, signup_at BIGINT NULL, last_ip VARCHAR(45) NULL, last_ua VARCHAR(200) NULL, last_device CHAR(32) NULL, last_at BIGINT NULL, flags VARCHAR(400) NULL, suspended TINYINT NOT NULL DEFAULT 0, suspended_reason VARCHAR(200) NULL, suspended_at BIGINT NULL, INDEX (signup_ip), INDEX (device_id), INDEX (last_ip), INDEX (last_device), INDEX (suspended) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } else J.load(); await refreshSets(); } async function refreshSets() { const rows = await all(); suspendedSet = new Set(rows.filter(r => r.suspended).map(r => r.email)); flaggedSet = new Set(rows.filter(r => (r.flags || []).some(f => HARD.has(f))).map(r => r.email)); } async function all() { if (db.enabled()) return (await db.q('SELECT * FROM account_signals')).map(rowPub); if (!J.db) J.load(); return Object.values(J.db).map(pubJ); } const rowPub = r => ({ email: r.email, signupIp: r.signup_ip || '', signupUa: r.signup_ua || '', deviceId: r.device_id || '', signupAt: Number(r.signup_at || 0), lastIp: r.last_ip || '', lastUa: r.last_ua || '', lastDevice: r.last_device || '', lastAt: Number(r.last_at || 0), flags: parseFlags(r.flags), suspended: !!r.suspended, suspendedReason: r.suspended_reason || '', suspendedAt: Number(r.suspended_at || 0) }); const pubJ = a => ({ email: a.email, signupIp: a.signupIp || '', signupUa: a.signupUa || '', deviceId: a.deviceId || '', signupAt: a.signupAt || 0, lastIp: a.lastIp || '', lastUa: a.lastUa || '', lastDevice: a.lastDevice || '', lastAt: a.lastAt || 0, flags: a.flags || [], suspended: !!a.suspended, suspendedReason: a.suspendedReason || '', suspendedAt: a.suspendedAt || 0 }); function parseFlags(s) { try { const v = JSON.parse(s || '[]'); return Array.isArray(v) ? v : []; } catch (e) { return []; } } async function get(email) { const e = norm(email); if (!e) return null; if (db.enabled()) { const r = await db.q('SELECT * FROM account_signals WHERE email=?', [e]); return r.length ? rowPub(r[0]) : null; } if (!J.db) J.load(); return J.db[e] ? pubJ(J.db[e]) : null; } async function upsert(email, fields) { const e = norm(email); if (!e) return; if (db.enabled()) { const cur = await get(e); const v = Object.assign({ signupIp: null, signupUa: null, deviceId: null, signupAt: null, lastIp: null, lastUa: null, lastDevice: null, lastAt: null, flags: [], suspended: false, suspendedReason: null, suspendedAt: null }, cur || {}, fields); await db.q(`INSERT INTO account_signals (email,signup_ip,signup_ua,device_id,signup_at,last_ip,last_ua,last_device,last_at,flags,suspended,suspended_reason,suspended_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE signup_ip=VALUES(signup_ip), signup_ua=VALUES(signup_ua), device_id=VALUES(device_id), signup_at=VALUES(signup_at), last_ip=VALUES(last_ip), last_ua=VALUES(last_ua), last_device=VALUES(last_device), last_at=VALUES(last_at), flags=VALUES(flags), suspended=VALUES(suspended), suspended_reason=VALUES(suspended_reason), suspended_at=VALUES(suspended_at)`, [e, v.signupIp || null, v.signupUa || null, v.deviceId || null, v.signupAt || null, v.lastIp || null, v.lastUa || null, v.lastDevice || null, v.lastAt || null, JSON.stringify(v.flags || []), v.suspended ? 1 : 0, v.suspendedReason || null, v.suspendedAt || null]); return; } if (!J.db) J.load(); J.db[e] = Object.assign({ email: e }, J.db[e] || {}, fields); J.save(); } // ---- sign-up time ---- async function checkSignup(req, sponsorAccount, cfg) { const ip = ipOf(req), dev = deviceOf(req); const flags = []; let block = null; const rows = await all(); const now = Date.now(); const maxPerDay = Math.max(1, Number(cfg && cfg.fraudMaxSignupsPerIpDay) || 2); const blockDevice = !(cfg && String(cfg.fraudBlockSharedDevice) === 'off'); if (dev) { const same = rows.filter(r => r.deviceId === dev || r.lastDevice === dev); if (same.length) { flags.push('dup-device'); if (blockDevice) block = 'This browser already has an InstantAdPay account (' + mask(same[0].email) + '), and it is one account per person.' + QS + ' Shared computer at work or a library? Contact support.'; } } if (ip) { const burst = rows.filter(r => r.signupIp === ip && now - (r.signupAt || 0) < 86400000); if (burst.length >= maxPerDay) { flags.push('ip-burst'); block = block || 'Too many new accounts from this connection today, and it is one account per person.' + QS + ' If these are different people on one connection, try again tomorrow or contact support.'; } const shared = rows.filter(r => (r.signupIp === ip || r.lastIp === ip) && now - Math.max(r.signupAt || 0, r.lastAt || 0) < 30 * 86400000); if (shared.length) flags.push('shared-ip'); } if (sponsorAccount && sponsorAccount.email) { const sp = await get(sponsorAccount.email); if (sp) { if (dev && (sp.deviceId === dev || sp.lastDevice === dev)) { flags.push('sponsor-device'); if (blockDevice) block = block || 'The invite link you used belongs to an account on this same browser, so this would be a second account for the same person, which the Terms do not allow.' + QS; } if (ip && (sp.signupIp === ip || sp.lastIp === ip)) flags.push('sponsor-ip'); } } return { block, flags: [...new Set(flags)], ip, device: dev }; } async function recordSignup(email, req, flags) { const now = Date.now(); await upsert(email, { signupIp: ipOf(req), signupUa: uaOf(req), deviceId: deviceOf(req) || null, signupAt: now, lastIp: ipOf(req), lastUa: uaOf(req), lastDevice: deviceOf(req) || null, lastAt: now, flags: flags || [] }); if ((flags || []).some(f => HARD.has(f))) flaggedSet.add(norm(email)); } async function recordSeen(email, req) { const e = norm(email); if (!e) return; const last = seenAt.get(e) || 0; if (Date.now() - last < 10 * 60 * 1000) return; seenAt.set(e, Date.now()); if (seenAt.size > 20000) seenAt.clear(); const cur = await get(e); const fields = { lastIp: ipOf(req), lastUa: uaOf(req), lastDevice: deviceOf(req) || (cur ? cur.lastDevice : null), lastAt: Date.now() }; if (!cur) Object.assign(fields, { signupIp: null, signupUa: null, deviceId: deviceOf(req) || null, signupAt: null, flags: [] }); await upsert(e, fields); } // ---- admin ---- async function addFlags(email, flags) { const cur = (await get(email)) || { flags: [] }; const merged = [...new Set([...(cur.flags || []), ...(flags || [])])]; await upsert(email, { flags: merged }); if (merged.some(f => HARD.has(f))) flaggedSet.add(norm(email)); else flaggedSet.delete(norm(email)); return merged; } async function clearFlags(email) { await upsert(email, { flags: [] }); flaggedSet.delete(norm(email)); } async function suspend(email, reason) { await upsert(email, { suspended: true, suspendedReason: String(reason || '').slice(0, 200), suspendedAt: Date.now() }); suspendedSet.add(norm(email)); } async function unsuspend(email) { await upsert(email, { suspended: false, suspendedReason: null, suspendedAt: null }); suspendedSet.delete(norm(email)); } function isSuspended(email) { return suspendedSet.has(norm(email)); } function excluded(email) { const e = norm(email); return suspendedSet.has(e) || flaggedSet.has(e); } // leaderboard / adoption async function report() { const rows = await all(); const groups = (key) => { const m = new Map(); for (const r of rows) { const k = r[key]; if (!k) continue; if (!m.has(k)) m.set(k, []); m.get(k).push(r.email); } return [...m.entries()].filter(([, v]) => new Set(v).size > 1).map(([k, v]) => ({ key: k, emails: [...new Set(v)] })); }; const byDevice = groups('deviceId').concat(groups('lastDevice')); const byIp = groups('signupIp').concat(groups('lastIp')); const dedupe = (list) => { const seen = new Set(); return list.filter(g => { const k = g.emails.slice().sort().join('|'); if (seen.has(k)) return false; seen.add(k); return true; }); }; return { flagged: rows.filter(r => (r.flags || []).length).map(r => ({ email: r.email, flags: r.flags, signupIp: r.signupIp, lastIp: r.lastIp, suspended: r.suspended })), suspended: rows.filter(r => r.suspended).map(r => ({ email: r.email, reason: r.suspendedReason, at: r.suspendedAt })), sharedDevice: dedupe(byDevice), sharedIp: dedupe(byIp), total: rows.length }; } function mask(e) { return String(e || '').replace(/^(.{2}).*(@.*)$/, '$1***$2'); } module.exports = { init, checkSignup, recordSignup, recordSeen, addFlags, clearFlags, suspend, unsuspend, isSuspended, excluded, report, get, deviceOf, newDeviceId, deviceCookie, ipOf, HARD };