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.