PageSourceSearch

https://ashenthr.one/token.js

js ashenthr.one collected 2026-10-04 14:47:21 UTC 92,422 bytes, 1,770 lines download raw bytes

1/* token.js - the shop TOKEN tab (ashenthrone.com/shop#token).
2
3   Renders GET /api/token/status (server/game/tokenLaunch.js) into the static
4   markup in shop.html. Two phases, one layout: the HERO swaps between the
5   prelaunch notice and the live contract-address plate; the tokenomics cards
6   render from the same payload in both phases.
7
8   THE CA IS NEVER TYPED HERE. It comes from the endpoint, which the server
9   verified on chain before flipping, so a wrong address on the BUY button is
10   unrepresentable from this file. The page polls every `pollSeconds` while it
11   is visible (faster than a Discord post can be read), so the moment the
12   server flips, buyers on this page see the address without a reload.
13
14   STAKING CARD (docs/token-launch-design.md section 2, concept sheet
15   2026-09-17): once the server publishes a staking address the card drives
16   contracts/ThroneStaking.sol through the hub wallet rail. Reads are plain
17   eth_call against the chain's public RPC (no wallet needed to LOOK), falling
18   back to the same-origin POST /api/token/chain-read when the player's
19   network blocks that host (CHAIN TRANSPORT below); writes go through
20   hubWallet.sendTransaction on the token's chain. States, in the
21   order they are decided: no contract / wallet rail absent / disconnected /
22   wrong chain / connected (nothing staked, staked, unbonding, ready), with a
23   "link this wallet" notice layered on top when the connected wallet is not
24   the verified wallet of a signed-in account (staking works on chain either
25   way; the allowance only lands on a linked account).
26
27   TEAM LOCK CARD: when the server publishes the ThroneTeamLock address the
28   card reads locked / released / cliff / duration from the chain so the
29   claim "bought from one wallet and locked" is a number a reader can check.
30
31   DAILY ASH DROP (owner 2026-09-29: "they need to see it"): the first block
32   on the tab states the pool, who shares it, and the live numbers that show
33   it is paid, from GET /api/token/stats (server/game/tokenStats.js), plus a
34   share calculator. The pool figure falls back to the status payload's floor
35   config; every other live figure is hidden when it cannot be read, never
36   shown as zero. The same pool feeds the shop rail and the Golden Ash tab
37   callout.
38
39   ONE FLEET, ONE RECORD: the status, the drop stats and the proof are read
40   from the GATEWAY front door (hubSession.gatewayUrl: same-origin, which the
41   Vercel rewrite sends to the gateway, or the ?api= test override), never
42   the signed-in player's home shard. The gateway alone takes the one fleet
43   snapshot and keeps its ledger, so every visitor, signed in on any shard or
44   not at all, reads the same figures. Only the personal reads (the allowance,
45   the linked wallet) go to the home shard, through hubSession.api.
46
47   STALE STATE: a failed poll never blanks the page. The last good payload
48   stays painted and a banner says how old it is.
49
50   Every player-facing literal is wrapped in t(...) so the landing l10n
51   extractor harvests it (scripts/l10n-landing-extract.js scans this file).
52   Guard: scripts/verify-token-launch.js */
53(function () {
54  const STATUS_PATH = '/api/token/status';
55  const PROOF_PATH = '/api/token/proof';
56  // The server builds the proof at most once a minute; the page asks no faster.
57  const PROOF_REFRESH_MS = 60000;
58  // The daily drop numbers: built at most once a minute server side too.
59  const STATS_PATH = '/api/token/stats';
60  const STATS_REFRESH_MS = 60000;
61  // A drop record this old is dropped rather than shown as live.
62  const STATS_MAX_AGE_MS = 5 * 60000;
63  const FALLBACK_POLL_SECONDS = 15;
64  const FETCH_TIMEOUT_MS = 10000;
65  const RPC_TIMEOUT_MS = 8000;
66  // The same-origin chain-read fallback (server/game/tokenChainRead.js).
67  const CHAIN_READ_PATH = '/api/token/chain-read';
68  // A public host gets a shorter leash than the fallback, which still needs
69  // its own RPC_TIMEOUT_MS after it. A host that failed is skipped (reads go
70  // straight to the fallback): DIRECT_RETRY_MS after a network failure or a
71  // timeout (what a block looks like), DIRECT_BUSY_RETRY_MS after an HTTP
72  // error or a node error (a busy node, back soon). A skipped host is still
73  // tried as a last resort when the fallback fails too, and RETRY clears it.
74  const DIRECT_RPC_TIMEOUT_MS = 6000;
75  const DIRECT_RETRY_MS = 5 * 60000;
76  const DIRECT_BUSY_RETRY_MS = 30000;
77  // Failed read attempts (every path failed), a poll apart, before the page
78  // stops saying "Retrying" and shows the RETRY notice.
79  const CHAIN_FAILS_FOR_NOTICE = 2;
80  const RECEIPT_POLLS = 60;
81  const RECEIPT_POLL_MS = 2000;
82  const PENDING_RECHECK_MS = 5000;
83  const MSG_HOLD_MS = 20000;
84  const DECIMALS = 18n;
85  const ONE = 10n ** DECIMALS;
86  // Function selectors for contracts/ThroneToken.sol, ThroneStaking.sol and
87  // ThroneTeamLock.sol (keccak256 of the signature, first 4 bytes).
88  const SEL = {
89    balanceOf: '0x70a08231', allowance: '0xdd62ed3e', approve: '0x095ea7b3',
90    stake: '0xa694fc3a', requestUnstake: '0x23095721', withdraw: '0x3ccfd60b',
91    stakedOf: '0xaf500ba3', pendingOf: '0xf44136a1', totalStaked: '0
91x817b1cd2', unbondDelay: '0x51127fca', beneficiary: '0x38af3eed', totalSupply: '0x18160ddd',
92    locked: '0xcf309012', released: '0x96132521', cliff: '0x13d033c0', duration: '0x0fb5a6b4',
93  };
94
95  // Same shape as landing/i18n.js root.t: (source, ...params) with {0} slots.
96  function t(s) {
97    const params = Array.prototype.slice.call(arguments, 1);
98    if (typeof window.t === 'function') return window.t.apply(window, [s].concat(params));
99    return String(s).replace(/\{(\d+)\}/g, (m, i) => (params[i] == null ? m : String(params[i])));
100  }
101  function $(id) { return document.getElementById(id); }
102
103  // The fleet-wide public records (status, drop stats, proof) always come
104  // from the gateway front door, never the home shard: see ONE FLEET, ONE
105  // RECORD above. Outside the hub shell a plain same-origin fetch.
106  function publicUrl(path) {
107    try {
108      if (window.hubSession && typeof window.hubSession.gatewayUrl === 'function') return window.hubSession.gatewayUrl(path);
109    } catch (_) { /* fall through */ }
110    return path;
111  }
112
113  // clockOffset: server now minus device now, so the countdown follows the
114  // server clock rather than a skewed device.
115  const st = {
116    status: null, timer: null, countdownTimer: null, lastPhase: null, clockOffset: 0, inFlight: null, seq: 0,
117    lastOkAt: 0, stale: false,
118    // staking
119    wallet: { address: '', chainId: null, linked: null, linkedAddress: '', proofElsewhere: false },
120    vault: null, vaultSeq: 0, busy: '', unstakeOpen: false, op: null, pendingTx: null, msgHold: 0, lockSeq: 0,
121    lock: null,
122    allowance: null,
123    // PROOF: the public record (/api/token/proof) and the two chain reads the
124    // page makes itself (vault totalStaked, lock beneficiary), each keyed to
125    // the identity it was read for, like the lock card.
126    proof: null, proofAt: 0, proofFailAt: 0, proofSeq: 0, proofChain: null, proofChainSeq: 0, proofChainAt: 0,
127    // DAILY ASH DROP: the public /api/token/stats record.
128    stats: null, statsAt: 0, statsFailAt: 0, statsSeq: 0,
129    // CHAIN TRANSPORT: public hosts skipped until a time, and the read
130    // health behind the RETRY notice (chainRound).
131    // Health is kept PER CARD (vault, lock, PROOF): shared reads served from
132    // the server's cache must not mask a wallet read that keeps failing.
133    rpcDown: {}, chainGen: 0, chainRetrying: false, chainKind: 'network',
134    chainHealth: { vault: { fails: 0, gen: -1 }, lock: { fails: 0, gen: -1 }, proof: { fails: 0, gen: -1 } },
135  };
136
137  function fmtInt(n) {
138    const v = Number(n);
139    if (!Number.isFinite(v)) return '…';
140    try { return v.toLocaleString('en-US'); } catch (_) { return String(v); }
141  }
142
143  // wei -> "12,500.25" (2 decimals, trailing zeros trimmed)
144  function fmtTokens(wei) {
145    let v;
146    try { v = BigInt(wei); } catch (_) { return '…'; }
147    const whole = v / ONE;
148    const frac = v % ONE;
149    let out = fmtInt(Number(whole));
150    const cents = Number((frac * 100n) / ONE);
151    if (cents > 0) out += `.${String(cents).padStart(2, '0').replace(/0$/, '')}`;
152    return out;
153  }
154
155  // A typed amount as a plain decimal string ("12500.25"), or null. The page
156  // writes numbers en-US ("12,500"), so a comma is a thousands separator
157  // only where it groups exactly three digits ("1,500", "12,500.5"); a comma
158  // anywhere else is the decimal separator a tr/id/ru phone keypad types
159  // ("12,5" is 12.5, "1500,5" is 1500.5), never deleted: stripping it read
160  // "12,5" as 125 and "1500,5" as 15005 (review 2026-09-29). Spaces used as
161  // group separators are dropped. Anything else is not an amount.
162  function normalizeAmount(text) {
163    const s = String(text == null ? '' : text).trim().replace(/[\s\u00a0\u202f]/g, '');
164    if (/^\d+(\.\d+)?$/.test(s)) return s;
165    if (/^\d{1,3}(,\d{3})+(\.\d+)?$/.test(s)) return s.replace(/,/g, '');
166    if (/^\d+,\d+$/.test(s)) return s.replace(',', '.');
167    return null;
168  }
169
170  // "12500.25" -> wei, null on anything that is not a plain positive decimal.
171  function parseTokens(text) {
172    const s = normalizeAmount(text);
173    if (s == null) return null;
174    const [w, f = ''] = s.split('.');
175    const fracPadded = (f + '0'.repeat(Number(DECIMALS))).slice(0, Number(DECIMALS));
176    const v = BigInt(w) * ONE + BigInt(fracPadded);
177    return v > 0n ? v : null;
178  }
179
180  function show(el, on) {
181    if (!el) return;
182    el.classList.toggle('hub-hidden', !on);
183  }
184
185  function setLink(el, href) {
186    if (!el) return;
187    if (href) { el.href = href; show(el, true); }
188    else { el.removeAttribute('href'); show(el, false); }
189  }
190
191  function setText(id, text) { const el = $(id); if (el) el.textContent = text; }
192
193  // Ambient lines (status, read errors) yield to a transaction line: they are
194  // written through ambient(), which is silent while an operation runs, an
195  // outcome is unknown, or a transaction line is still being held.
196  function ambient(text, type) { if (st.busy || st.pendingTx || Date.now() <= st.msgHold) return; setMessage(text, type); }
197  function setMessage(text, type) {
198    const el = $('tokenMessage');
199    if (!el) return;
200    el.textContent = text;
201    el.className = `mint-log${type ? ` ${type}` : ''}`;
202  }
203
204  // The site's ONE countdown format lives in landing/ash-drop.js (loaded
205  // before this file on shop.html), so this tab and the home band show the
206  // same moment the same way ("1d 03:03:56"). Without it a countdown is left
207  // blank rather than printed in a second format.
208  function fmtCountdown(secs) {
209    const shared = window.ashDrop && window.ashDrop.fmtCountdown;
210    return typeof shared === 'function' ? shared(secs) : '';
211  }
212
213  function serverNow() { return Math.floor(Date.now() / 1000) + st.clockOffset; }
214
215  function fmtDate(epoch) {
216    const n = Number(epoch);
217    if (!Number.isFinite(n) || n <= 0) return '…';
218    try { return new Date(n * 1000).toISOString().slice(0, 10); } catch (_) { return String(n); }
219  }
220
221  function shortAddr(a) { return a && a.length > 12 ? `${a.slice(0, 6)}…${a.slice(-4)}` : String(a || ''); }
222
223  function renderCountdown() {
224    const s = st.status;
225    const wrap = $('tokenCountdown');
226    if (!s || s.phase !== 'prelaunch' || !s.launchAt) { show(wrap, false); return; }
227    const remaining = s.launchAt - serverNow();
228    show(wrap, true);
229    const value = $('tokenCountdownValue');
230    if (value) value.textContent = remaining > 0 ? fmtCountdown(remaining) : t('ANY MOMENT');
231  }
232
233  // ── stale banner ──────────────────────────────────────────────────────
234  function renderStale() {
235    const banner = $('tokenStale');
236    if (!banner) return;
237    if (!st.stale || !st.status) { show(banner, false); return; }
238    const mins = Math.max(1, Math.round((Date.now() - st.lastOkAt) / 60000));
239    setText('tokenStaleText', t('Offline. Last update {0} min ago.', mins));
240    show(banner, true);
241  }
242
243  // ── tokenomics ────────────────────────────────────────────────────────
244  function renderTokenomics(s) {
245    const ticker = String(s.ticker || 'THRONE');
246    setText('tokenTicker', ticker);
247    setText('tokenSupply', `${fmtInt(s.totalSupply)} $${ticker}`);
248    setText('tokenChainMeta', String(s.chain && s.chain.name ? s.chain.name : 'Base').toUpperCase());
249    setText('tokenEmissionPct', `${s.emission.revenuePct}% ${t('OF REVENUE')}`);
250    // Whole sentences with {0}, never number + noun composed at runtime, so a
251    // multi-plural locale can restructure the sentence around the value.
252    const unbond = s.staking && s.staking.unbondDays;
253    const expiry = s.emission.allowanceExpiryDays;
254    // The page carries numbers and actions; every explanation lives in a tip
255    // (TIPS below). Whole sentences with {0} so a locale can restructure them.
256    setText('tokenPayTitle', s.stakingAddress ? t('HOW STAKING PAYS') : t('HOW STAKING WILL PAY'));
257    setText('tokenUnbondLine', t('Stake below. Unstaking takes {0} days.', unbond));
258    const drop = liveStats();
259    const offset = drop && drop.schedule && drop.schedule.utcOffsetSeconds != null ? drop.schedule.utcOffsetSeconds : null;
260    setText('tokenPoolLine', offset != null ? t('Lands in your game account at {0}
260 UTC.', fmtOffset(offset)) : t('Lands in your game account daily.'));
261    setText('tokenExpiryLine', t('Spend it in {0} days.', expiry));
262
263    // Allocation bar + legend, derived from the payload's percentages.
264    const alloc = s.allocation || {};
265    const liquidity = Number(alloc.liquidityPct) || 0;
266    const team = Number(alloc.teamCommunityPct) || 0;
267    const bar = $('tokenAlloc');
268    if (bar) {
269      bar.innerHTML = '';
270      const seg = (cls, pct) => {
271        const d = document.createElement('div');
272        d.className = `token-alloc__seg ${cls}`;
273        d.style.width = `${pct}%`;
274        bar.appendChild(d);
275      };
276      seg('token-alloc__seg--public', liquidity);
277      seg('token-alloc__seg--team', team);
278    }
279    const legend = $('tokenAllocLegend');
280    if (legend) {
281      legend.innerHTML = '';
282      const row = (cls, label, pct) => {
283        const dt = document.createElement('dt');
284        dt.innerHTML = `<i class="token-alloc__swatch ${cls}"></i>`;
285        dt.appendChild(document.createTextNode(label));
286        const dd = document.createElement('dd');
287        dd.textContent = `${pct}%`;
288        legend.appendChild(dt); legend.appendChild(dd);
289      };
290      row('token-alloc__seg--public', t('Public'), liquidity);
291      row('token-alloc__seg--team', t('Team + community, locked'), team);
292    }
293
294    const tiers = $('tokenTiers');
295    if (tiers) {
296      tiers.innerHTML = '';
297      const list = (s.staking && s.staking.tiers) || [];
298      const stakedTokens = st.vault ? Number(st.vault.staked / ONE) : -1;
299      let current = null;
300      for (const tier of list) if (stakedTokens >= Number(tier.minStaked)) current = tier.key;
301      let nextMarked = false;
302      for (const tier of list) {
303        const row = document.createElement('div');
304        row.className = 'hud-row token-tier';
305        if (tier.key === current) row.className += ' token-tier--current';
306        const dt = document.createElement('dt');
307        dt.textContent = String(tier.label || tier.key || '').toUpperCase();
308        const dd = document.createElement('dd');
309        if (tier.key === current) dd.textContent = t('YOUR TIER');
310        else if (stakedTokens >= 0 && !nextMarked && stakedTokens < Number(tier.minStaked)) {
311          nextMarked = true;
312          row.className += ' token-tier--next';
313          dd.textContent = t('{0} more', fmtInt(Number(tier.minStaked) - stakedTokens));
314        } else dd.textContent = t('{0} staked', fmtInt(tier.minStaked));
315        row.appendChild(dt); row.appendChild(dd);
316        tiers.appendChild(row);
317      }
318    }
319    setLink($('tokenStakingLink'), s.links && s.links.staking);
320  }
321
322  // ── hero ──────────────────────────────────────────────────────────────
323  function renderPhase(s) {
324    const panel = $('tokenPanel');
325    const live = s.phase === 'launched' && !!s.contractAddress;
326    if (panel) panel.setAttribute('data-phase', live ? 'launched' : 'prelaunch');
327    show($('tokenPre'), !live);
328    show($('tokenLive'), live);
329    renderRail();
330
331    if (live) {
332      setText('tokenCa', s.contractAddress);
333      setText('tokenLiveDot', `${t('LIVE ON')} ${String(s.chain.name).toUpperCase()}`);
334      const note = $('tokenVerifiedNote');
335      if (note) {
336        note.textContent = s.verifiedSymbol ? t('Verified on chain.') : t('Posted by the team.');
337      }
338      // BUY only ever points at a buy surface (server policy); with none, the
339      // explorer link stands alone rather than wearing the BUY label.
340      setLink($('tokenBuyLink'), s.links.buy);
341      setLink($('tokenChartLink'), s.links.chart);
342      setLink($('tokenExplorerLink'), s.links.explorer);
343      setLink($('tokenTxLink'), s.links.tx);
344      // While the chain notice is up the log line keeps saying what is wrong,
345      // rather than flipping back to "is live" on every poll.
346      if (chainDown()) ambient(chainDownLine(), 'bad');
347      else ambient(`${t('$THRONE is live.')} ${s.contractAddress}`, 'ok');
348    } else {
349      // A reset un-publishes the address: the hidden plate must not keep it
350      // (found by scripts/token-staking-ui-e2e.js: the old CA stayed in the DOM).
351      setText('tokenCa', '…');
352      for (const id of ['tokenBuyLink', 'tokenChartLink', 'tokenExplorerLink', 'tokenTxLink']) setLink($(id), null);
353      setLink($('tokenVenueLink'), s.links && s.links.launchpad);
354      if (s.links && s.links.discord && $('tokenDiscordLink')) $('tokenDiscordLink').href = s.links.discord;
355      if (s.links && s.links.x && $('tokenXLink')) $('tokenXLink').href = s.links.x;
356      renderCountdown();
357      ambient(s.launchAt ? t('Launch scheduled. This page updates itself.') : t('Not live yet. This page updates itself.'));
358    }
359    if (live && st.countdownTimer) { clearInterval(st.countdownTimer); st.countdownTimer = null; }
360    if (!live && !st.countdownTimer) st.countdownTimer = setInterval(() => { if (!document.hidden) renderCountdown(); }, 1000);
361    if (live && st.lastPhase === 'prelaunch') {
362      // The flip happened while the reader was on the page: make it obvious.
363      const plate = $('tokenLive');
364      if (plate) { plate.classList.remove('token-hero--flip'); void plate.offsetWidth; plate.classList.add('token-hero--flip'); }
365    }
366    st.lastPhase = live ? 'launched' : 'prelaunch';
367  }
368
369  // ── chain reads (no wallet) ───────────────────────────────────────────
370  function pad32(hex) { return String(hex).replace(/^0x/, '').padStart(64, '0'); }
371  function encAddress(a) { return pad32(String(a).toLowerCase()); }
372  function encUint(v) { return pad32(BigInt(v).toString(16)); }
373  function decUint(hex, index) {
374    const body = String(hex || '').replace(/^0x/, '');
375    const word = body.slice((index || 0) * 64, (index || 0) * 64 + 64);
376    // A short answer ("0x" from an address with no contract) is no answer,
377    // never a zero: every caller reads it as a failed read.
378    if (!/^[0-9a-fA-F]{64}$/.test(word)) throw new Error('empty eth_call answer');
379    return BigInt(`0x${word}`);
380  }
381
382  async function rpcCall(to, data) {
383    const s = st.status;
384    if (!s || !s.chain) throw new Error('no chain');
385    return rpcOn(chainOp(s), 'eth_call', [{ to, data }, 'latest']);
386  }
387
388  async function readVault(address) {
389    const s = st.status;
390    const vault = s.stakingAddress;
391    const token = s.contractAddress;
392    const [bal, allowance, staked, pending, delay] = await Promise.all([
393      rpcCall(token, SEL.balanceOf + encAddress(address)),
394      rpcCall(token, SEL.allowance + encAddress(address) + encAddress(vault)),
395      rpcCall(vault, SEL.stakedOf + encAddress(address)),
396      rpcCall(vault, SEL.pendingOf + encAddress(address)),
397      rpcCall(vault, SEL.unbondDelay),
398    ]);
399    return {
400      address, balance: decUint(bal), allowance: decUint(allowance), staked: decUint(staked),
401      pending: decUint(pending, 0), readyAt: Number(decUint(pending, 1)), unbondDelay: Number(decUint(delay)),
402    };
403  }
404
405  function lockIdentity(s) { return s && s.teamLockAddress && s.chain ? `${s.chain.id}:${s.contractAddress}:${s.teamLockAddress}`.toLowerCase() : ''; }
406
407  async function readLock(s) {
408    const lock = s.teamLockAddress;
409    const op = chainOp(s);
410    const call = (sel) => rpcOn(op, 'eth_call', [{ to: lock, data: sel }, 'latest']);
411    const [locked, released, cliff, duration] = await Promise.all([call(SEL.locked), call(SEL.released), call(SEL.cliff), call(SEL.duration)]);
412    return { locked: decUint(locked), released: decUint(released), cliff: Number(decUint(cliff)), duration: Number(decUint(duration)) };
413  }
414
415  // ── team lock card ────────────────────────────────────────────────────
416  // A lock reading belongs to ONE (chain, token, lock address). A reading for
417  // any other identity is never rendered: a new, unfunded lock used to inherit
418  // the previous lock's LOCKED ON CHAIN until its own read landed, and kept
419  // it forever if that read failed (Codex review 2026-09-17, round 4).
420  async function refreshLock() {
421    const s = st.status;
422    const id = lockIdentity(s);
423    if (!id) { st.lock = null; chainRound('lock', 0, null); renderLock(); return; }
424    const seq = ++st.lockSeq;
425    const gen = st.chainGen;
426    if (!st.lock || st.lock.id !== id) { st.lock = null; renderLock(); }
427    let next;
428    try { next = { id, ...(await readLock(s)) }; chainRound('lock', gen, null); } catch (e) { console.error(e); next = { id, error: true }; chainRound('lock', gen, e); }
429    // A slower answer for an older identity (or an older poll) is dropped.
430    if (seq !== st.lockSeq || lockIdentity(st.status) !== id) return;
431    st.lock = next;
432    renderLock();
433    // PROOF's TEAM LOCKED row reads the same lock reading.
434    renderProof();
435  }
436
437  function renderLock() {
438    const s = st.status;
439    const card = $('tokenLockCard');
440    if (!card) return;
441    const on = !!(s && s.teamLockAddress);
442    show(card, on);
443    if (!on) return;
444    setText('tokenLockAddress', s.teamLockAddress);
445    setLink($('tokenLockLink'), s.links && s.links.teamLock);
446    const lockDot = $('tokenLockDot');
447    const l = st.lock && st.lock.id === lockIdentity(s) ? st.lock : null;
448    if (!l || l.error) {
449      // No matching successful read: the pill says so. It never keeps a claim.
450      if (lockDot) { lockDot.textContent = l && l.error ? t('UNAVAILABLE') : t('CHECKING'); lockDot.className = 'mint-status-dot warn'; }
451      setText('tokenLockLocked', l && l.error ? t('unavailable') : '…');
452      setText('tokenLockReleased', '…');
453      setText('tokenLockCliff', '…');
454      setText('tokenLockEnd', '…');
455      return;
456    }
457    // "LOCKED" is a claim about MONEY: say it only once the lock holds the
458    // promised allocation (locked + released), else it is an empty contract.
459    const heldWhole = (l.locked + l.released) / ONE;
460    const funded = heldWhole >= BigInt((s.teamLock && s.teamLock.fundedMinWhole) || 0) && heldWhole > 0n;
461    if (lockDot) { lockDot.textContent = funded ? t('LOCKED ON CHAIN') : t('AWAITING DEPOSIT'); lockDot.className = `mint-status-dot ${funded ? 'ok' : 'warn'}`; }
462    setText('tokenLockLocked', `${fmtTokens(l.locked)} $${s.ticker}`);
463    setText('tokenLockReleased', `${fmtTokens(l.released)} $${s.ticker}`);
464    setText('tokenLockCliff', fmtDate(l.cliff));
465    setText('tokenLockEnd', fmtDate(l.cliff + l.duration));
466  }
467
468  // ── allowance card ────────────────────────────────────────────────────
469  // The daily emission allowance for the signed-in account. This page is the
470  // crypto surface, so it is fine to name the stake here.
471  async function refreshAllowance() {
472    const session = window.hubSession;
473    if (!session || typeof session.api !== 'function' || !session.isSignedIn || !session.isSignedIn()) {
474      st.allowance = null; renderAllowance(); return;
475    }
476    try { st.allowance = await session.api('/api/token/allowance'); }
477    catch (e) { console.error(e); st.allowance = { error: true }; }
478    renderAllowance();
479  }
480
481  function renderAllowance() {
482    const card = $('tokenAllowanceCard');
483    if (!card) return;
484    const session = window.hubSession;
485    const signedIn = !!(session && typeof session.isSignedIn === 'function' && session.isSignedIn());
486    const dot = $('tokenAllowanceDot');
487    if (!signedIn) {
488      if (dot) { dot.textContent = t('SIGN IN'); dot.className = 'mint-status-dot warn'; }
489      setText('tokenAllowanceValue', '…');
490      setText('tokenAllowanceExpiry', '…');
491      setText('tokenAllowanceDay', '…');
492      setText('tokenAllowanceShare', '…');
493      setText('tokenAllowanceNote', t('Sign in to see it.'));
494      return;
495    }
496    const a = st.allowance;
497    if (!a || a.error) {
498      if (dot) { dot.textContent = t('UNAVAILABLE'); dot.className = 'mint-status-dot warn'; }
499      setText('tokenAllowanceNote', t('Unavailable right now.'));
500      return;
501    }
502    const ashes = Number(a.emissionAshes || 0);
503    if (dot) {
504      dot.textContent = ashes > 0 ? t('ACTIVE') : t('NONE YET');
505      dot.className = `mint-status-dot ${ashes > 0 ? 'ok' : 'warn'}`;
506    }
507    setText('tokenAllowanceValue', `${fmtInt(ashes)} ASH`);
508    setText('tokenAllowanceExpiry', a.emissionNextExpiry ? t('in {0}', fmtCountdown(a.emissionNextExpiry - serverNow())) : '…');
509    const latest = a.latest;
510    setText('tokenAllowanceDay', latest && latest.day ? String(latest.day) : '…');
511    setText('tokenAllowanceShare', latest && Number(latest.poolAsh) > 0
512      ? t('{0} of {1} ASH ({2}%)', fmtInt(latest.yourLot), fmtInt(latest.poolAsh), latest.sharePct)
513      : '…');
514    const wallet = a.wallet;
515    if (!wallet || !wallet.verified) setText('tokenAllowanceNote', t('Link a wallet to earn it.'));
516    // The server's own chain rule: a Safe proven on another chain earns nothing.
517    else if (wallet.earnsDrop === false) setText('tokenAllowanceNote', t('Smart wallet verified on another chain: no allowance.'));
518    else if (ashes === 0) setText('tokenAllowanceNote', t('Paid daily, just after 00:00 UTC.'));
519    else setText('tokenAllowanceNote', t('Spent first, before purchased ASH.'));
520  }
521
522
523  // ── info bubbles ──────────────────────────────────────────────────────
524  // The page shows numbers and actions only. Every explanation is a tip
525  // behind an (i) button (owner 2026-09-18: nobody reads paragraphs), the
526  // same bubble idiom as the game shop's _sheetBubble. Each data-tip key in
527  // shop.html must have an entry here and vice versa (guard: verify-token-launch).
528  const TIPS = {
529    what: () => [t('$THRONE'), t('The token of the Ashen Throne economy. Stake it from a wallet linked to your game account and 
529you share a daily Golden Ash pool with the other linked stakers. Tiers are cosmetic. It is never sold inside the app.')],
530    drop: () => [t('DAILY ASH DROP'), t('Every day just after 00:00 UTC the game splits the pool between linked wallets by staked share, read at one block. Your cut lands in your game account and is spent before purchased ASH.')],
531    calc: () => [t('YOUR DAILY SHARE'), t('Your amount over everything staked now plus your amount, times the next pool. Unlinked stake is not paid, so the real share can be bigger. It moves every day with the pool and the stake.')],
532    linkstep: () => [t('LINK YOUR WALLET'), t('The drop only pays wallets linked to a game account. Sign in on the Account page and verify the wallet you stake from.')],
533    fake: () => [t('NO ADDRESS YET'), t('The contract address appears here the moment the token is live, and BUY points straight at it. Until then there is nothing to buy.')],
534    verified: () => [t('CHECK THE ADDRESS'), t('The game server verified this contract on chain before showing it. Match it against the Discord announcement. We never post a different address anywhere.')],
535    staking: (s) => [t('STAKING'), t('Stake to earn the daily allowance and a cosmetic tier: badge, title, base skin. Never combat or economy power. Unstaking takes {0} days.', s.staking.unbondDays)],
536    pending: (s) => [t('PENDING'), t('Unstaked tokens wait {0} days, earn nothing, then can be withdrawn. Any new unstake request restarts the clock for the whole pending amount.', s.staking.unbondDays)],
537    link: () => [t('LINK YOUR WALLET'), t('Staking works from any wallet, but the allowance only lands on a game account this wallet is linked to. Link and verify it on the Account tab.')],
538    allowance: (s) => [t('DAILY ALLOWANCE'), t('Once a day the pool is split across linked wallets by staked share. It is spent automatically before purchased ASH and each day expires after {0} days.', s.emission.allowanceExpiryDays)],
539    supply: () => [t('FIXED SUPPLY'), t('Minted once, in full, at launch. No mint function, no team tax. The launch venue charges its standard trade fee.')],
540    distribution: (s) => [t('NO PREMINE'), t('The whole supply goes to market. The team buys its {0}% at launch from one announced wallet and locks it: nothing for {1} days, then released evenly over {2} days.', s.allocation.teamCommunityPct, s.teamLock.cliffDays, s.teamLock.durationDays)],
541    // A DAY's pool: the share of one average day's revenue, never of 30 days' (review 2026-09-29).
542    pool: (s) => [t('THE POOL'), t("Each day's pool is {0}% of the game's average daily revenue over the last 30 days, at {3} ASH per $1, paid as Golden Ash and split by staked share. For the first {1} days after launch it never drops below {2} ASH a day.", s.emission.revenuePct, s.emission.coldStartFloorDays, fmtInt(Math.floor(Number(s.emission.coldStartFloorUsd) * Number(s.emission.ashPerUsd))), fmtInt(s.emission.ashPerUsd))],
543    spend: (s) => [t('USE IT OR LOSE IT'), t('Allowance ASH buys everything in the shop. It cannot be swapped, exported or traded, and it expires after {0} days.', s.emission.allowanceExpiryDays)],
544    lock: () => [t('TEAM LOCK'), t('An on-chain lock with no owner and no early exit. Nothing moves before the cliff, then it releases evenly. Read the contract yourself.')],
545    proof: () => [t('PROOF'), t('Each value is read from a contract by this page, or is a game record with the block, transaction or contract to check it against.')],
546    onewallet: () => [t('ONE WALLET'), t('LAUNCHED IT and GETS THE FEE come from the Pons launch record. OWNS THE LOCK is the lock contract beneficiary. Each is compared with the Safe.')],
547    proofsupply: () => [t('SUPPLY'), t('TOTAL is the token totalSupply. CREATOR TAX is from the Pons launch record. TEAM LOCKED is the lock balance. STAKED NOW is the vault totalStaked.')],
548    ledger: () => [t('ALLOWANCE LEDGER'), t('One row per daily snapshot: pool, ASH granted, linked wallets with stake, their total stake (whole tokens), and the block stakes were read at. Your lot is the pool times your stake at that block over STAKED. FLOOR marks the minimum pool.')],
549    cupmoney: () => [t('CUP MONEY'), t('FEES IN ESCROW is the Pons escrow balanceOfToken for the Safe at the block shown. FEES CLAIMED is the sum of its ClaimedToken logs to the Safe. SAFE BALANCE is read live. CUP LEDGER is the game accounting; each payout is a transaction.')],
550    not: () => [t('PLAINLY'), t('Not a promise of price: we market the allowance, never the chart. Tiers are cosmetic. The token lives on this site only, never in the store apps.')],
551  };
552  function tip(key) {
553    const fn = TIPS[key];
554    if (!fn || !st.status) return null;
555    const out = fn(st.status);
556    return out && out[0] && out[1] ? out : null;
557  }
558  let bubbleEl = null;
559  function killBubble() {
560    if (bubbleEl && bubbleEl.parentNode) bubbleEl.parentNode.removeChild(bubbleEl);
561    bubbleEl = null;
562  }
563  function showBubble(anchor, key) {
564    killBubble();
565    const content = tip(key);
566    if (!content) return;
567    const b = document.createElement('div');
568    b.className = 'token-bubble';
569    b.setAttribute('role', 'tooltip');
570    const title = document.createElement('b');
571    title.textContent = content[0];
572    b.appendChild(title);
573    b.appendChild(document.createTextNode(content[1]));
574    document.body.appendChild(b);
575    const r = anchor.getBoundingClientRect();
576    b.style.left = `${Math.max(8, Math.min(window.innerWidth - b.offsetWidth - 8, r.left + r.width / 2 - b.offsetWidth / 2))}px`;
577    const above = r.top - b.offsetHeight - 8;
578    b.style.top = `${above > 8 ? above : r.bottom + 8}px`;
579    bubbleEl = b;
580    // Dismiss on the NEXT press anywhere, Escape, or a scroll.
581    setTimeout(() => { document.addEventListener('pointerdown', killBubble, { once: true }); }, 0);
582  }
583  function initTips() {
584    document.addEventListener('click', (ev) => {
585      const btn = ev.target && ev.target.closest ? ev.target.closest('.token-tip') : null;
586      if (!btn) return;
587      ev.preventDefault(); ev.stopPropagation();
588      showBubble(btn, btn.getAttribute('data-tip'));
589    });
590    document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') killBubble(); });
591    if (typeof window.addEventListener === 'function') window.addEventListener('scroll', killBubble, { passive: true });
592  }
593
594  // ── PROOF ─────────────────────────────────────────────────────────────
595  // Owner 2026-09-24: "the goal is verifiable trust". Every value is either
596  // read from the chain by this page (TOTAL, TEAM LOCKED, STAKED NOW, OWNS
597  // THE LOCK, SAFE BALANCE) or a record from /api/token/proof that names the
598  // contract, block or transaction to check it against. No row states a
599  // claim the page cannot point at.
600  function proofUrl() { return publicUrl(PROOF_PATH); }
601  function fmtUsd(v) {
602    const n = Number(v);
603    if (!Number.isFinite(n)) return '…';
604    try { return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; } catch (_) { return `$${n.toFixed(2)}`; }
605  }
606  function explorerBase(s) { return (s && s.chain && s.chain.explorer ? String(s.chain.explorer) : '').replace(/\/+$/, ''); }
607  // The chain reads belong to one (chain, token, vault, lock, cup token).
608  function proofChainIdentity(s) {
609    if (!s || !s.chain) return '';
610    const c = st.proof && st.proof.cup;
611    const cupKey = c && Number(c.chainId) === Number(s.chain.id) && c.tokenContract && c.treasury ? `${c.tokenContract}:${c.treasury}` : '';
612    return `${s.chain.id}:${s.contractAddress || ''}:${s.stakingAddress || ''}:${s.teamLockAddress || ''}:${cupKey}`.toLowerCase();
613  }
614  function sameToken(a, b) { return String(a || '').toLowerCase() === String(b || '').toLowerCase(); }
615
616  async function refreshProof({ force = false } = {}) {
617    const s = st.status;
618    if (!s || s.phase !== 'launched') { st.proof = null; renderProof(); return; }
619    const stale = !st.proof || !sameToken(st.proof.contractAddress, s.contractAddress) || Date.now() - st.proofAt >= PROOF_REFRESH_MS;
620    // A failed fetch waits the same minute before asking again, so an
621    // unavailable record does not rebuild on every status poll per visitor.
622    const backedOff = st.proofFailAt && Date.now() - st.proofFailAt < PROOF_REFRESH_MS;
623    if (force || (stale && !backedOff)) {
624      const seq = ++st.proofSeq;
625      try {
626        const next = await boundedFetch(proofUrl(), { headers: { Accept: 'application/json' }, cache: 'no-store' }, FETCH_TIMEOUT_MS, (res) => {
627          if (!res.ok) throw new Error(`Proof unavailable (${res.status})`);
628          return res.json();
629        });
630        // A record for another token (a reset and relaunch in between) is dropped.
631        if (seq === st.proofSeq && st.status && sameToken(next.contractAddress, st.status.contractAddress)) {
632          st.proof = next;
633          st.proofAt = Date.now();
634          st.proofFailAt = 0;
635          renderProof();
636        }
637      } catch (e) {
638        st.proofFailAt = Date.now();
639        console.error('[token] proof', e && e.message);
640      }
641    }
642    // The chain reads follow the same minute as the server record, and run
643    // after it because the Safe's USDG balance needs the record's cup token.
644    const cur = st.status;
645    if (!cur || cur.phase !== 'launched') return;
646    if (force || !st.proofChain || st.proofChain.id !== proofChainIdentity(cur) || Date.now() - st.proofChainAt >= PROOF_REFRESH_MS) refreshProofChain();
647  }
648
649  // One eth_call's answer: a 32-byte word, or null. An address with no
650  // contract answers "0x", which is NO answer, never a zero balance and never
651  // "the lock pays another wallet".
652  function word(hex) { return /^0x[0-9a-fA-F]{64}/.test(String(hex || '')) ? String(hex) : null; }
653
654  async function refreshProofChain() {
655    const s = st.status;
656    const id = proofChainIdentity(s);
657    if (!id) { chainRound('proof', 0, null); return; }
658    const seq = ++st.proofChainSeq;
659    const next = { id, totalSupply: null, totalStaked: null, beneficiary: null, safeBalance: null, failed: {} };
660    const c = st.proof && st.proof.cup;
661    const cupOnChain = !!(c && Number(c.chainId) === Number(s.chain.id) && c.tokenContract && c.treasury);
662    const reads = {
663      totalSupply: s.contractAddress ? [s.contractAddress, SEL.totalSupply] : null,
664      totalStaked: s.stakingAddress ? [s.stakingAddress, SEL.totalStaked] : null,
665      beneficiary: s.teamLockAddress ? [s.teamLockAddress, SEL.beneficiary] : null,
666      safeBalance: cupOnChain ? [c.tokenContract, SEL.balanceOf + encAddress(c.treasury)] : null,
667    };
668    const op = chainOp(s);
669    const gen = st.chainGen;
670    let reached = false;
671    let unreachable = null;
672    await Promise.all(Object.keys(reads).map(async (k) => {
673      if (!reads[k]) return;
674      try {
675        const w = word(await rpcOn(op, 'eth_call', [{ to: reads[k][0], data: reads[k][1] }, 'latest']));
676        reached = true;
677        if (!w) { next.failed[k] = true; return; }
678        next[k] = k === 'beneficiary' ? `0x${w.replace(/^0x/, '').slice(-40)}` : decUint(w);
679      } catch (e) {
680        console.error('[token] proof chain read', k, e && e.message);
681        if (e && e.code === 'CHAIN_UNREACHABLE') unreachable = e;
682        next.failed[k] = true;
683      }
684    }));
685    if (reached) chainRound('proof', gen, null);
686    else if (unreachable) chainRound('proof', gen, unreachable);
687    if (seq !== st.proofChainSeq || proofChainIdentity(st.status) !== id) return;
688    st.proofChain = next;
689    st.proofChainAt = Date.now();
690    renderProof();
691  }
692
693  // A verdict names the Safe only when the record says so; a different wallet
694  // is shown in red, an unanswered read says checking. Never a tick on doubt.
695  function setVerdict(id, verdict, addr) {
696    const el = $(id);
697    if (!el) return;
698    if (verdict === true) { el.textContent = t('THE SAFE'); el.className = 'token-proof__ok'; return; }
699    if (verdict === false) { el.textContent = `${t('OTHER')} ${shortAddr(addr)}`; el.className = 'token-proof__bad'; return; }
700    el.textContent = t('checking'); el.className = '';
701  }
702
703  function cell(tr, text, href) {
704    const td = document.createElement('td');
705    if (href) {
706      const a = document.createElement('a');
707      a.href = href; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.textContent = text;
708      td.appendChild(a);
709    } else td.textContent = text;
710    tr.appendChild(td);
711    return td;
712  }
713
714  // Share of the chain-read total supply, both in wei. No total, no share.
715  function pctOf(wei, totalWei) {
716    if (totalWei == null || totalWei <= 0n || wei == null) return null;
717    return Number((wei * 10000n) / totalWei) / 100;
718  }
719  function tokensWithShare(wei, totalWei) {
720    const pct = pctOf(wei, totalWei);
721    return pct == null ? fmtTokens(wei) : `${fmtTokens(wei)} (${pct}%)`;
722  }
723  // A chain value: its number, 'unavailable' when the read failed, '…' while pending.
724  function chainValue(chain, key, fmt) {
725    if (chain && chain[key] != null) return fmt(chain[key]);
726    return chain && chain.failed && chain.failed[key] ? t('unavailable') : '…';
727  }
728  function fmtUtcTime(epoch) {
729    const n = Number(epoch);
730    if (!Number.isFinite(n) || n <= 0) return '…';
731    try { return `${new Date(n * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC`; } catch (_) { return String(n); }
732  }
733  function fmtOffset(seconds) {
734    const m = Math.max(0, Math.floor(Number(seconds) / 60) || 0);
735    return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
736  }
737
738  function renderProof() {
739    const s = st.status;
740    const sec = $('tokenProof');
741    if (!sec) return;
742    const on = !!(s && s.phase === 'launched');
743    show(sec, on);
744    if (!on) return;
745    const p = st.proof && sameToken(st.proof.contractAddress, s.contractAddress) ? st.proof : null;
746    const chain = st.proofChain && st.proofChain.id === proofChainIdentity(s) ? st.proofChain : null;
747    const base = explorerBase(s);
748    setText('tokenProofAsOf', p ? t('Record as of {0}', fmtUtcTime(p.generatedAt)) : '…');
749
750    // ONE WALLET
751    const w = p && p.wallet;
752    const safeLink = $('tokenProofSafe');
753    if (safeLink) {
754      if (w && w.safe) { safeLink.textContent = w.safe; if (base) safeLink.href = `${base}/address/${w.safe}`; else safeLink.removeAttribute('href'); }
755      else { safeLink.textContent = w ? t('not set') : '…'; safeLink.removeAttribute('href'); }
756    }
757    // The fallback route (another wallet launched with the Safe as creator)
758    // is stated in amber, never as the Safe and never as a mismatch.
759    const forSafe = !!(w && w.launchedBySafe === false && w.launchedForSafe === true);
760    if (forSafe) { const el = $('tokenProofLauncher'); if (el) { el.textContent = `${shortAddr(w.launcher)} ${t('FOR THE SAFE')}`; el.className = 'token-proof__warn'; } }
761    else setVerdict('tokenProofLauncher', w ? w.launchedBySafe : null, w && w.launcher);
762    setVerdict('tokenProofFeeTo', w ? w.feesToSafe : null, w && w.feeRecipient);
763    let lockOwner = null;
764    const lockEl = $('tokenProofLockOwner');
765    if (!s.teamLockAddress) { if (lockEl) { lockEl.textContent = t('no lock yet'); lockEl.className = ''; } }
766    else if (chain && chain.beneficiary && w && w.safe) {
767      lockOwner = chain.beneficiary.toLowerCase() === w.safe.toLowerCase();
768      setVerdict('tokenProofLockOwner', lockOwner, chain.beneficiary);
769    } else if (chain && chain.failed.beneficiary) { if (lockEl) { lockEl.textContent = t('unavailable'); lockEl.className = ''; } }
770    else setVerdict('tokenProofLockOwner', null);
771    const verdicts = [forSafe ? 'warn' : (w ? w.launchedBySafe : null), w ? w.feesToSafe : null].concat(s.teamLockAddress ? [lockOwner] : []);
772    const dot = $('tokenProofWalletDot');
773    if (dot) {
774      if (verdicts.some(v => v === false)) { dot.textContent = t('MISMATCH'); dot.className = 'mint-status-dot bad'; }
775      else if (verdicts.every(v => v === true)) { dot.textContent = t('VERIFIED'); dot.className = 'mint-status-dot ok'; }
776      else if (verdicts.every(v => v === true || v === 'warn')) { dot.textContent = t('SAFE IS CREATOR'); dot.className = 'mint-status-dot warn'; }
777      else { dot.textContent = t('CHECKING'); dot.className = 'mint-status-dot warn'; }
778    }
779    setLink($('tokenProofRecord'), w && w.launchRecord && base ? `${base}/address/${w.launchRecord}?tab=read_contract` : null);
780
781    // SUPPLY: every row read from a contract.
782    const total = chain && chain.totalSupply != null ? chain.totalSupply : null;
783    setText('tokenProofTotal', chainValue(chain, 'totalSupply', v => `${fmtTokens(v)} $${s.ticker}`));
784    setText('tokenProofTax', w && w.creatorTaxBps != null ? `${w.creatorTaxBps / 100}%` : (w && w.launcher ? t('unavailable') : '…'));
785    const l = st.lock && st.lock.id === lockIdentity(s) ? st.lock : null;
786    if (!s.teamLockAddress) setText('tokenProofLocked', t('no lock yet'));
787    else if (!l) setText('tokenProofLocked', '…');
788    else if (l.error) setText('tokenProofLocked', t('unavailable'));
789    else setText('tokenProofLocked', tokensWithShare(l.locked, total));
790    if (!s.stakingAddress) setText('tokenProofStaked', t('opens after launch'));
791    else setText('tokenProofStaked', chainValue(chain, 'totalStaked', v => tokensWithShare(v, total)));
792
793    // ALLOWANCE LEDGER
794    const em = p && p.emission;
795    const rows = $('tokenProofLedgerRows');
796    const days = em && Array.isArray(em.days) ? em.days : [];
797    if (rows) {
798      rows.textContent = '';
799      for (const d of days) {
800        const tr = document.createElement('tr');
801        cell(tr, d.day);
802        const pool = cell(tr, `${fmtInt(d.poolAsh)} ASH (${fmtUsd(d.poolUsd)})`);
803        if (d.floor) { const tag = document.createElement('span'); tag.className = 'token-proof__tag'; tag.textContent = t('FLOOR'); pool.appendChild(tag); }
804        cell(tr, `${fmtInt(d.grantedAsh)} ASH`);
805        cell(tr, fmtInt(d.wallets));
806        cell(tr, fmtInt(d.totalStaked));
807        cell(tr, `#${fmtInt(d.block)}`, base ? `${base}/block/${d.block}` : null);
808        rows.appendChild(tr);
809      }
810    }
811    show($('tokenProofLedgerTable'), days.length > 0);
812    const note = $('tokenProofLedgerNote');
813    if (note) {
814      let text = '';
815      if (!p) text = '…';
816      else if (days.length === 0 && em && em.firstDay) text = t('First snapshot {0}, {1} UTC.', em.firstDay, fmtOffset(em.snapshotUtcOffsetSeconds));
817      else if (days.length === 0) text = t('No snapshot yet.');
818      note.textContent = text;
819      show(note, !!text);
820    }
821
822    // CUP MONEY
823    const c = p && p.cup;
824    show($('tokenProofCup'), !!c);
825    if (c) {
826      const atBlock = c.feeReadBlock ? ` (${t('block')} #${fmtInt(c.feeReadBlock)})` : '';
827      setText('tokenProofFeesOwed', c.feeOwedUsd == null ? t('not read yet') : `${fmtUsd(c.feeOwedUsd)} ${c.tokenSymbol}${atBlock}`);
828      setText('tokenProofFeesClaimed', c.feeClaimedUsd == null ? t('not read yet') : `${fmtUsd(c.feeClaimedUsd)} ${c.tokenSymbol}`);
829      const dec = BigInt(Math.max(2, Number(c.tokenDecimals) || 6));
830      setText('tokenProofSafeBal', Number(c.chainId) === Number(s.chain.id)
831        ? chainValue(chain, 'safeBalance', v => `${fmtUsd(Number(v / (10n ** (dec - 2n))) / 100)} ${c.tokenSymbol}`)
832        : t('unavailable'));
833      const cupNote = $('tokenProofCupNote');
834      const missing = c.shards && Array.isArray(c.shards.missing) ? c.shards.missing : [];
835      if (cupNote) {
836        const ids = (reason) => missing.filter(m => m.reason === reason).map(m => m.id).join(', ');
837        const parts = [];
838        if (ids('wrong_shard')) parts.push(t('Shard {0} answered as another shard.', ids('wrong_shard')));
839        if (ids('not_running')) parts.push(t('Shard {0} reports no Cup.', ids('not_running')));
840        const other = missing.filter(m => m.reason !== 'wrong_shard' && m.reason !== 'not_running').map(m => m.id).join(', ');
841        if (other) parts.push(t('Shard {0} not answering.', other));
842        cupNote.textContent = c.complete ? '' : `${parts.join(' ')} ${t('Ledger totals held back.')}`;
843        show(cupNote, !c.complete);
844      }
845      setText('tokenProofReserve', c.complete && c.reserveUsd != null ? `${fmtUsd(c.reserveUsd)} ${c.tokenSymbol}` : '…');
846      setText('tokenProofPaid', c.complete && c.paid ? t('{0}, transfers: {1}', fmtUsd(c.paid.usd), fmtInt(c.paid.transfers)) : '…');
847      const cr = $('tokenProofCupRows');
848      const weeks = c.complete && Array.isArray(c.weeks) ? c.weeks : [];
849      if (cr) {
850        cr.textContent = '';
851        for (const wk of weeks) {
852          const tr = document.createElement('tr');
853          cell(tr, fmtDate(wk.startsAt));
854          cell(tr, fmtUsd(wk.feeShareUsd));
855          cell(tr, fmtUsd(wk.poolUsd));
856          cell(tr, fmtUsd(wk.reserveCloseUsd));
857          cr.appendChild(tr);
858        }
859      }
860      show($('tokenProofCupTable'), weeks.length > 0);
861      setLink($('tokenProofEscrow'), c.feeEscrow && base ? `${base}/address/${c.feeEscrow}` : null);
862      setLink($('tokenProofSafeLink'), c.treasury && base ? `${base}/address/${c.treasury}` : null);
863      setLink($('tokenProofLastPay'), c.complete && c.paid && c.paid.lastTxUrl);
864    }
865  }
866
867  // ── staking card ──────────────────────────────────────────────────────
868  function walletRail() { return (typeof window !== 'undefined' && window.hubWallet) ? window.hubWallet : null; }
869
870  function stakingState() {
871    const s = st.status;
872    if (!s || !s.stakingAddress || !s.contractAddress) return 'closed';
873    const w = walletRail();
874    if (!w) return 'norail';
875    if (!st.wallet.address) return 'disconnected';
876    if (st.wallet.chainId !== Number(s.chain.id)) return 'wrongchain';
877    if (!st.vault) return 'loading';
878    if (st.vault.pending > 0n && st.vault.readyAt <= serverNow()) return 'ready';
879    if (st.vault.pending > 0n) return 'unbonding';
880    if (st.vault.staked > 0n) return 'staked';
881    return 'empty';
882  }
883
884  function renderStaking() {
885    const s = st.status;
886    const state = stakingState();
887    const card = $('tokenStakingCard');
888    if (card) card.setAttribute('data-staking', state);
889    const dot = $('tokenStakingDot');
890    const stakingLive = !!(s && s.stakingAddress);
891    // After launch the vault can still be days away (it is deployed against the
892    // launched token), so "after launch" would be false the moment the token
893    // goes live; a live contract with no wallet rail is a reload, not a wait.
894    const launched = !!(s && s.phase === 'launched');
895    setText('tokenStakeClosedText', state === 'norail' ? t('Wallet unavailable. Reload the page.')
896      : (launched ? t('Opening soon.') : t('Opens after launch.')));
897    if (dot) {
898      dot.textContent = stakingLive ? t('CONTRACT LIVE') : (launched ? t('OPENING SOON') : t('OPENS AFTER LAUNCH'));
899      dot.className = `mint-status-dot ${stakingLive ? 'ok' : 'warn'}`;
900    }
901    show($('tokenStakeClosed'), state === 'closed' || state === 'norail');
902    show($('tokenStakeConnect'), state === 'disconnected');
903    // CONNECTING while the picker is pending, and still tappable: a re-tap
904    // re-opens the picker (doConnect). Written only on a change so the page's
905    // own translation pass is not fought on every render.
906    const connectBtn = $('tokenStakeConnectBtn');
907    if (connectBtn) {
908      const label = st.busy === 'connect' ? t('CONNECTING…') : t('CONNECT WALLET');
909      if (connectBtn.textContent !== label) connectBtn.textContent = label;
910    }
911    show($('tokenStakeSwitch'), state === 'wrongchain');
912    // The chain notice replaces "Reading the chain…" rather than sitting
913    // under a spinner that will not finish.
914    const down = chainDown();
915    show($('tokenStakeLoading'), state === 'loading' && !down);
916    show($('tokenChainDown'), down);
917    if (down) setText('tokenChainDownText', chainDownLine());
918    const retry = $('tokenChainRetry');
919    if (retry) { retry.disabled = st.chainRetrying; retry.textContent = st.chainRetrying ? t('CHECKING') : t('RETRY'); }
920    const open = state === 'empty' || state === 'staked' || state === 'unbonding' || state === 'ready';
921    show($('tokenStakeOpen'), open);
922    if (state === 'wrongchain') setText('tokenStakeSwitchText', t('Switch to {0}.', s.chain.name));
923    if (state === 'disconnected' || open || state === 'wrongchain') setText('tokenStakeWallet', shortAddr(st.wallet.address) || t('no wallet'));
924    if (!open) { renderLinkNotice(state); return; }
925
926    const v = st.vault;
927    const ticker = s.ticker;
928    setText('tokenStakeBalance', `${fmtTokens(v.balance)} $${ticker}`);
929    setText('tokenStakeStaked', `${fmtTokens(v.staked)} $${ticker}`);
930    const pendingWrap = $('tokenStakePendingRow');
931    show(pendingWrap, v.pending > 0n);
932    if (v.pending > 0n) {
933      setText('tokenStakePending', `${fmtTokens(v.pending)} $${ticker}`);
934      const left = v.readyAt - serverNow();
935      setText('tokenStakeReady', left > 0 ? t('READY IN {0}', fmtCountdown(left)) : t('READY'));
936      const pill = $('tokenStakeReadyPill');
937      if (pill) pill.className = `mint-status-dot ${left > 0 ? 'warn' : 'ok'}`;
938    }
939    const days = Math.round(v.unbondDelay / 86400);
940    setText('tokenStakeNote', t('Unstaking takes {0} days.', days));
941
942    const stakeBtn = $('tokenStakeBtn');
943    const unstakeBtn = $('tokenUnstakeBtn');
944    const withdrawBtn = $('tokenWithdrawBtn');
945    const busy = !!st.busy || !!st.pendingTx;
946    if (stakeBtn) { stakeBtn.disabled = busy || v.balance === 0n; stakeBtn.textContent = st.busy === 'approve' ? t('APPROVING…') : st.busy === 'stake' ? t('STAKING…') : t('STAKE'); }
947    if (unstakeBtn) { unstakeBtn.disabled = busy || v.staked === 0n; unstakeBtn.textContent = st.busy === 'unstake' ? t('REQUESTING…') : (v.pending > 0n ? t('UNSTAKE MORE') : t('UNSTAKE')); }
948    if (withdrawBtn) {
949      show(withdrawBtn, v.pending > 0n);
950      withdrawBtn.disabled = busy || state !== 'ready';
951      withdrawBtn.textContent = st.busy === 'withdraw' ? t('WITHDRAWING…') : state === 'ready' ? t('WITHDRAW {0} ${1}', fmtTokens(v.pending), ticker) : t('WITHDRAW');
952    }
953    // The aggregate clock: any new request relocks the whole pe
953nding amount.
954    const warn = $('tokenUnstakeWarn');
955    if (warn) {
956      show(warn, st.unstakeOpen && v.pending > 0n);
957      warn.textContent = state === 'ready'
958        ? t('Withdraw first, or the ready amount relocks for {0} days.', days)
959        : t('Restarts the {0} day clock for everything pending.', days);
960    }
961    show($('tokenUnstakeForm'), st.unstakeOpen);
962    renderLinkNotice(state);
963  }
964
965  function renderLinkNotice(state) {
966    const el = $('tokenLinkNotice');
967    if (!el) return;
968    const connected = state !== 'closed' && state !== 'norail' && state !== 'disconnected';
969    if (!connected || st.wallet.linked === true) { show(el, false); return; }
970    show(el, true);
971    const session = window.hubSession;
972    const signedIn = !!(session && typeof session.isSignedIn === 'function' && session.isSignedIn());
973    if (st.wallet.linked === null) setText('tokenLinkText', t('Checking link.'));
974    else if (!signedIn) setText('tokenLinkText', t('Not signed in: no allowance.'));
975    else if (st.wallet.proofElsewhere) setText('tokenLinkText', t('Smart wallet verified on another chain: no allowance.'));
976    else if (st.wallet.linkedAddress) setText('tokenLinkText', t('Account is linked to {0}.', shortAddr(st.wallet.linkedAddress)));
977    else setText('tokenLinkText', t('Wallet not linked: no allowance.'));
978  }
979
980  async function refreshVault() {
981    const s = st.status;
982    const w = walletRail();
983    // No read to make (no vault, no wallet, another chain): nothing about the
984    // chain is known from this card, so it holds no failure either.
985    if (!s || !s.stakingAddress || !w) { st.vault = null; chainRound('vault', 0, null); renderStaking(); return; }
986    let snap = null;
987    try { snap = w.getState(); } catch (_) { snap = null; }
988    st.wallet.address = (snap && snap.address) || '';
989    st.wallet.chainId = snap && snap.chainId ? parseInt(snap.chainId, 16) : null;
990    if (!st.wallet.address || st.wallet.chainId !== Number(s.chain.id)) { st.vault = null; chainRound('vault', 0, null); renderStaking(); return; }
991    const seq = ++st.vaultSeq;
992    const gen = st.chainGen;
993    renderStaking();
994    try {
995      const v = await readVault(st.wallet.address);
996      chainRound('vault', gen, null);
997      if (seq !== st.vaultSeq) return;
998      st.vault = v;
999    } catch (e) {
1000      console.error(e);
1001      chainRound('vault', gen, e);
1002      if (seq !== st.vaultSeq) return;
1003      st.vault = null;
1004      // Once the notice is up the line says what is wrong and what to do;
1005      // never an endless "Retrying" (ticket 2026-10-01).
1006      ambient(chainDown() ? chainDownLine() : t('Could not read the chain. Retrying.'), 'bad');
1007    }
1008    renderTokenomics(s);
1009    renderStaking();
1010    refreshLinked();
1011    refreshAllowance();
1012  }
1013
1014  // Is the connected wallet the verified wallet of the signed-in account?
1015  // Read from the same endpoint the hub account page uses; never inferred.
1016  async function refreshLinked() {
1017    const session = window.hubSession;
1018    if (!session || typeof session.api !== 'function' || !session.isSignedIn || !session.isSignedIn()) {
1019      st.wallet.linked = false; st.wallet.linkedAddress = ''; st.wallet.proofElsewhere = false; renderLinkNotice(stakingState()); return;
1020    }
1021    try {
1022      const data = await session.api('/api/vault/wallet');
1023      const wallet = data && data.wallet;
1024      const linkedAddress = wallet && wallet.verified && wallet.address ? String(wallet.address) : '';
1025      st.wallet.linkedAddress = linkedAddress;
1026      // A smart contract wallet (Safe) counts only on the chain its link was
1027      // proven on (tokenEmission countsOnChain): proven anywhere else, it is
1028      // linked but the drop pays it nothing, and the page says so.
1029      const chainId = st.status && st.status.chain ? Number(st.status.chain.id) : NaN;
1030      st.wallet.proofElsewhere = !!(linkedAddress && wallet.contractWallet && Number.isFinite(chainId) && Number(wallet.proofChainId) !== chainId);
1031      st.wallet.linked = !!linkedAddress && !st.wallet.proofElsewhere && linkedAddress.toLowerCase() === String(st.wallet.address).toLowerCase();
1032    } catch (e) {
1033      console.error(e);
1034      st.wallet.linked = false;
1035    }
1036    renderLinkNotice(stakingState());
1037  }
1038
1039  function txData(selector, ...words) { return selector + words.join(''); }
1040
1041  // ── one operation, one identity ───────────────────────────────────────
1042  // Codex review 2026-09-17 (B4, B5). An operation (approve + stake is ONE
1043  // operation) captures the account, chain, token, vault and RPC it started
1044  // with and uses ONLY those for every send and every receipt poll: the
1045  // status row can flip under a pending approval (reset, relaunch, chain
1046  // change) and the old code then sent the stake to the old vault address on
1047  // the new chain. A changed identity aborts the rest of the operation.
1048  // A transaction line outlives the status poll: the ambient "is live" line
1049  // used to overwrite "Still pending. Do not resend." within one poll.
1050  function say(text, type) { st.msgHold = Date.now() + MSG_HOLD_MS; setMessage(text, type); }
1051  function TxError(code, message, hash) { const e = new Error(message || code); e.code = code; e.hash = hash || null; return e; }
1052
1053  function beginOp(kind) {
1054    const s = st.status;
1055    if (st.op || st.pendingTx) return null;
1056    st.op = {
1057      kind, account: st.wallet.address, chainId: Number(s.chain.id), token: s.contractAddress, vault: s.stakingAddress,
1058      rpcs: chainHosts(s.chain), identity: `${s.chain.id}:${s.contractAddress}:${s.stakingAddress}`,
1059    };
1060    st.busy = kind;
1061    return st.op;
1062  }
1063  function endOp() { st.op = null; st.busy = ''; }
1064  function assertSameIdentity(op) {
1065    const s = st.status;
1066    const w = walletRail();
1067    let snap = null; try { snap = w.getState(); } catch (_) { snap = null; }
1068    const account = (snap && snap.address) || '';
1069    const now = s ? `${s.chain.id}:${s.contractAddress}:${s.stakingAddress}` : '';
1070    if (now !== op.identity) throw TxError('IDENTITY_CHANGED');
1071    if (account.toLowerCase() !== String(op.account).toLowerCase()) throw TxError('ACCOUNT_CHANGED');
1072  }
1073
1074  // Every network read on this page goes through here: the request AND its
1075  // body read race one timer, so a hung response can never park a poll or an
1076  // operation. AbortController (when present) cancels the timed-out request
1077  // instead of leaving it outstanding; the timer alone still answers on a
1078  // browser that has neither it nor AbortSignal.timeout.
1079  function boundedFetch(url, init, ms, read) {
1080    const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
1081    let timer = null;
1082    const timeout = new Promise((_, reject) => {
1083      timer = setTimeout(() => {
1084        try { if (ctrl) ctrl.abort(); } catch (_) { /* already settled */ }
1085        reject(TxError('TIMEOUT', 'request timed out'));
1086      }, ms);
1087    });
1088    const request = fetch(url, Object.assign({}, init, ctrl ? { signal: ctrl.signal } : {}))
1089      .then(res => (read ? read(res) : res));
1090    return Promise.race([request, timeout]).finally(() => clearTimeout(timer));
1091  }
1092
1093  // ── CHAIN TRANSPORT ───────────────────────────────────────────────────
1094  // Every chain read, and the receipt and block reads behind a transaction,
1095  // goes through rpcOn: the payload's ordered public hosts first, then the
1096  // same-origin fallback POST /api/token/chain-read, which the landing proxies
1097  // to the game server and the server answers through its own RPC for the
1098  // page's read calls only (server/game/tokenChainRead.js). A network that
1099  // blocks the public host still reads the chain (Discord ticket 2026-10-01:
1100  // NOT AVAILABLE on every read while the site's own /api worked). A host
1101  // fails over on a network error, a timeout, any HTTP error (429 included)
1102  // or an answer that is not JSON-RPC; a contract revert is an ANSWER and
1103  // never fails over. Writes never come here: they go through the wallet.
1104  function chainHosts(chain) {
1105    const list = chain && Array.isArray(chain.publicRpcs) && chain.publicRpcs.length ? chain.publicRpcs : (chain && chain.publicRpc ? [chain.publicRpc] : []);
1106    return list.filter(u => /^https?:\/\//.test(String(u)));
1107  }
1108  function chainOp(s) { return { rpcs: chainHosts(s.chain), chainId: Number(s.chain.id) }; }
1109  function chainReadUrl() { return publicUrl(CHAIN_READ_PATH); }
1110
1111  // A contract revert is an ANSWER; every other node error is the node
1112  // failing (internal, rate limited, header not found) and fails over.
1113  function isRevert(err) { return !!err && (err.code === 3 || /revert/i.test(String(err.message || ''))); }
1114  // `network` is true when no HTTP answer came back at all (a block, a hang,
1115  // no connection), false when a server answered with an error.
1116  function transportError(network, message) { const e = TxError('TRANSPORT', message); e.network = network; return e; }
1117
1118  // One JSON-RPC answer (a result or a revert) from one host, or a TRANSPORT error.
1119  function rpcPost(url, payload, ms) {
1120    return boundedFetch(url, {
1121      method: 'POST', headers: { 'Content-Type': 'application/json' },
1122      body: JSON.stringify(payload),
1123    }, ms, async (res) => {
1124      if (!res || !res.ok) throw transportError(false, `http ${res && res.status}`);
1125      let json = null;
1126      try { json = await res.json(); } catch (_) { json = null; }
1127      if (!json || typeof json !== 'object' || (!('result' in json) && !json.error)) throw transportError(false, 'not a JSON-RPC answer');
1128      if (json.error && !isRevert(json.error)) throw transportError(false, `node error ${json.error.code}`);
1129      return json;
1130    }).catch((e) => { throw (e && e.code === 'TRANSPORT') ? e : transportError(true, (e && e.message) || String(e)); });
1131  }
1132
1133  async function rpcOn(op, method, params) {
1134    const request = { jsonrpc: '2.0', id: 1, method, params };
1135    const tried = {};
1136    const viaHost = async (url) => {
1137      tried[url] = true;
1138      try { return await rpcPost(url, request, DIRECT_RPC_TIMEOUT_MS); } catch (e) {
1139        // The parallel reads of one poll fail together: one log line per host.
1140        if (!((st.rpcDown[url] || 0) > Date.now())) console.error('[token] public RPC failed, using the fallback', url, e && e.message);
1141        st.rpcDown[url] = Date.now() + (e && e.network ? DIRECT_RETRY_MS : DIRECT_BUSY_RETRY_MS);
1142        return null;
1143      }
1144    };
1145    let json = null;
1146    for (const url of op.rpcs || []) {
1147      if ((st.rpcDown[url] || 0) > Date.now()) continue;
1148      json = await viaHost(url);
1149      if (json) break;
1150    }
1151    let fallbackError = null;
1152    if (!json) {
1153      // The page names the chain its reads are for; the server refuses a
1154      // read for any other (a status that moved under an operation).
1155      try { json = await rpcPost(chainReadUrl(), Object.assign({ chainId: op.chainId }, request), RPC_TIMEOUT_MS); } catch (e) { fallbackError = e; }
1156    }
1157    // Last resort: a host skipped after an earlier failure may be back.
1158    for (const url of op.rpcs || []) {
1159      if (json) break;
1160      if (!tried[url]) json = await viaHost(url);
1161    }
1162    if (!json) {
1163      const e = TxError('CHAIN_UNREACHABLE', `chain unreachable: ${(fallbackError && fallbackError.message) || 'no path'}`);
1164      // Our own server answered with an error: the player's network is fine.
1165      e.kind = fallbackError && fallbackError.network === false ? 'busy' : 'network';
1166      throw e;
1167    }
1168    if (json.error) throw new Error(json.error.message || 'rpc error');
1169    return json.result;
1170  }
1171
1172  // ── chain health and RETRY ────────────────────────────────────────────
1173  // A read ROUND is one vault, lock or PROOF read, and each CARD keeps its
1174  // own count, so shared reads the server still serves from its cache never
1175  // mask a wallet read that keeps failing (review 2026-10-01). Rounds of one
1176  // card started by the same poll (or the same RETRY) share a generation and
1177  // count once, so the notice needs failures a poll apart. Only
1178  // CHAIN_UNREACHABLE counts (every path failed); a contract that answers
1179  // "0x" or reverts is a value problem, not the network.
1180  function chainFails() { return Math.max(...Object.keys(st.chainHealth).map(k => st.chainHealth[k].fails)); }
1181  function chainDown() { return chainFails() >= CHAIN_FAILS_FOR_NOTICE; }
1182  function chainDownLine(kind) {
1183    return (kind || st.chainKind) === 'busy'
1184      ? t('Chain reads are busy. Try again in a minute.')
1185      : t('Cannot reach the chain. Check your connection and try again.');
1186  }
1187  function chainRound(card, gen, err) {
1188    const h = st.chainHealth[card];
1189    if (!err) {
1190      if (!h.fails) return;
1191      h.fails = 0; h.gen = -1;
1192      renderStaking();
1193      return;
1194    }
1195    if (err.code !== 'CHAIN_UNREACHABLE' || gen === h.gen) return;
1196    h.gen = gen;
1197    h.fails++;
1198    st.chainKind = err.kind || 'network';
1199    renderStaking();
1200    if (chainDown()) ambient(chainDownLine(), 'bad');
1201  }
1202
1203  // Asks every chain read again now, the skipped public hosts included.
1204  async function doChainRetry() {
1205    if (st.chainRetrying || !st.status) return;
1206    st.chainRetrying = true;
1207    st.chainGen++;
1208    // The player asked: every public host gets another chance too.
1209    st.rpcDown = {};
1210    renderStaking();
1211    ambient(t('Reading the chain…'));
1212    try {
1213      await Promise.all([refreshVault(), refreshLock(), refreshProofChain()]);
1214    } catch (e) {
1215      console.error('[token] retry', e && e.message);
1216    } finally {
1217      st.chainRetrying = false;
1218    }
1219    if (chainDown()) { renderStaking(); ambient(chainDownLine(), 'bad'); } else render();
1220  }
1221
1222  // Resolves ONLY on an explicit successful receipt. A revert throws REVERTED;
1223  // running out of polls throws PENDING (the outcome is UNKNOWN: it is never
1224  // reported as success and never as an invitation to resend). Outcomes are
1225  // typed codes, never matched on translated text.
1226  async function waitForReceipt(op, hash) {
1227    if (!/^0x[0-9a-fA-F]{64}$/.test(String(hash || ''))) throw TxError('NO_HASH');
1228    for (let i = 0; i < RECEIPT_POLLS; i++) {
1229      await new Promise(r => setTimeout(r, RECEIPT_POLL_MS));
1230      let receipt = null;
1231      try { receipt = await rpcOn(op, 'eth_getTransactionReceipt', [hash]); } catch (e) {
1232        receipt = null;
1233        if (!op.pollErrorLogged) { op.pollErrorLogged = true; console.error('[token] receipt poll failed', hash, e && e.message); }
1234      }
1235      if (!receipt) continue;
1236      if (receipt.status === '0x1') return receipt;
1237      throw TxError('REVERTED', null, hash);
1238    }
1239    throw TxError('PENDING', null, hash);
1240  }
1241
1242  async function sendTx(op, to, data) {
1243    assertSameIdentity(op);
1244    const w = walletRail();
1245    renderStaking();
1246    const hash = await w.sendTransaction({ from: op.account, to, data, chainId: op.chainId });
1247    say(`${t('Sent. Waiting for the chain.')}${hash ? ` ${hash}` : ''}`, 'ok');
1248    await waitForReceipt(op, hash);
1249    return hash;
1250  }
1251
1252  // An outcome we could not determine. The controls stay locked on this hash
1253  // and a background poll keeps asking until THIS transaction's own receipt
1254  // arrives; nothing else releases it. A higher account nonce plus a missing
1255  // receipt is not proof of replacement (another transaction from the same
1256  // account, a lagging RPC node behind a load balancer), and a false release
1257  // invites a second stake (astra round 5). A player who replaced or
1258  // cancelled it in their wallet is told to reload: the page never guesses.
1259  function holdPending(op, hash) {
1260    st.pendingTx = { hash, op };
1261    say(`${t('Still pending. Do not resend. Cancelled in wallet? Reload.')} ${hash}`, 'bad');
1262    renderStaking();
1263    const again = async () => {
1264      if (!st.pendingTx || st.pendingTx.hash !== hash) return;
1265      let receipt = null;
1266      try { receipt = await rpcOn(op, 'eth_getTransactionReceipt', [hash]); } catch (_) { receipt = null; }
1267      if (!receipt) { setTimeout(again, PENDING_RECHECK_MS); return; }
1268      st.pendingTx = null;
1269      say(receipt.status === '0x1' ? t('Confirmed.') : t('The transaction reverted.'), receipt.status === '0x1' ? 'ok' : 'bad');
1270      refreshVault();
1271    };
1272    setTimeout(again, PENDING_RECHECK_MS);
1273  }
1274
1275  function failMessage(e) {
1276    const w = walletRail();
1277    if (w && typeof w.isUserRejected === 'function' && w.isUserRejected(e)) return t('Cancelled. Nothing was sent.');
1278    // A dropped session re-opened the picker and the player closed it.
1279    if (e && e.code === 'WALLET_CLOSED') return t('Cancelled. Nothing was sent.');
1280    const code = e && e.code;
1281    if (code === 'REVERTED') return t('The transaction reverted.');
1282    if (code === 'IDENTITY_CHANGED') return t('The token changed. Nothing more was sent.');
1283    if (code === 'ACCOUNT_CHANGED') return t('The wallet changed. Nothing more was sent.');
1284    if (code === 'NO_HASH') return t('The wallet returned no transaction.');
1285    if (code === 'NOT_READY_ON_CHAIN') return t('The chain clock is not there yet. Try in a minute.');
1286    if (code === 'CHAIN_UNREACHABLE') return chainDownLine(e.kind);
1287    return `${t('That did not go through.')} ${String((e && e.message) || e).slice(0, 160)}`;
1288  }
1289
1290  // Runs one operation under the lock; a PENDING outcome keeps the lock.
1291  // The operation lock is released on EVERY path, including a throw while the
1292  // failure itself is being described: a lock held past its operation leaves
1293  // every control disabled beside a stale "Sent" line (seen twice in the
1294  // browser e2e 2026-09-17 with the withdrawal already on chain).
1295  async function runOp(kind, body) {
1296    const op = beginOp(kind);
1297    if (!op) return;
1298    let pending = null;
1299    try {
1300      await body(op);
1301    } catch (e) {
1302      console.error(e);
1303      if (e && e.code === 'PENDING') pending = e.hash;
1304      else {
1305        let text = '';
1306        try { text = failMessage(e); } catch (fe) { console.error(fe); text = t('That did not go through.'); }
1307        say(text, 'bad');
1308      }
1309    } finally {
1310      endOp();
1311    }
1312    if (pending) holdPending(op, pending);
1313    refreshVault();
1314  }
1315
1316  async function doConnect() {
1317    const w = walletRail();
1318    if (!w || st.pendingTx) return;
1319    // A re-tap while the first connect is still pending (the player closed the
1320    // picker, or came back from a wallet app without approving) goes to the
1321    // wallet layer, which re-opens the picker and joins the SAME attempt; the
1322    // first tap still owns the outcome. Until 2026-10-01 this returned early,
1323    // and the button stayed dead for as long as a pairing could be pending
1324    // (up to 10 minutes).
1325    if (st.busy === 'connect') { w.connect().catch(() => {}); return; }
1326    if (st.busy) return;
1327    st.busy = 'connect'; renderStaking();
1328    try {
1329      await w.connect();
1330    } catch (e) {
1331      console.error(e);
1332      // Closing the picker is not an error. A wallet that connects later still
1333      // paints through onChange.
1334      if (!(e && e.code === 'WALLET_CLOSED')) say(failMessage(e), 'bad');
1335    } finally {
1336      st.busy = '';
1337    }
1338    refreshVault();
1339  }
1340
1341  async function doSwitch() {
1342    const w = walletRail();
1343    if (!w || st.busy || st.pendingTx) return;
1344    st.busy = 'switch'; renderStaking();
1345    try { await w.ensureChain(Number(st.status.chain.id)); } catch (e) { console.error(e); say(failMessage(e), 'bad'); }
1346    finally { st.busy = ''; }
1347    refreshVault();
1348  }
1349
1350  async function doStake() {
1351    const v = st.vault;
1352    if (!v || st.busy || st.pendingTx) return;
1353    const amount = parseTokens($('tokenStakeAmount') && $('tokenStakeAmount').value);
1354    if (!amount) { say(t('Enter an amount.'), 'bad'); return; }
1355    if (amount > v.balance) { say(t('More than the wallet holds.'), 'bad'); return; }
1356    await runOp('stake', async (op) => {
1357      // The allowance is read again inside the operation, for THIS identity.
1358      const allowance = decUint(await rpcOn(op, 'eth_call', [{ to: op.token, data: SEL.allowance + encAddress(op.account) + encAddress(op.vault) }, 'latest']));
1359      if (allowance < amount) {
1360        say(t('Step 1 of 2: approve.'));
1361        st.busy = 'approve';
1362        await sendTx(op, op.token, txData(SEL.approve, encAddress(op.vault), encUint(amount)));
1363      }
1364      say(t('Step 2 of 2: stake. Confirm in your wallet.'));
1365      st.busy = 'stake';
1366      await sendTx(op, op.vault, txData(SEL.stake, encUint(amount)));
1367      say(t('Staked. Counts from the next snapshot.'), 'ok');
1368      if ($('tokenStakeAmount')) $('tokenStakeAmount').value = '';
1369    });
1370  }
1371
1372  async function doUnstake() {
1373    const v = st.vault;
1374    if (!v || st.busy || st.pendingTx) return;
1375    if (!st.unstakeOpen) { st.unstakeOpen = true; renderStaking(); return; }
1376    const amount = parseTokens($('tokenUnstakeAmount') && $('tokenUnstakeAmount').value);
1377    if (!amount) { say(t('Enter an amount.'), 'bad'); return; }
1378    if (amount > v.staked) { say(t('More than you have staked.'), 'bad'); return; }
1379    await runOp('unstake', async (op) => {
1380      say(t('Confirm in your wallet.'));
1381      await sendTx(op, op.vault, txData(SEL.requestUnstake, encUint(amount)));
1382      say(t('Unstake requested. Withdraw when the clock runs out.'), 'ok');
1383      st.unstakeOpen = false;
1384      if ($('tokenUnstakeAmount')) $('tokenUnstakeAmount').value = '';
1385    });
1386  }
1387
1388  async function doWithdraw() {
1389    if (!st.vault || st.busy || st.pendingTx || stakingState() !== 'ready') return;
1390    const readyAt = st.vault.readyAt;
1391    await runOp('withdraw', async (op) => {
1392      // The CHAIN decides readiness (block.timestamp), not the server clock the
1393      // countdown runs on: ask it before spending the player's gas on a revert.
1394      const block = await rpcOn(op, 'eth_getBlockByNumber', ['latest', false]);
1395      if (!block || Number(BigInt(block.timestamp)) < readyAt) throw TxError('NOT_READY_ON_CHAIN');
1396      say(t('Confirm in your wallet.'));
1397      await sendTx(op, op.vault, SEL.withdraw);
1398      say(t('Withdrawn to your wallet.'), 'ok');
1399    });
1400  }
1401
1402  // Exact 18-decimal string for MAX (the display formatter rounds to 2 places,
1403  // which left dust that could never be selected).
1404  function exactTokens(wei) {
1405    const v = BigInt(wei);
1406    const whole = v / ONE;
1407    const frac = (v % ONE).toString().padStart(Number(DECIMALS), '0').replace(/0+$/, '');
1408    return frac ? `${whole}.${frac}` : `${whole}`;
1409  }
1410
1411  function initStaking() {
1412    const on = (id, fn) => { const el = $(id); if (el) el.addEventListener('click', (ev) => { if (ev && ev.preventDefault) ev.preventDefault(); fn(); }); };
1413    on('tokenStakeConnectBtn', doConnect);
1414    on('tokenStakeSwitchBtn', doSwitch);
1415    on('tokenStakeBtn', doStake);
1416    on('tokenUnstakeBtn', doUnstake);
1417    on('tokenWithdrawBtn', doWithdraw);
1418    on('tokenStakeMax', () => { if (st.vault && $('tokenStakeAmount')) $('tokenStakeAmount').value = exactTokens(st.vault.balance); });
1419    on('tokenUnstakeMax', () => { if (st.vault && $('tokenUnstakeAmount')) $('tokenUnstakeAmount').value = exactTokens(st.vault.staked); });
1420    on('tokenUnstakeCancel', () => { st.unstakeOpen = false; renderStaking(); });
1421    on('tokenChainRetry', doChainRetry);
1422    const w = walletRail();
1423    if (w && typeof w.onChange === 'function') w.onChange(() => refreshVault());
1424    if (w && typeof w.refreshState === 'function') { try { w.refreshState().then(() => refreshVault()).catch(() => refreshVault()); } catch (_) { refreshVault(); } }
1425    // Unbonding countdown + ready flip without a poll.
1426    setInterval(() => { if (!document.hidden && st.vault && st.vault.pending > 0n) renderStaking(); }, 1000);
1427  }
1428
1429  // ── DAILY ASH DROP ────────────────────────────────────────────────────
1430  // The status decides whether the token is live; the stats only fill
1431  // numbers in when it is, so a server the token is not live on never shows
1432  // a live drop. Both are the gateway's fleet records (publicUrl).
1433  function liveStats() {
1434    const s = st.status;
1435    const x = st.stats;
1436    return x && x.stakingLive && x.pool && s && s.phase === 'launched' && s.contractAddress && s.stakingAddress ? x : null;
1437  }
1438
1439  // The pool the page advertises: the next drop's pool from the stats; else
1440  // the cold-start floor from the status config while it still covers the
1441  // next drop; else null, and the markup's figure is hidden rather than kept.
1442  // The floor is judged at the NEXT DROP, not now: the record says so
1443  // (floorActive, judged at the snapshot time server side); without a record
1444  // the page assumes the next drop is a full day away, so the hours between
1445  // the last floored drop and the floor's end never advertise a floor the
1446  // next drop will not get (review 2026-09-29).
1447  function dropPool() {
1448    const x = liveStats();
1449    if (x && Number(x.pool.nextAsh) > 0) return Number(x.pool.nextAsh);
1450    const s = st.status;
1451    const em = s && s.emission;
1452    if (!em) return null;
1453    const floor = Math.floor(Number(em.coldStartFloorUsd) * Number(em.ashPerUsd));
1454    if (!(floor > 0)) return null;
1455    if (x) return x.pool.floorActive ? floor : null;
1456    if (s.phase !== 'launched' || !s.launchedAt) return floor;
1457    return serverNow() + 86400 - Number(s.launchedAt) <= Number(em.coldStartFloorDays) * 86400 ? floor : null;
1458  }
1459
1460  // Everything in the vault, linked or not: the estimate divides by the
1461  // largest honest denominator, so it never overstates a share. No vault
1462  // read, no estimate.
1463  function calcStaked(x) {
1464    const v = x && x.staked && x.staked.total != null ? Number(x.staked.total) : NaN;
1465    return Number.isFinite(v) && v >= 0 ? v : null;
1466  }
1467
1468  // The $THRONE typed by the visitor ("12,500", "12500.5", "12,5"), in WHOLE
1469  // tokens as the snapshot counts them (each wallet's stake is floored to
1470  // whole tokens, so 0.5 $THRONE earns nothing), or null for no amount.
1471  function calcAmount() {
1472    const el = $('tokenCalcAmount');
1473    const s = normalizeAmount(el && el.value);
1474    if (s == null) return null;
1475    const n = Math.floor(Number(s));
1476    return Number.isFinite(n) && n >= 0 && Number(s) > 0 ? n : null;
1477  }
1478
1479  // pool x amount / (everything staked + amount): the visitor's cut if they
1480  // staked now and nothing else moved. Whole ASH, floored like a real lot.
1481  function calcEstimate(pool, staked, amount) {
1482    if (!(pool > 0) || !(amount > 0) || !(staked >= 0)) return null;
1483    return Math.floor(pool * amount / (staked + amount));
1484  }
1485
1486  // "10K", "1M" in the page's language (Intl compact notation), plain digits
1487  // where the runtime cannot say it.
1488  function fmtShort(v) {
1489    const locale = (window.i18n && window.i18n.locale) || 'en-US';
1490    try { return new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: 1 }).format(v); } catch (_) { return fmtInt(v); }
1491  }
1492
1493  // Quick picks are the staking tier thresholds from config.
1494  function renderCalcPicks() {
1495    const wrap = $('tokenCalcPicks');
1496    const s = st.status;
1497    if (!wrap || !s || !s.staking) return;
1498    const amounts = (s.staking.tiers || []).map(tier => Number(tier.minStaked)).filter(v => v > 0);
1499    const key = amounts.join(',');
1500    if (wrap.getAttribute('data-picks') === key) return;
1501    wrap.setAttribute('data-picks', key);
1502    wrap.innerHTML = '';
1503    for (const v of amounts) {
1504      const b = document.createElement('button');
1505      b.type = 'button';
1506      b.className = 'btn btn--ghost-sm ash-calc__pick';
1507      b.textContent = fmtShort(v);
1508      b.addEventListener('click', () => {
1509        const input = $('tokenCalcAmount');
1510        if (input) input.value = fmtInt(v);
1511        renderCalc();
1512      });
1513      wrap.appendChild(b);
1514    }
1515  }
1516
1517  function renderCalc() {
1518    const x = liveStats();
1519    const pool = dropPool();
1520    const staked = calcStaked(x);
1521    const on = !!x && pool != null && staked != null;
1522    show($('tokenCalc'), on);
1523    if (!on) return;
1524    const amount = calcAmount();
1525    // Under one whole token the drop pays nothing; a share floored to 0 ASH
1526    // is 0, said as a number with the reason, never "about under 1".
1527    const est = amount === 0 ? 0 : (amount == null ? null : calcEstimate(pool, staked, amount));
1528    setText('tokenCalcAsh', est == null ? '…' : fmtInt(est));
1529    if (amount === 0) setText('tokenCalcNote', t('The drop counts whole $THRONE. Stake at least 1.'));
1530    else if (est === 0) setText('tokenCalcNote', t('Under 1 whole ASH a day at this stake.'));
1531    else setText('tokenCalcNote', t("At today's pool and {0} $THRONE staked.", fmtInt(staked)));
1532  }
1533
1534  function renderDropNext() {
1535    const x = liveStats();
1536    const at = x && x.schedule ? Number(x.schedule.nextAt) : NaN;
1537    const on = Number.isFinite(at) && at > 0;
1538    show($('tokenDropNextTile'), on);
1539    if (!on) return;
1540    const left = at - serverNow();
1541    // The home band shows the same moment through the same helper.
1542    const until = window.ashDrop && window.ashDrop.fmtUntil;
1543    setText('tokenDropNext', typeof until === 'function' ? until(left) : (left > 0 ? fmtCountdown(left) : t('ANY MOMENT')));
1544  }
1545
1546  // The Golden Ash tab callout: ONE translated sentence with the pool in it
1547  // ({0}), the number then wrapped for styling by splitting the sentence at
1548  // its slot, so a locale can put the number where its grammar wants it. The
1549  // pieces are marked data-no-i18n: they are translated already, and the
1550  // DOM translator must not match a fragment of them against the catalog.
1551  function renderGoldLine(value) {
1552    const el = $('goldDropText');
1553    if (!el) return;
1554    const GOLD_SLOT = '\u0001';
1555    const sentence = t('{0} ASH a day, shared by $THRONE stakers with a linked wallet.', GOLD_SLOT);
1556    const key = `${sentence}|${value}`;
1557    if (el.getAttribute('data-rendered') === key) return;
1558    el.setAttribute('data-rendered', key);
1559    el.textContent = '';
1560    const wrap = document.createElement('span');
1561    wrap.setAttribute('data-no-i18n', '');
1562    sentence.split(GOLD_SLOT).forEach((part, i) => {
1563      if (i > 0) {
1564        const b = document.createElement('b');
1565        b.className = 'ash-callout__num';
1566        b.textContent = value;
1567        wrap.appendChild(b);
1568      }
1569      if (part) wrap.appendChild(document.createTextNode(part));
1570    });
1571    el.appendChild(wrap);
1572  }
1573
1574  function renderRail() {
1575    const rail = $('tokenRailSub');
1576    const s = st.status;
1577    if (!rail || !s) return;
1578    const live = s.phase === 'launched' && !!s.contractAddress;
1579    const pool = dropPool();
1580    rail.textContent = !live ? t('Coming soon') : (pool != null ? t('Stake for {0} ASH a day', fmtInt(pool)) : t('Stake for daily ASH'));
1581  }
1582
1583  function renderDrop() {
1584    const s = st.status;
1585    const x = liveStats();
1586    const pool = dropPool();
1587    const live = !!(s && s.phase === 'launched' && s.contractAddress);
1588    // Before the first status answer the markup's fixed pool stands.
1589    const num = $('tokenDropPool');
1590    if (num) {
1591      if (pool != null) num.textContent = fmtInt(pool);
1592      show(num, pool != null || !s);
1593    }
1594    const gold = $('goldDrop');
1595    if (gold) {
1596      if (pool != null) renderGoldLine(fmtInt(pool));
1597      show(gold, !s || (live && pool != null));
1598    }
1599    const floor = $('tokenDropFloor');
1600    const floorOn = !!(x && x.pool.floorActive && x.pool.floorLastDay && Number(x.pool.floorAsh) > 0);
1601    // Non-breaking hyphens: a phone must never wrap the date mid-way (2027-03- / 27).
1602    if (floor && floorOn) floor.textContent = t('Never below {0} ASH a day through {1}.', fmtInt(x.pool.floorAsh), String(x.pool.floorLastDay).replace(/-/g, '\u2011'));
1603    show(floor, floorOn);
1604
1605    show($('tokenDropStats'), !!x);
1606    if (x) {
1607      renderDropNext();
1608      const total = calcStaked(x);
1609      show($('tokenDropStakedTile'), total != null);
1610      if (total != null) setText('tokenDropStaked', fmtInt(total));
1611      show($('tokenDropStakersTile'), !!x.lastDrop);
1612      if (x.lastDrop) setText('tokenDropStakers', fmtInt(x.lastDrop.stakers));
1613      const paid = !!(x.totals && Number(x.totals.drops) > 0);
1614      show($('tokenDropPaidTile'), paid);
1615      if (paid) setText('tokenDropPaid', fmtInt(x.totals.grantedAsh));
1616    }
1617    setLink($('tokenDropBuy'), live && s.links ? s.links.buy : null);
1618    show($('tokenDropStake'), !s || (live && !!s.stakingAddress));
1619    renderCalcPicks();
1620    renderCalc();
1621    renderRail();
1622  }
1623
1624  function statsUrl() { return publicUrl(STATS_PATH); }
1625
1626  async function refreshStats({ force = false } = {}) {
1627    const stale = !st.stats || Date.now() - st.statsAt >= STATS_REFRESH_MS;
1628    const backedOff = st.statsFailAt && Date.now() - st.statsFailAt < STATS_REFRESH_MS;
1629    if (!force && (!stale || backedOff)) return;
1630    const seq = ++st.statsSeq;
1631    try {
1632      const next = await boundedFetch(statsUrl(), { headers: { Accept: 'application/json' }, cache: 'no-store' }, FETCH_TIMEOUT_MS, (res) => {
1633        if (!res.ok) throw new Error(`Stats unavailable (${res.status})`);
1634        return res.json();
1635      });
1636      if (seq !== st.statsSeq) return;
1637      if (!next || typeof next !== 'object' || !next.pool || !next.schedule) throw new Error('Stats malformed');
1638      st.stats = next;
1639      st.statsAt = Date.now();
1640      st.statsFailAt = 0;
1641    } catch (e) {
1642      if (seq !== st.statsSeq) return;
1643      st.statsFailAt = Date.now();
1644      console.error('[token] stats', e && e.message);
1645      if (st.stats && Date.now() - st.statsAt > STATS_MAX_AGE_MS) st.stats = null;
1646    }
1647    if (st.status) renderTokenomics(st.status);
1648    renderDrop();
1649  }
1650
1651  function initDrop() {
1652    const stake = $('tokenDropStake');
1653    if (stake) {
1654      stake.addEventListener('click', (ev) => {
1655        if (ev && ev.preventDefault) ev.preventDefault();
1656        const card = $('tokenStakingCard');
1657        if (card && typeof card.scrollIntoView === 'function') card.scrollIntoView({ behavior: 'smooth', block: 'start' });
1658      });
1659    }
1660    const input = $('tokenCalcAmount');
1661    if (input) input.addEventListener('input', () => renderCalc());
1662    setInterval(() => { if (!document.hidden) renderDropNext(); }, 1000);
1663  }
1664
1665  function render() {
1666    const s = st.status;
1667    if (!s) return;
1668    renderTokenomics(s);
1669    renderPhase(s);
1670    renderStaking();
1671    renderLock();
1672    renderAllowance();
1673    renderStale();
1674    renderProof();
1675    renderDrop();
1676  }
1677
1678  function statusUrl() { return publicUrl(STATUS_PATH); }
1679
1680  async function fetchStatus() {
1681    return boundedFetch(statusUrl(), { headers: { Accept: 'application/json' }, cache: 'no-store' }, FETCH_TIMEOUT_MS, (res) => {
1682      if (!res.ok) throw new Error(`Token status unavailable (${res.status})`);
1683      return res.json();
1684    });
1685  }
1686
1687  // Single-flight with newest-wins: a visibility refresh landing during a
1688  // poll shares the poll, and an older answer can never overwrite a newer
1689  // one (which is how a reset could have restored an obsolete address).
1690  async function refresh() {
1691    if (st.inFlight) return st.inFlight;
1692    const seq = ++st.seq;
1693    st.inFlight = (async () => {
1694      try {
1695        const next = await fetchStatus();
1696        if (seq !== st.seq) return;
1697        const before = st.status;
1698        st.status = next;
1699        st.lastOkAt = Date.now();
1700        st.stale = false;
1701        if (Number.isFinite(Number(st.status.now))) st.clockOffset = Number(st.status.now) - Math.floor(Date.now() / 1000);
1702        render();
1703        renderLock();
1704        // Chain state moves without the status row moving (a transfer into
1705        // the lock, a stake from another device), so every successful poll
1706        // re-reads the lock, and the vault whenever a wallet is connected.
1707        // Four eth_calls per poll is cheap; a lock card stuck on 0 after
1708        // the Safe's transfer landed is how this was first noticed.
1709        const addrChanged = !before || before.stakingAddress !== next.stakingAddress || before.contractAddress !== next.contractAddress || before.teamLockAddress !== next.teamLockAddress;
1710        // One poll, one chain-read attempt (chainRound).
1711        st.chainGen++;
1712        refreshLock();
1713        refreshAllowance();
1714        refreshProof();
1715        refreshStats();
1716        if (addrChanged || st.wallet.address) refreshVault();
1717      } catch (e) {
1718        console.error(e);
1719        if (!st.status) ambient(t('Status unavailable. Retrying.'), 'bad');
1720        else { st.stale = true; renderStale(); }
1721      } finally {
1722        st.inFlight = null;
1723        schedule();
1724      }
1725    })();
1726    return st.inFlight;
1727  }
1728
1729  function schedule() {
1730    clearTimeout(st.timer);
1731    const secs = (st.status && Number(st.status.pollSeconds)) || FALLBACK_POLL_SECONDS;
1732    // Poll only while the tab is visible; a hidden tab re-fetches on return.
1733    st.timer = setTimeout(() => {
1734      if (document.hidden) { schedule(); return; }
1735      refresh();
1736    }, Math.max(5, secs) * 1000);
1737  }
1738
1739  function initCopy() {
1740    const btn = $('tokenCopy');
1741    if (!btn) return;
1742    btn.addEventListener('click', async () => {
1743      const ca = st.status && st.status.contractAddress;
1744      if (!ca) return;
1745      try {
1746        await navigator.clipboard.writeText(ca);
1747        btn.textContent = t('COPIED');
1748        setTimeout(() => { btn.textContent = t('COPY'); }, 1600);
1749      } catch (_) {
1750        setMessage(ca, 'ok');
1751      }
1752    });
1753  }
1754
1755  function init() {
1756    if (!$('tokenPanel')) return;
1757    initCopy();
1758    initTips();
1759    initStaking();
1760    initDrop();
1761    st.countdownTimer = setInterval(() => { if (!document.hidden) renderCountdown(); }, 1000);
1762    document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
1763    refresh();
1764  }
1765
1766  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
1767  else init();
1768
1769  window.tokenPage = { refresh, render, refreshVault, refreshAllowance, _state: st, _stakingState: stakingState, _tip: tip, _tipKeys: () => Object.keys(TIPS), _waitForReceipt: waitForReceipt, _doStake: doStake, _doWithdraw: doWithdraw, _exactTokens: exactTokens, _refreshLock: refreshLock, _boundedFetch: boundedFetch, _refreshProof: refreshProof, _renderProof: renderProof, _refreshStats: refreshStats, _renderDrop: renderDrop, _dropPool: dropPool, _calcEstimate: calcEstimate, _parseTokens: parseTokens, _calcAmount: calcAmount, _doChainRetry: doChainRetry, _chainHosts: chainHosts, _chainFails: chainFails };
1770})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.