// Resolve a round: fetch every question's source, record value + snippet, set // outcomes, score entries (mean Brier), rank, and write the payout table. // Usage: node resolve.js [--dry-run] (dry run fetches and prints only) 'use strict'; const path = require('path'); const L = require('./ladder'); const DATA = process.env.DATA_DIR || path.join(__dirname, 'data'); const RPC = process.env.NANO_RPC || 'http://127.0.0.1:7076'; const id = process.argv[2], dry = process.argv.includes('--dry-run'); if (id === undefined) { console.error('usage: node resolve.js [--dry-run]'); process.exit(2); } const round = L.readJson(path.join(DATA, 'rounds.json')).rounds.find(r => String(r.id) === String(id)); if (!round) { console.error('no such round'); process.exit(2); } function pick(obj, jsonPath) { // "a.b.0" -> obj.a.b[0]; "" -> obj return jsonPath ? jsonPath.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj) : obj; } async function fetchValue(q) { const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 20000); const opts = { signal: ctl.signal, headers: { 'user-agent': 'nano-ladder-resolver/0.1', accept: 'application/json' } }; if (q.source_body) { opts.method = 'POST'; opts.body = JSON.stringify(q.source_body); } const r = await fetch(q.source_url, opts).finally(() => clearTimeout(t)); const text = await r.text(); if (!r.ok) throw new Error('HTTP ' + r.status + ' ' + text.slice(0, 200)); const raw = pick(JSON.parse(text), q.json_path); const value = Number(raw); if (raw === undefined || Number.isNaN(value)) throw new Error('json_path ' + q.json_path + ' gave ' + JSON.stringify(raw)); return { value, fetched_at: new Date().toISOString(), snippet: text.slice(0, 400) }; } // Paid rounds: the pot is the seed (round.pot_raw) plus every entry's stake block // amount, read from the node; if block_info fails the round's stake_raw is used. async function stakesRaw(round, entries) { if (BigInt(round.stake_raw || 0) === 0n) return 0n; let sum = 0n; for (const [address, e] of Object.entries(entries)) { let amt = BigInt(round.stake_raw); try { const r = await fetch(RPC, { method: 'POST', body: JSON.stringify({ action: 'block_info', json_block: 'true', hash: e.submission.stake_hash }) }); const b = await r.json(); if (b.amount) amt = BigInt(b.amount); else console.error('stake block_info for ' + address + ': ' + JSON.stringify(b).slice(0, 120) + '; using stake_raw'); } catch (err) { console.error('stake block_info failed for ' + address + ': ' + err.message + '; using stake_raw'); } sum += amt; } return sum; } function outcomeOf(q, value) { if (q.comparator === 'above') return value > q.threshold ? 1 : 0; if (q.comparator === 'below') return value < q.threshold ? 1 : 0; throw new Error('bad comparator ' + q.comparator); } (async () => { if (!dry && Date.now() < Date.parse(round.resolves_at)) { console.error('round ' + id + ' resolves at ' + round.resolves_at + '; refusing without --dry-run'); process.exit(1); } const resolutions = {}, outcomes = {}, voided = []; for (const q of round.questions) { let r; for (let attempt = 1; attempt <= 3 && !r; attempt++) { try { r = await fetchValue(q); } catch (e) { console.error(q.id + ': attempt ' + attempt + ' failed: ' + e.message); await new Promise(s => setTimeout(s, 3000)); } } if (!r) { voided.push(q.id); console.log(q.id + ': VOIDED (source unreachable)'); continue; } const outcome = outcomeOf(q, r.value); resolutions[q.id] = { ...r, threshold: q.threshold, comparator: q.comparator, outcome, source_url: q.source_url, json_path: q.json_path }; outcomes[q.id] = outcome; console.log(q.id.padEnd(14), String(r.value).padStart(14), q.comparator, q.threshold, '->', outcome, ' ', r.fetched_at); } if (dry) { console.log('dry run: nothing written'); return; } const allRes = L.readJson(path.join(DATA, 'resolutions.json'), {}); allRes[round.id] = { resolved_at: new Date().toISOString(), questions: resolutions, voided }; L.writeJsonAtomic(path.join(DATA, 'resolutions.json'), allRes); const entries = L.readJson(path.join(DATA, 'entries.json'), {})[round.id] || {}; const rows = Object.entries(entries).map(([address, e]) => ({ address, brier: L.brier(e.submission.forecasts, outcomes), received_at: e.received_at })); const stakes = await stakesRaw(round, entries); const potRaw = (BigInt(round.pot_raw) + stakes).toString(); const table = L.payoutTable(rows, potRaw); const out = { round: round.id, resolved_at: allRes[round.id].resolved_at, pot_raw: potRaw, pot_nano: L.nanoStr(potRaw), seed_raw: round.pot_raw, stakes_raw: stakes.toString(), outcomes, voided, entrants: table.length, table }; L.writeJsonAtomic(path.join(DATA, 'results', round.id + '.json'), out); console.log('wrote data/results/' + round.id + '.json with ' + table.length + ' entrants; pot ' + L.nanoStr(potRaw) + ' XNO (seed ' + L.nanoStr(round.pot_raw) + ' + stakes ' + L.nanoStr(stakes) + ')'); for (const t of table) console.log(String(t.rank).padStart(3), t.address, t.brier.toFixed(4), t.payout_nano + ' XNO'); })().catch(e => { console.error(e); process.exit(1); });