// Shared pure functions for the Nano forecast ladder: canonical JSON, hashing, // proof-of-work and signature checks, Brier scoring and the payout table. 'use strict'; const fs = require('fs'); const path = require('path'); const { blake2b } = require('blakejs'); const nc = require('nanocurrency'); const SEND_THRESHOLD = 0xfffffff800000000n; // Nano send/change work threshold const RAW_PER_NANO = 10n ** 30n; const ADDRESS_RE = /^nano_[13][13456789abcdefghijkmnopqrstuwxyz]{59}$/; const NONCE_RE = /^[A-Za-z0-9._-]{1,64}$/; const QID_RE = /^[a-z0-9_]{1,32}$/; // --- canonical JSON --------------------------------------------------------- // Keys sorted (code-unit order) recursively, no whitespace, numbers as // JavaScript JSON.stringify prints them, strings as JSON.stringify escapes them. function canonical(v) { if (v === null || typeof v === 'boolean' || typeof v === 'string') return JSON.stringify(v); if (typeof v === 'number') { if (!Number.isFinite(v)) throw new Error('non-finite number'); return JSON.stringify(v); } if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']'; if (typeof v === 'object') { return '{' + Object.keys(v).sort().map(k => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}'; } throw new Error('cannot canonicalise ' + typeof v); } function blake2bHex(bytes, outlen) { return Buffer.from(blake2b(bytes, undefined, outlen)).toString('hex').toUpperCase(); } // MESSAGE = blake2b-256 of the canonical string of {round, address, forecasts, nonce} function messageOf(sub) { const s = canonical({ round: sub.round, address: sub.address, forecasts: sub.forecasts, nonce: sub.nonce }); return { canonical: s, hash: blake2bHex(Buffer.from(s, 'utf8'), 32) }; } // --- proof of work ---------------------------------------------------------- // value = blake2b-8(work as 8 bytes little-endian || hash bytes) read as LE uint64 function workValue(hashHex, workHex) { const buf = Buffer.concat([Buffer.from(workHex, 'hex').reverse(), Buffer.from(hashHex, 'hex')]); return Buffer.from(blake2b(buf, undefined, 8)).readBigUInt64LE(0); } function validWork(hashHex, workHex, threshold = SEND_THRESHOLD) { if (!/^[0-9a-fA-F]{64}$/.test(hashHex) || !/^[0-9a-fA-F]{16}$/.test(workHex)) return false; return workValue(hashHex, workHex) >= threshold; } // --- Ed25519-Blake2b signature (Nano variant) via nanocurrency's bundled nacl - function validSignature(hashHex, signatureHex, address) { if (!ADDRESS_RE.test(address) || !/^[0-9a-fA-F]{128}$/.test(signatureHex)) return false; try { return nc.verifyBlock({ hash: hashHex.toUpperCase(), signature: signatureHex.toUpperCase(), publicKey: nc.derivePublicKey(address) }); } catch { return false; } } function sign(hashHex, secretKeyHex) { return nc.signBlock({ hash: hashHex.toUpperCase(), secretKey: secretKeyHex }); } // --- submission shape checks (no I/O) -------------------------------------- function checkShape(sub, round) { if (!sub || typeof sub !== 'object' || Array.isArray(sub)) return 'body must be a JSON object'; if (!Number.isInteger(sub.round)) return 'round must be an integer id'; if (typeof sub.address !== 'string' || !ADDRESS_RE.test(sub.address)) return 'address must be a nano_ address'; if (typeof sub.nonce !== 'string' || !NONCE_RE.test(sub.nonce)) return 'nonce must match ' + NONCE_RE; if (typeof sub.work !== 'string' || !/^[0-9a-fA-F]{16}$/.test(sub.work)) return 'work must be 16 hex chars'; if (typeof sub.signature !== 'string' || !/^[0-9a-fA-F]{128}$/.test(sub.signature)) return 'signature must be 128 hex chars'; if (!sub.forecasts || typeof sub.forecasts !== 'object' || Array.isArray(sub.forecasts)) return 'forecasts must be an object'; const ids = new Set(round.questions.map(q => q.id)); for (const [k, v] of Object.entries(sub.forecasts)) { if (!ids.has(k)) return 'unknown question id ' + JSON.stringify(k) + ' for round ' + round.id; if (typeof v !== 'number' || !(v >= 0 && v <= 1)) return 'forecast for ' + k + ' must be a number in [0,1]'; } for (const id of ids) if (!(id in sub.forecasts)) return 'missing forecast for question ' + id; const extra = Object.keys(sub).filter(k => !['round', 'address', 'forecasts', 'nonce', 'work', 'signature', 'stake_hash'].includes(k)); if (extra.length) return 'unexpected fields: ' + extra.join(', '); return null; } // --- scoring and payouts ---------------------------------------------------- function brier(forecasts, outcomes) { // outcomes: {qid: 0|1} const ids = Object.keys(outcomes); return ids.reduce((s, id) => s + (forecasts[id] - outcomes[id]) ** 2, 0) / ids.length; } // rows: [{address, brier}] -> ranked rows with payouts. Top half (ceil(n/2)) of // ranks share the pot with weights n - rank + 1; ties share a rank (1,1,3). function payoutTable(rows, potRaw) { const n = rows.length; const sorted = [...rows].sort((a, b) => a.brier - b.brier || a.address.localeCompare(b.address)); sorted.forEach((r, i) => { r.rank = i > 0 && sorted[i - 1].brier === r.brier ? sorted[i - 1].rank : i + 1; }); const cutoff = Math.ceil(n / 2); const winners = sorted.filter(r => r.rank <= cutoff); const weight = r => BigInt(n - r.rank + 1); const total = winners.reduce((s, r) => s + weight(r), 0n); const pot = BigInt(potRaw); for (const r of sorted) { const raw = r.rank <= cutoff && total > 0n ? (pot * weight(r)) / total : 0n; r.payout_raw = raw.toString(); r.payout_nano = nanoStr(raw); } return sorted; } function nanoStr(raw) { raw = BigInt(raw); const whole = raw / RAW_PER_NANO, frac = (raw % RAW_PER_NANO).toString().padStart(30, '0').replace(/0+$/, ''); return whole.toString() + (frac ? '.' + frac : ''); } // --- authoring checks --------------------------------------------------------- // The threshold a question resolves on must appear in its text, so the prose cannot say 6650 while the field that resolves // says 6670 (round 3 x402_stars, reported by PlatinumVera and pyfile-toolkit, 2026-09-29). Returns one string per question // whose text carries numbers but not the threshold; empty when they agree. ISO dates, clock times and digits inside // identifiers (x402) are not read as numbers; 20.0 and 20 are the same number; a text with no number at all passes. function textThresholdMismatches(round) { const out = []; for (const q of round.questions || []) { if (q.threshold === undefined || q.threshold === null || q.threshold === '') continue; const text = String(q.text || '').replace(/\d{4}-\d{2}-\d{2}/g, ' ').replace(/\b\d{1,2}:\d{2}\b/g, ' '); const nums = (text.match(/(? Number(n.replace(/,/g, ''))); if (!nums.length) continue; const t = Number(String(q.threshold).replace(/,/g, '')); if (!nums.some(n => n === t)) out.push(round.id + '/' + q.id + ': the text does not contain the threshold ' + q.threshold + ' (numbers in the text: ' + nums.join(', ') + ')'); } return out; } // --- files ------------------------------------------------------------------ function readJson(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { if (e.code === 'ENOENT' && fallback !== undefined) return fallback; throw e; } } function writeJsonAtomic(file, obj) { const tmp = path.join(path.dirname(file), '.' + path.basename(file) + '.tmp-' + process.pid); fs.writeFileSync(tmp, JSON.stringify(obj, null, 1) + '\n'); fs.renameSync(tmp, file); } // phase of a round from its status, the clock and whether results exist function phase(round, now, hasResults) { if (round.status === 'draft') return 'draft'; if (hasResults) return 'resolved'; const t = now.getTime(); if (t < Date.parse(round.opens_at)) return 'upcoming'; if (t < Date.parse(round.closes_at)) return 'open'; return 'closed'; } // Receive sources of an address before a moment, read through the given rpc(body) function (account_history on a // node). Used to answer, one hop out, whether an entry's stake was funded by an address the operator had paid. async function fundingSources(rpc, address, beforeMs, count = 500) { const h = await rpc({ action: 'account_history', account: address, count: String(count) }); const seen = new Map(); for (const b of (h && Array.isArray(h.history)) ? h.history : []) { if (b.type !== 'receive' || Number(b.local_timestamp) * 1000 >= beforeMs) continue; const t = Number(b.local_timestamp); const s = seen.get(b.account) || { account: b.account, amount_raw: 0n, first_at: t, last_at: t, receives: [] }; s.amount_raw += BigInt(b.amount); s.first_at = Math.min(s.first_at, t); s.last_at = Math.max(s.last_at, t); // every transfer from this source to the address before the moment, so an order condition can be checked later // (added 2026-09-22; entries stored before that were backfilled by backfill-receives.js) s.receives.push({ at: new Date(t * 1000).toISOString(), amount_raw: String(b.amount), hash: b.hash }); seen.set(b.account, s); } return { checked_at: new Date().toISOString(), sources: [...seen.values()].map(s => ({ account: s.account, amount_raw: s.amount_raw.toString(), first_at: new Date(s.first_at * 1000).toISOString(), last_at: new Date(s.last_at * 1000).toISOString(), receives: s.receives.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)) })) }; } // Order condition for the one-hop funding check, added 2026-09-22 (swipepredictbot, round 1 results thread). The // original funded_by_operator_payee_before_entry asks only whether the operator had paid a funding source before the // ENTRY; it has no order condition, so it also fires when the source paid the entrant first and was paid by the // operator only later, a sequence in which no operator money can have reached the entry. The *_in_order fields // require operator -> source before source -> entrant: an operator payment to the source dated before that source's // last transfer to the entry address (so a transfer after the operator's payment could carry operator money; one // made only before it could not). Timestamps are the operator's ledger clock and the ladder node's local_timestamp // for the receive, both wall-clock approximations: an order that hinges on seconds is not reliable either way. // paymentsOf(account) returns the operator's ledger rows to that account before the entry as [{ts, kind}], or null // when the ledger is unreadable. Own addresses count as in order for both fields. Sources stored without // `receives`/`last_at` answer null. // ownIsOther(account): for an own-address source, whether the operator's ledger shows money of kind other (or no row at // all, e.g. a test account) went from it to the entry address; a source that only carried a ladder prize or refund // is in order for `any` but not for `excluding_ladder_returns`, the same reading the hop-0 fields use. function inOrderFunding(sources, own, paymentsOf, ownIsOther = () => true) { if (!sources) return { any: null, excluding_ladder_returns: null, per_source: [] }; if (!sources.length) return { any: false, excluding_ladder_returns: false, per_source: [] }; const per = sources.map(s => { if (own.has(s.account)) return { any: true, other: ownIsOther(s.account), first_operator_payment_at: null, first_other_operator_payment_at: null }; const last = s.last_at || (Array.isArray(s.receives) && s.receives.length ? s.receives[s.receives.length - 1].at : null); const pays = paymentsOf(s.account); if (pays === null || !last) return { any: null, other: null, first_operator_payment_at: null, first_other_operator_payment_at: null }; const ts = pays.map(p => p.ts).sort(), others = pays.filter(p => p.kind === 'other').map(p => p.ts).sort(); const firstAny = ts.length ? ts[0] : null, firstOther = others.length ? others[0] : null; return { any: firstAny !== null && firstAny < last, other: firstOther !== null && firstOther < last, first_operator_payment_at: firstAny, first_other_operator_payment_at: firstOther }; }); const agg = k => per.some(x => x[k] === true) ? true : per.some(x => x[k] === null) ? null : false; return { any: agg('any'), excluding_ladder_returns: agg('other'), per_source: per }; } // Amount test for the one-hop funding check, added 2026-09-22 (third change that day; swipepredictbot, round 1 results // thread). The *_in_order fields test sequence only: an operator payment of any size before a transfer of any size // passes, so they cannot tell a funded stake from an unrelated earlier balance. These figures ask how much moved in // that order. Per source (all raw, before the entry, node-local timestamps for receives and the operator's ledger // clock for payments): // transferred_after_first_operator_payment: the source's transfers to the entry address dated after the operator's // first payment to that source, summed; // operator_paid_before_last_transfer: the operator's payments to that source dated before the source's last transfer // to the entry address, summed; // operator_money_bound: the smaller of the two. An upper bound on the operator money that can have reached the entry // through this source: money the operator never sent cannot have been forwarded, and money forwarded before the // operator paid cannot have been the operator's. A bound, not a trace; it does not follow raw through the source's // other balances, and interleaved payments and transfers can make the true figure smaller. // Each has an _other twin that counts only operator payments of kind other (the excluding_ladder_returns reading). // An operator-owned source is labelled operator_address and every transfer from it is operator money: transferred, // paid and bound all equal its total receives; its _other figures are what the operator's ledger shows sent to the // entry address of kind other (hop0), capped at the source's total receives, since the ladder's own address does not // appear as a payee in the ledger and cannot be matched row by row. // Entry level: at_least_stake is the test as proposed: some source's transferred_after_first_operator_payment >= the // round's stake; bound is the sum of the per-source bounds; bound_at_least_stake is that sum >= stake. Null when the // ledger is unreadable, a source lacks receives, or the round has no stake (round 0). No sources: false and 0. function amountFunding(sources, own, paymentsOf, stakeRaw, hop0) { const empty = (isOwn) => ({ operator_address: isOwn, operator_payments: null, transferred_after_first_operator_payment_raw: null, transferred_after_first_other_operator_payment_raw: null, operator_paid_before_last_transfer_raw: null, operator_paid_other_before_last_transfer_raw: null, operator_money_bound_raw: null, operator_money_bound_other_raw: null }); const nul = { at_least_stake: null, at_least_stake_other: null, bound_raw: null, bound_other_raw: null, per_source: [] }; if (!sources) return nul; const stake = stakeRaw === undefined || stakeRaw === null ? null : BigInt(stakeRaw); const min = (a, b) => (a < b ? a : b); const sum = (list, f) => list.filter(f).reduce((a, x) => a + BigInt(x.amount_raw), 0n); const per = sources.map(s => { const isOwn = own.has(s.account); const recv = Array.isArray(s.receives) ? s.receives : null; if (!recv) return empty(isOwn); const total = sum(recv, () => true); if (isOwn) { if (hop0 === null) return empty(true); const other = min(sum(hop0, p => p.kind === 'other'), total); return { operator_address: true, operator_payments: null, transferred_after_first_operator_payment_raw: total, transferred_after_first_other_operator_payment_raw: other, operator_paid_before_last_transfer_raw: total, operator_paid_other_before_last_transfer_raw: other, operator_money_bound_raw: total, operator_money_bound_other_raw: other }; } const pays = paymentsOf(s.account); if (pays === null) return empty(false); const sorted = pays.slice().sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts)); const others = sorted.filter(p => p.kind === 'other'); const firstAny = sorted.length ? Date.parse(sorted[0].ts) : null, firstOther = others.length ? Date.parse(others[0].ts) : null; const last = recv.length ? Math.max(...recv.map(r => Date.parse(r.at))) : null; const after = t => (t === null ? 0n : sum(recv, r => Date.parse(r.at) > t)); const before = list => (last === null ? 0n : sum(list, p => Date.parse(p.ts) < last)); const tAny = after(firstAny), tOther = after(firstOther), pAny = before(sorted), pOther = before(others); return { operator_address: false, operator_payments: sorted.map(p => ({ ledger_id: p.ledger_id, ts: p.ts, amount_nano: nanoStr(p.amount_raw), kind: p.kind })), transferred_after_first_operator_payment_raw: tAny, transferred_after_first_other_operator_payment_raw: tOther, operator_paid_before_last_transfer_raw: pAny, operator_paid_other_before_last_transfer_raw: pOther, operator_money_bound_raw: min(tAny, pAny), operator_money_bound_other_raw: min(tOther, pOther) }; }); const atLeast = k => stake === null ? null : per.some(x => x[k] !== null && x[k] >= stake) ? true : per.some(x => x[k] === null) ? null : false; const total = k => (per.some(x => x[k] === null) ? null : per.reduce((a, x) => a + x[k], 0n)); const bAny = total('operator_money_bound_raw'), bOther = total('operator_money_bound_other_raw'); return { at_least_stake: atLeast('transferred_after_first_operator_payment_raw'), at_least_stake_other: atLeast('transferred_after_first_other_operator_payment_raw'), bound_raw: bAny, bound_other_raw: bOther, bound_at_least_stake: stake === null || bAny === null ? null : bAny >= stake, bound_at_least_stake_other: stake === null || bOther === null ? null : bOther >= stake, per_source: per }; } // Lower bound on operator money, added 2026-09-22 (fourth change that day; swipepredictbot, round 1 results thread). // Every figure above is an upper bound: it says how much operator money CAN have reached the entry through a source, // so only a bound below the stake is decisive. This one runs the other way. Nano records each account's balance in // every block, so for a source it is computable how much of what it sent the entry address MUST have been operator // money even if every other raw it held went out first: // lower = max(0, transferred to the entry address after the operator's first payment landed // - (the source's balance just before that payment landed // + everything it received from anyone but the operator between that payment and its last // transfer to the entry address)) // A non-zero lower bound at or above the stake shows the stake was operator money; zero means possible, not shown. // Everything here is in the source's own chain order (block heights), not wall clock: the operator's payment is the // receive block whose link is the ledger row's block hash, sender read from the raw history. Transfers from the source to // itself add no raw and are not counted as receipts. The _other twin counts only operator payments of kind other, so a // ladder prize or refund the source held is treated as non-operator money there. A bound, not a trace, and not the // tightest one possible (a balance that hit zero after the first payment would allow a sharper figure; not used). // Sources stored without a chain (entries before this change until backfilled, or a chain over the cap) answer null; // an operator-owned source is all operator money: lower equals its total transfers (its _other figure as in amountFunding). // kindOf(linkHash) -> 'ladder_payout' | 'ladder_refund' | 'other' | null; null (no ledger row for a send from an // operator address, e.g. a test account) is read as other, the own_address reading the hop-0 fields use. async function sourceChain(rpc, account, beforeMs, cap = 3000) { const blocks = []; let head = null; for (;;) { const body = { action: 'account_history', account, count: String(Math.min(1000, cap + 1 - blocks.length)), raw: 'true' }; if (head) body.head = head; const h = await rpc(body); const list = (h && Array.isArray(h.history)) ? h.history : []; blocks.push(...list); if (blocks.length > cap) return { truncated: true, block_count: blocks.length, checked_at: new Date().toISOString() }; if (!list.length || !h.previous) break; head = h.previous; } const out = []; for (const b of blocks) { if (Number(b.local_timestamp) * 1000 >= beforeMs) continue; if (b.balance === undefined) return { truncated: false, legacy: true, block_count: blocks.length, checked_at: new Date().toISOString() }; out.push({ height: Number(b.height), subtype: b.subtype || b.type, account: b.account || null, amount_raw: String(b.amount || '0'), balance_raw: String(b.balance), link: b.link || null, hash: b.hash, at: new Date(Number(b.local_timestamp) * 1000).toISOString() }); } out.sort((a, b) => a.height - b.height); return { truncated: false, block_count: out.length, checked_at: new Date().toISOString(), blocks: out }; } function lowerBoundFunding(sources, own, kindOf, entrant, stakeRaw, hop0) { const nul = { lower_raw: null, lower_other_raw: null, shown_at_least_stake: null, shown_at_least_stake_other: null, lower_reset_raw: null, lower_reset_other_raw: null, shown_at_least_stake_reset: null, shown_at_least_stake_reset_other: null, wash_cycle_in_any_source: null, wash_cycle_in_any_source_other: null, wash_cycle_in_transfer_stretch_any_source: null, wash_cycle_in_transfer_stretch_any_source_other: null, per_source: [] }; if (!sources) return nul; const stake = stakeRaw === undefined || stakeRaw === null ? null : BigInt(stakeRaw); const min = (a, b) => (a < b ? a : b), max0 = a => (a < 0n ? 0n : a); const sum = (list, f) => list.filter(f).reduce((a, x) => a + BigInt(x.amount_raw), 0n); const empty = (isOwn, chain) => ({ operator_address: isOwn, chain_blocks: chain && typeof chain.block_count === 'number' ? chain.block_count : null, chain_truncated: chain ? !!chain.truncated : null, first_operator_payment_block: null, first_other_operator_payment_block: null, balance_before_first_operator_payment_raw: null, balance_before_first_other_operator_payment_raw: null, non_operator_received_before_last_transfer_raw: null, non_other_operator_received_before_last_transfer_raw: null, transferred_after_first_operator_payment_chain_raw: null, transferred_after_first_other_operator_payment_chain_raw: null, operator_money_lower_bound_raw: null, operator_money_lower_bound_other_raw: null, zero_balance_blocks_in_window: null, zero_balance_blocks_in_window_excluding_ladder_returns: null, operator_money_lower_bound_zero_reset_raw: null, operator_money_lower_bound_zero_reset_other_raw: null, wash_cycle_receipts: null, wash_cycle_receipts_excluding_ladder_returns: null, wash_cycle_in_window: null, wash_cycle_in_window_excluding_ladder_returns: null, wash_cycle_in_transfer_stretch: null, wash_cycle_in_transfer_stretch_excluding_ladder_returns: null }); const per = sources.map(s => { const isOwn = own.has(s.account); if (isOwn) { const recv = Array.isArray(s.receives) ? s.receives : null; if (!recv || hop0 === null) return empty(true, null); const total = sum(recv, () => true), other = min(sum(hop0, p => p.kind === 'other'), total); return { ...empty(true, null), transferred_after_first_operator_payment_chain_raw: total, transferred_after_first_other_operator_payment_chain_raw: other, operator_money_lower_bound_raw: total, operator_money_lower_bound_other_raw: other, operator_money_lower_bound_zero_reset_raw: total, operator_money_lower_bound_zero_reset_other_raw: other, wash_cycle_receipts: [], wash_cycle_receipts_excluding_ladder_returns: [], wash_cycle_in_window: false, wash_cycle_in_window_excluding_ladder_returns: false, wash_cycle_in_transfer_stretch: false, wash_cycle_in_transfer_stretch_excluding_ladder_returns: false }; } const chain = s.chain; if (!chain || chain.truncated || chain.legacy || !Array.isArray(chain.blocks)) return empty(false, chain || null); const blocks = chain.blocks; const toEntrant = blocks.filter(b => b.subtype === 'send' && b.account === entrant); const opRecv = blocks.filter(b => (b.subtype === 'receive' || b.subtype === 'open') && b.account && own.has(b.account)).map(b => ({ ...b, kind: kindOf(b.link) || 'other' })); // One reading of the chain, for one notion of "operator payment" (any kind, or kind other only). Fifth change // of 2026-09-22, after swipepredictbot's fifth review: the original figures (b0, n, t, lower) are unchanged in // every case but one, noted below; the zero-reset bound, the wash-cycle detector and the self-send fix are // additions. const view = b => (b ? { height: b.height, hash: b.hash, at: b.at, amount_nano: nanoStr(b.amount_raw), kind: b.kind } : null); const isRecv = b => b.subtype === 'receive' || b.subtype === 'open'; const isOpRecv = (b, counted) => !!(b.account && own.has(b.account) && counted({ ...b, kind: kindOf(b.link) || 'other' })); const run = counted => { const first = opRecv.find(counted) || null; const none = { first, b0: first ? BigInt(first.balance_raw) - BigInt(first.amount_raw) : null, n: 0n, t: 0n, lower: 0n, zeros: [], lower_reset: 0n, cycles: [], cycle_in_transfer_stretch: false }; if (!first || !toEntrant.length) return none; const lastH = Math.max(...toEntrant.map(b => b.height)); const b0 = BigInt(first.balance_raw) - BigInt(first.amount_raw); // Self-sends: raw the source sent to itself sits outside its balance until it receives it back. A self-receive // whose self-send happened earlier in the same stretch returns raw already accounted for and does not count; // one whose self-send lies before the stretch (before the first operator payment, or before a zero point) // brings in raw that neither the balance-before term nor the receipts term saw, so it counts as a receipt. // The original rule excluded every self-receive; that overstated the bound in the pending-across-the-boundary // case, so this is the one place the original figure can move (it does not on any chain stored so far). const stretches = []; // [{ start, end }] : receipts with start < h < end, transfers with start < h <= end const zeros = blocks.filter(b => b.height > first.height && b.height < lastH && BigInt(b.balance_raw) === 0n).map(b => b.height); let start = first.height; for (const z of zeros) { stretches.push({ start, end: z }); start = z; } stretches.push({ start, end: lastH }); const receiptsIn = (st, allSelf) => { const pendingSelf = []; // amounts of self-sends seen so far in this stretch, unmatched let n = 0n; for (const b of blocks) { if (b.height <= st.start || b.height >= st.end) continue; if (b.subtype === 'send' && b.account === s.account) { pendingSelf.push(BigInt(b.amount_raw)); continue; } if (!isRecv(b)) continue; if (b.account === s.account) { const i = pendingSelf.indexOf(BigInt(b.amount_raw)); if (i >= 0 && !allSelf) { pendingSelf.splice(i, 1); continue; } n += BigInt(b.amount_raw); continue; } if (isOpRecv(b, counted)) continue; n += BigInt(b.amount_raw); } return n; }; const whole = { start: first.height, end: lastH }; const t = sum(toEntrant, b => b.height > first.height); const n = receiptsIn(whole, false); const lower = max0(t - b0 - n); // zero-reset bound: cut the chain at every block after the first operator payment where the balance is zero // and before the last transfer; bound each stretch on its own (balance-before is b0 for the first stretch and // zero for every later one, since whatever the source held before it emptied cannot be in what it sent after); // sum the stretches. Each stretch bound is a valid lower bound on its own, so the sum is one too, and it is // never below the single-window figure. const per = stretches.map((st, i) => { const ti = sum(toEntrant, b => b.height > st.start && b.height <= st.end); const ni = receiptsIn(st, false); const bi = i === 0 ? b0 : 0n; return { st, t: ti, n: ni, b0: bi, lower: max0(ti - bi - ni) }; }); const lowerReset = per.reduce((a, x) => a + x.lower, 0n); // wash-cycle detector (a flag beside the bound, never folded into it): a receipt inside the window from an // address the source itself sent to earlier in the window. That is the signature of money going out and // coming back, which the receipts term then counts as someone else's; a cycle means the bound may understate // operator money, so a "not shown" next to a cycle is weaker than a "not shown" without one. Absence of a // cycle proves nothing (the return leg can take a longer path). const cycles = []; for (const b of blocks) { if (!isRecv(b) || b.height <= first.height || b.height >= lastH || !b.account || b.account === s.account || own.has(b.account)) continue; const back = blocks.filter(x => x.subtype === 'send' && x.account === b.account && x.height > first.height && x.height < b.height); if (!back.length) continue; const stretch = per.find(x => b.height > x.st.start && b.height < x.st.end) || null; cycles.push({ receive: view(b), earlier_send: view(back[back.length - 1]), account: b.account, in_transfer_stretch: !!(stretch && stretch.t > 0n) }); } return { first, b0, n, t, lower, zeros, lower_reset: lowerReset, cycles, cycle_in_transfer_stretch: cycles.some(c => c.in_transfer_stretch) }; }; const any = run(() => true), other = run(b => b.kind === 'other'); return { operator_address: false, chain_blocks: blocks.length, chain_truncated: false, first_operator_payment_block: view(any.first), first_other_operator_payment_block: view(other.first), balance_before_first_operator_payment_raw: any.b0, balance_before_first_other_operator_payment_raw: other.b0, non_operator_received_before_last_transfer_raw: any.n, non_other_operator_received_before_last_transfer_raw: other.n, transferred_after_first_operator_payment_chain_raw: any.t, transferred_after_first_other_operator_payment_chain_raw: other.t, operator_money_lower_bound_raw: any.lower, operator_money_lower_bound_other_raw: other.lower, zero_balance_blocks_in_window: any.zeros, zero_balance_blocks_in_window_excluding_ladder_returns: other.zeros, operator_money_lower_bound_zero_reset_raw: any.lower_reset, operator_money_lower_bound_zero_reset_other_raw: other.lower_reset, wash_cycle_receipts: any.cycles, wash_cycle_receipts_excluding_ladder_returns: other.cycles, wash_cycle_in_window: any.cycles.length > 0, wash_cycle_in_window_excluding_ladder_returns: other.cycles.length > 0, wash_cycle_in_transfer_stretch: any.cycle_in_transfer_stretch, wash_cycle_in_transfer_stretch_excluding_ladder_returns: other.cycle_in_transfer_stretch }; }); const total = k => (per.some(x => x[k] === null) ? null : per.reduce((a, x) => a + x[k], 0n)); const lower = total('operator_money_lower_bound_raw'), lowerOther = total('operator_money_lower_bound_other_raw'); const lowerReset = total('operator_money_lower_bound_zero_reset_raw'), lowerResetOther = total('operator_money_lower_bound_zero_reset_other_raw'); const anyCycle = k => (per.some(x => x[k] === null) ? null : per.some(x => x[k] === true)); const ge = (v) => (stake === null || v === null ? null : v >= stake); return { lower_raw: lower, lower_other_raw: lowerOther, shown_at_least_stake: ge(lower), shown_at_least_stake_other: ge(lowerOther), lower_reset_raw: lowerReset, lower_reset_other_raw: lowerResetOther, shown_at_least_stake_reset: ge(lowerReset), shown_at_least_stake_reset_other: ge(lowerResetOther), wash_cycle_in_any_source: anyCycle('wash_cycle_in_window'), wash_cycle_in_any_source_other: anyCycle('wash_cycle_in_window_excluding_ladder_returns'), wash_cycle_in_transfer_stretch_any_source: anyCycle('wash_cycle_in_transfer_stretch'), wash_cycle_in_transfer_stretch_any_source_other: anyCycle('wash_cycle_in_transfer_stretch_excluding_ladder_returns'), per_source: per }; } // The closed list of operator payment kinds the funding fields use, with a version. Adding or splitting a kind bumps // the version and is a field change under the ladder rule: existing fields keep their meaning and a new kind gets a // new field. Published on the entries and robustness endpoints as `payment_kinds` (swipepredictbot, 2026-09-22). const PAYMENT_KINDS = Object.freeze({ version: 1, kinds: { ladder_payout: 'ledger reason matches /ladder round \\d+ payout/i: a prize paid by this ladder', ladder_refund: 'ledger reason matches /ladder round \\d+:? return of/i or /surplus stake/i: a stake or surplus stake sent back by this ladder', other: 'every other payment_out row in the operator\'s public ledger: seeds, purchases, seller credits, bounties, research fees, and anything not yet given its own kind' }, rule: 'closed list; a new or split kind bumps version and adds a field rather than changing what an existing field means' }); // Which kind of operator payment a public-ledger row is, from its reason text. The ladder's own prizes and // stake refunds went out because the address entered a round; they are not money that funded an entry. // Added 2026-09-22 after swipepredictbot noted that a returning winner is hop-0 flagged by construction. function paymentKind(reason) { const r = String(reason || ''); if (/ladder round \d+ payout/i.test(r)) return 'ladder_payout'; if (/ladder round \d+:? return of/i.test(r) || /surplus stake/i.test(r)) return 'ladder_refund'; return 'other'; } module.exports = { fundingSources, inOrderFunding, amountFunding, sourceChain, lowerBoundFunding, PAYMENT_KINDS, SEND_THRESHOLD, RAW_PER_NANO, ADDRESS_RE, NONCE_RE, QID_RE, canonical, blake2bHex, messageOf, workValue, validWork, validSignature, sign, checkShape, brier, payoutTable, nanoStr, readJson, writeJsonAtomic, phase, paymentKind, textThresholdMismatches };