New BCH chipnet faucet by BCH Ninja. Superior to tbch.googol.cash for this project: single flow for both sats top-up AND CashToken genesis (fungible FT + NFT with immutable / mutable / minting capability + optional hex commitment), token-aware addresses (bchtest:z/r), no captcha (client-side bot check instead), limit 0.1% of available balance per request. Not embeddable — the faucet declares CSP frame-ancestors 'none' explicitly, so we don't attempt the iframe. Opens in a new tab via the standard faucet-item blocked flow. tbch.googol.cash kept as the sats-only fallback. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1647 lines
99 KiB
HTML
1647 lines
99 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Testnet Faucet Hub</title>
|
||
<style>
|
||
:root{
|
||
--bg:#0b0e14; --panel:#141a24; --panel2:#1b2330; --line:#26303f;
|
||
--txt:#e6edf3; --dim:#8b98a9; --accent:#4fd1c5; --accent2:#f6ad55;
|
||
--ok:#48bb78; --btc:#f7931a; --eth:#627eea; --tron:#e50914; --bch:#0ac18e; --bsc:#f0b90b;
|
||
--base:#3c7cff; --arb:#12aaff; --op:#ff0420; --poly:#8b5cf6; --sol:#9945ff; --avax:#e84142; --xrp:#3aa0e0; --xlm:#f0c14b;
|
||
--radius:14px; --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
|
||
}
|
||
*{box-sizing:border-box}
|
||
body{
|
||
margin:0;background:radial-gradient(1200px 600px at 20% -10%,#16202e,var(--bg));
|
||
color:var(--txt);font:15px/1.55 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
||
min-height:100vh;padding:36px 20px 80px;
|
||
}
|
||
.wrap{max-width:980px;margin:0 auto}
|
||
header{margin-bottom:20px;position:relative;padding-right:270px}
|
||
h1{font-size:26px;margin:0 0 6px;letter-spacing:.3px}
|
||
.account{position:absolute;top:0;right:0;display:flex;align-items:center;gap:6px;background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:5px 8px}
|
||
.account .acct-ic{font-size:14px;opacity:.85}
|
||
.account select{background:#0f1620;color:var(--txt);border:1px solid var(--line);border-radius:7px;padding:4px 6px;font-size:12.5px;font-family:inherit;max-width:130px;cursor:pointer}
|
||
.account select:focus{outline:none;border-color:var(--accent)}
|
||
.acct-btn{cursor:pointer;background:none;border:1px solid var(--line);border-radius:7px;color:var(--dim);padding:4px 8px;font-size:12px;line-height:1.2;transition:.15s}
|
||
.acct-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||
.acct-btn:disabled{opacity:.4;cursor:default}
|
||
.acct-btn.del:hover{border-color:#f6768a;color:#f6768a}
|
||
/* owner sign-in panel (public deploys with an encrypted seed) */
|
||
.acct-panel{position:absolute;top:calc(100% + 6px);right:0;z-index:45;width:300px;
|
||
display:flex;flex-direction:column;gap:9px;padding:13px;border:1px solid var(--line);
|
||
border-radius:10px;background:var(--panel2);box-shadow:0 10px 26px rgba(0,0,0,.5)}
|
||
.acct-panel[hidden]{display:none}
|
||
.acct-panel-title{font-size:13px;font-weight:700}
|
||
.acct-panel input[type="password"],.acct-panel textarea{font-family:var(--mono);font-size:12px;background:#0f1620;
|
||
border:1px solid var(--line);border-radius:8px;padding:8px 10px;color:#cfe8e4;width:100%;resize:vertical;line-height:1.5}
|
||
.acct-panel input[type="password"]:focus,.acct-panel textarea:focus{outline:none;border-color:var(--accent)}
|
||
.acct-remember{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--dim);cursor:pointer}
|
||
.acct-remember input{accent-color:var(--accent);margin:0}
|
||
.acct-row{display:flex;align-items:center;gap:8px}
|
||
.acct-hint{font-size:11px;color:var(--dim);line-height:1.5}
|
||
.acct-hint b{color:#bfeae4}
|
||
@media(max-width:640px){ header{padding-right:0} .account{position:static;margin-bottom:14px;justify-content:flex-start} }
|
||
.sub{color:var(--dim);font-size:14px;max-width:720px}
|
||
.warn{margin-top:14px;padding:10px 14px;border-radius:10px;font-size:13px;
|
||
background:rgba(246,173,85,.08);border:1px solid rgba(246,173,85,.3);color:#f6d29a;}
|
||
.note{margin-top:10px;padding:10px 14px;border-radius:10px;font-size:13px;
|
||
background:rgba(79,209,197,.06);border:1px solid rgba(79,209,197,.25);color:#bfeae4;}
|
||
|
||
/* controls */
|
||
.controls{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:18px}
|
||
.seg{display:inline-flex;border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
||
.seg button{background:var(--panel2);color:var(--dim);border:0;padding:8px 14px;font-size:13px;font-weight:600;cursor:pointer;transition:.15s}
|
||
.seg button+button{border-left:1px solid var(--line)}
|
||
.seg button.active{background:var(--accent);color:#06231f}
|
||
.seg button:not(.active):hover{color:var(--txt);background:#1f2a38}
|
||
.refresh{cursor:pointer;background:none;border:1px solid var(--line);border-radius:9px;color:var(--dim);padding:8px 13px;font-size:13px;font-weight:600;transition:.15s}
|
||
.refresh:hover{border-color:var(--accent);color:var(--accent)}
|
||
.refresh:disabled{opacity:.7;cursor:default}
|
||
.auto{display:inline-flex;align-items:center;gap:7px;font-size:13px;color:var(--dim);border:1px solid var(--line);border-radius:9px;padding:5px 11px}
|
||
.auto select{background:var(--panel2);color:var(--txt);border:1px solid var(--line);border-radius:7px;padding:4px 6px;font-size:12.5px;font-family:inherit}
|
||
.auto.live{border-color:var(--ok);color:#bff0d2}
|
||
/* chain-visibility dropdown (header) */
|
||
.chains-wrap{position:relative}
|
||
.chains-panel{position:absolute;top:calc(100% + 6px);left:0;z-index:40;min-width:210px;
|
||
display:flex;flex-direction:column;gap:2px;padding:9px 11px;border:1px solid var(--line);
|
||
border-radius:9px;background:var(--panel2);box-shadow:0 10px 26px rgba(0,0,0,.5)}
|
||
.chains-panel[hidden]{display:none}
|
||
.chains-panel label{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--txt);cursor:pointer;padding:3px 5px;border-radius:6px;white-space:nowrap}
|
||
.chains-panel label:hover{background:#1f2a38}
|
||
.chains-panel label.off{color:var(--dim)}
|
||
.chains-panel label.lock{opacity:.55;cursor:default}
|
||
.chains-panel input{accent-color:var(--accent);margin:0}
|
||
#chains-btn.open{border-color:var(--accent);color:var(--accent)}
|
||
.unit-hidden{display:none !important}
|
||
@keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
|
||
.spin1{animation:spin .5s ease}
|
||
.spinning{animation:spin .8s linear infinite}
|
||
|
||
/* tabbar (tabs view only) */
|
||
#tabbar{display:none;gap:8px;margin:20px 0 4px;overflow-x:auto;padding-bottom:6px}
|
||
#tabbar .tab{display:inline-flex;align-items:center;gap:8px;white-space:nowrap;cursor:pointer;
|
||
background:var(--panel2);border:1px solid var(--line);border-radius:20px;padding:8px 14px;font-size:13px;font-weight:600;color:var(--dim);transition:.15s}
|
||
#tabbar .tab .dot{width:9px;height:9px;border-radius:50%;box-shadow:0 0 8px currentColor}
|
||
#tabbar .tab:hover{color:var(--txt);border-color:var(--accent)}
|
||
#tabbar .tab.active{color:var(--txt);border-color:var(--accent);background:#1f2a38}
|
||
body[data-view="tabs"] #panel-seg{display:none}
|
||
|
||
/* panels */
|
||
#panels{display:grid;gap:14px;margin-top:18px}
|
||
.card{background:linear-gradient(180deg,var(--panel),#10151d);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
|
||
.card-head{display:flex;align-items:center;gap:11px;padding:14px 16px}
|
||
.dot{width:11px;height:11px;border-radius:50%;flex:0 0 auto;box-shadow:0 0 12px currentColor}
|
||
.cicon{width:23px;height:23px;flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;filter:drop-shadow(0 0 6px currentColor)}
|
||
.cicon svg{width:100%;height:100%;fill:currentColor}
|
||
.cicon.sm{width:17px;height:17px}
|
||
.chev{color:var(--dim);font-size:11px;transition:transform .2s;flex:0 0 auto}
|
||
.card.collapsed .chev{transform:rotate(-90deg)}
|
||
.card-head h2{font-size:16px;margin:0;flex:1}
|
||
.evm-net{flex:1;min-width:0;font-size:15px;font-weight:600;color:var(--txt);background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:5px 8px;font-family:inherit;cursor:pointer;max-width:260px}
|
||
.evm-net:hover{border-color:var(--accent)}
|
||
.evm-net:focus{outline:none;border-color:var(--accent)}
|
||
.head-ctls{display:flex;align-items:center;gap:7px;flex:0 0 auto}
|
||
.auto-lbl{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--dim);white-space:nowrap}
|
||
.chain-auto{background:var(--panel2);color:var(--dim);border:1px solid var(--line);border-radius:7px;padding:4px 6px;font-size:11.5px;font-family:inherit;cursor:pointer}
|
||
.chain-auto:focus{outline:none;border-color:var(--accent)}
|
||
.chain-auto.live{border-color:var(--ok);color:#bff0d2}
|
||
.evm-order{background:var(--panel2);color:var(--dim);border:1px solid var(--line);border-radius:7px;padding:4px 6px;font-size:11.5px;font-family:inherit;cursor:pointer}
|
||
.evm-order:focus{outline:none;border-color:var(--accent)}
|
||
.badge{font-size:11px;color:var(--dim);border:1px solid var(--line);border-radius:20px;padding:3px 9px;text-transform:uppercase;letter-spacing:.6px}
|
||
.card-body{padding:4px 16px 16px;border-top:1px solid var(--line)}
|
||
.row{margin-bottom:16px}
|
||
.row:last-child{margin-bottom:0}
|
||
.row:first-child{margin-top:12px}
|
||
.label{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.7px;margin-bottom:8px}
|
||
.card.collapsed .card-body{display:none}
|
||
body[data-view="cards"] .card-head{cursor:pointer}
|
||
body[data-view="cards"] .card-head:hover{background:rgba(255,255,255,.02)}
|
||
|
||
/* tabs view: only active card shown, always expanded */
|
||
body[data-view="tabs"] #tabbar{display:flex}
|
||
body[data-view="tabs"] #panels{display:block}
|
||
body[data-view="tabs"] .card{display:none}
|
||
body[data-view="tabs"] .card.active-tab{display:block}
|
||
body[data-view="tabs"] .card.active-tab .card-body{display:block}
|
||
body[data-view="tabs"] .chev{display:none}
|
||
|
||
/* watchlist — single compact row */
|
||
.wl-region-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}
|
||
.wl-region-head .label{margin-bottom:0}
|
||
.wl-item{display:flex;align-items:flex-start;gap:10px;background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:8px 10px;margin-bottom:7px}
|
||
.wl-info{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||
.wl-label{font-size:12px;font-weight:600;color:var(--txt);cursor:pointer;border-bottom:1px dashed transparent}
|
||
.wl-label:hover{border-bottom-color:var(--dim)}
|
||
.wl-label.muted{color:var(--dim);font-weight:400;font-style:italic}
|
||
.wl-label-input{font-size:12px;font-weight:600;background:#0f1620;border:1px solid var(--accent);border-radius:6px;color:var(--txt);padding:2px 6px;font-family:inherit;min-width:110px}
|
||
.wl-tag{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:#8fe3d8;border:1px solid rgba(79,209,197,.4);border-radius:20px;padding:1px 7px}
|
||
.grow{flex:1 1 auto}
|
||
.wl-addr{font-family:var(--mono);font-size:11.5px;color:#cfe8e4;overflow-wrap:anywhere;cursor:pointer;text-decoration:none;border-bottom:1px dashed transparent}
|
||
.wl-addr:hover{border-bottom-color:var(--accent);color:#eafffb}
|
||
.wl-addr-input{font-family:var(--mono);font-size:11.5px;background:#0f1620;border:1px solid var(--accent);border-radius:6px;color:#cfe8e4;padding:3px 7px;flex:1;min-width:200px}
|
||
.wl-label-input.bad,.wl-addr-input.bad{border-color:#f6768a}
|
||
.edit-wrap{display:inline-flex;align-items:center;gap:5px}
|
||
.edit-wrap.addr{flex:1 1 auto;min-width:0}
|
||
.iconbtn.del:hover{border-color:#f6768a;color:#f6768a}
|
||
.iconbtn.ok:hover{border-color:var(--ok);color:var(--ok)}
|
||
.iconbtn.no:hover{border-color:#f6768a;color:#f6768a}
|
||
/* per-address assets — one grid row per shown asset: [amount] [ticker] */
|
||
.bal-grid{flex:0 0 auto;display:grid;grid-template-columns:auto auto;column-gap:7px;row-gap:5px;align-items:baseline}
|
||
.bal-grid .bal{justify-self:end}
|
||
.bal-grid .k{justify-self:start;color:var(--dim);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
|
||
/* assets selector */
|
||
.wl-region-btns{display:flex;gap:8px;flex-wrap:wrap;flex:0 0 auto}
|
||
.assets-panel{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:9px;padding:9px 11px;border:1px solid var(--line);border-radius:9px;background:#0f1620}
|
||
.assets-panel[hidden]{display:none}
|
||
.assets-hint{font-size:11px;color:var(--dim);margin-right:2px}
|
||
.asset-chip,.asset-scan{cursor:pointer;background:var(--panel2);border:1px solid var(--line);border-radius:16px;color:var(--dim);padding:4px 11px;font-size:11.5px;font-weight:600;transition:.15s}
|
||
.asset-chip:hover,.asset-scan:hover{border-color:var(--accent);color:var(--accent)}
|
||
.asset-chip.on{background:rgba(79,209,197,.12);border-color:var(--accent);color:#bff0ea}
|
||
.asset-scan[disabled]{opacity:.6;cursor:default}
|
||
.bal{font-family:var(--mono);font-weight:600;font-size:12.5px;color:#cfe8e4;white-space:nowrap}
|
||
.bal.loading{color:var(--dim);font-weight:400}
|
||
.bal.err{color:var(--accent2)}
|
||
.iconbtn{cursor:pointer;background:none;border:1px solid var(--line);border-radius:7px;color:var(--dim);padding:3px 8px;font-size:11.5px;font-weight:600;transition:.15s;line-height:1.4;flex:0 0 auto}
|
||
.iconbtn:hover{border-color:var(--accent);color:var(--accent)}
|
||
.iconbtn.copy.done{border-color:var(--ok);color:var(--ok)}
|
||
.iconbtn.rm:hover{border-color:#f6768a;color:#f6768a}
|
||
.iconbtn.busy{border-color:var(--accent);color:var(--accent)}
|
||
.wl-empty{font-size:12.5px;color:var(--dim);padding:2px 0 6px}
|
||
|
||
/* add address */
|
||
.wl-add{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px}
|
||
.wl-add input{font-family:var(--mono);font-size:12.5px;background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:8px 11px;color:#cfe8e4}
|
||
.wl-add .wl-input{flex:1;min-width:200px}
|
||
.wl-add .wl-lbl{width:150px;font-family:inherit}
|
||
.wl-add input:focus{outline:none;border-color:var(--accent)}
|
||
.addbtn{cursor:pointer;background:var(--accent);color:#06231f;border:0;border-radius:9px;padding:8px 14px;font-size:13px;font-weight:700;transition:.15s}
|
||
.addbtn:hover{filter:brightness(1.08)}
|
||
.wl-err{font-size:12px;color:#f6768a;width:100%}
|
||
|
||
/* faucets */
|
||
.faucet-item{border:1px solid var(--line);border-radius:11px;margin-bottom:10px;overflow:hidden;background:rgba(20,26,36,.5)}
|
||
.faucet-item:last-child{margin-bottom:0}
|
||
.faucet-bar{display:flex;align-items:center;gap:10px;padding:10px 12px}
|
||
.faucet-bar .fname{font-weight:600;font-size:14px}
|
||
.faucet-bar .tag{font-size:11px;color:var(--accent2);font-weight:600}
|
||
.faucet-bar .spacer{flex:1}
|
||
.fbtn-embed,.fbtn-open{display:inline-flex;align-items:center;gap:6px;text-decoration:none;cursor:pointer;
|
||
border-radius:8px;padding:7px 12px;font-size:12.5px;font-weight:600;border:1px solid var(--line);background:var(--panel2);color:var(--txt);transition:.15s}
|
||
.fbtn-embed:hover,.fbtn-open:hover{border-color:var(--accent);background:#1f2a38}
|
||
.fbtn-open::after{content:"↗";color:var(--dim);font-size:12px}
|
||
.fbtn-embed.active{background:var(--accent);color:#06231f;border-color:var(--accent)}
|
||
.fbtn-reload{display:none;align-items:center;justify-content:center;cursor:pointer;border-radius:8px;padding:7px 11px;font-size:13px;font-weight:600;border:1px solid var(--line);background:var(--panel2);color:var(--txt);transition:.15s}
|
||
.fbtn-reload:hover{border-color:var(--accent);background:#1f2a38;color:var(--accent)}
|
||
.fbtn-reload.show{display:inline-flex}
|
||
.fbtn-reload.spin1{animation:spin .5s ease}
|
||
.blocked{opacity:.65;cursor:not-allowed;border-style:dashed}
|
||
.frame-wrap{height:0;overflow:hidden;transition:height .28s ease;background:#fff}
|
||
.frame-wrap.open{height:620px;border-top:1px solid var(--line)}
|
||
.frame-wrap iframe{width:100%;height:620px;border:0;display:block;background:#fff}
|
||
.frame-hint{padding:8px 12px;font-size:11.5px;color:var(--dim);background:#0a0d13;border-top:1px solid var(--line);display:flex;gap:6px;align-items:center;justify-content:space-between}
|
||
.frame-hint a{color:var(--accent);text-decoration:none}
|
||
|
||
.funds{font-size:13px;color:var(--dim)}
|
||
.funds b{color:var(--txt);font-weight:600}
|
||
.explorer{font-size:12px;color:var(--dim);margin-top:10px}
|
||
.explorer a{color:var(--accent);text-decoration:none}
|
||
.explorer a:hover{text-decoration:underline}
|
||
footer{margin-top:34px;color:var(--dim);font-size:12px;text-align:center;line-height:1.7}
|
||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(20px);background:var(--ok);color:#04220f;font-weight:600;font-size:13px;padding:10px 18px;border-radius:10px;opacity:0;pointer-events:none;transition:.25s;z-index:50}
|
||
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
||
</style>
|
||
</head>
|
||
<body data-view="tabs">
|
||
<div class="wrap">
|
||
<header>
|
||
<div class="account" id="account">
|
||
<span class="acct-ic">👤</span>
|
||
<select id="profile-sel" title="Switch profile"></select>
|
||
<button id="profile-new" class="acct-btn" title="New profile / username">+</button>
|
||
<button id="profile-rename" class="acct-btn" title="Rename this profile">✎</button>
|
||
<button id="profile-del" class="acct-btn del" title="Delete this profile">🗑</button>
|
||
<button id="acct-lock" class="acct-btn" hidden>🔑 Sign in</button>
|
||
<div class="acct-panel" id="acct-panel" hidden>
|
||
<div class="acct-panel-title">Owner sign-in</div>
|
||
<textarea id="acct-phrase" placeholder="Seed phrase (12–24 words) — same one you use on CoinSpectrum" rows="3" autocomplete="off" spellcheck="false" hidden></textarea>
|
||
<input type="password" id="acct-wif" placeholder="WIF private key (account holder)" autocomplete="off" spellcheck="false" hidden />
|
||
<input type="password" id="acct-masterpw" placeholder="Master password (Deviant keystore)" autocomplete="off" spellcheck="false" hidden />
|
||
<label class="acct-remember"><input type="checkbox" id="acct-remember" checked />Stay signed in on this device</label>
|
||
<div class="acct-row"><button class="addbtn" id="acct-unlock">Unlock</button><span class="wl-err" id="acct-err"></span></div>
|
||
<div class="acct-hint" id="acct-hint">The seed phrase never leaves this page. It derives a per-purpose key (BIP-32 path <span style="font-family:var(--mono);color:#bfeae4">m/44'/145'/1417'/2/0</span>) that decrypts the owner list. Only that derived key is remembered on this device if you tick the box.</div>
|
||
</div>
|
||
</div>
|
||
<h1>⛲ Testnet Faucet Hub</h1>
|
||
<div class="sub">Track your testnet addresses, watch balances live, and claim from each chain's faucet — all on one page. Bitcoin Cash · Bitcoin signet · Tron · Ethereum · BNB Smart Chain.</div>
|
||
<div class="warn">⚠ Testnet only. Add <b>public receive addresses</b> — never a private key, seed phrase, or WIF.</div>
|
||
<div class="note">🔒 Your addresses are saved only in <b>this browser</b> (localStorage) — nothing is uploaded. Balances are read from public testnet APIs; faucets are captcha-gated, so you still solve the captcha (inline where the faucet allows embedding, otherwise in a new tab).</div>
|
||
<div class="controls">
|
||
<div class="seg" id="view-seg">
|
||
<button data-view="tabs" class="active">▤ Tabs</button>
|
||
<button data-view="cards">▦ Cards</button>
|
||
</div>
|
||
<div class="seg" id="panel-seg">
|
||
<button data-mode="multi" class="active">▤ Multiple open</button>
|
||
<button data-mode="accordion">▬ One open</button>
|
||
</div>
|
||
<div class="chains-wrap" id="chains-wrap">
|
||
<button class="refresh" id="chains-btn" title="Show / hide chains">⛓ Chains ▾</button>
|
||
<div class="chains-panel" id="chains-panel" hidden></div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<nav id="tabbar"></nav>
|
||
<main id="panels"></main>
|
||
|
||
<footer>
|
||
Testnet Faucet Hub · static, client-side only · addresses live in your browser's local storage<br>
|
||
BCH · BTC · TRX/USDT · ETH/USDC · BNB — testnet assets, no real value.
|
||
</footer>
|
||
</div>
|
||
|
||
<div class="toast" id="toast">Copied</div>
|
||
|
||
<!-- seed loading:
|
||
· local dev — plaintext faucet-seed.js (gitignored) pre-fills the watchlist.
|
||
· public deploy — ship faucet-seed.enc.json instead (built by build-seed-enc.mjs);
|
||
the owner unlocks it in-browser with the account-holder WIF. Never deploy faucet-seed.js. -->
|
||
<script>
|
||
(function(){
|
||
var s=document.createElement('script');
|
||
s.src='faucet-seed.js';
|
||
s.onload=s.onerror=function(){
|
||
if(window.FAUCET_SEED){ window.startFaucetApp(); return; }
|
||
fetch('faucet-seed.enc.json')
|
||
.then(function(r){ return r.ok ? r.json() : null; })
|
||
.then(function(j){ window.FAUCET_SEED_ENC = j || null; })
|
||
.catch(function(){})
|
||
.then(function(){ window.startFaucetApp(); });
|
||
};
|
||
document.head.appendChild(s);
|
||
})();
|
||
</script>
|
||
|
||
<script>
|
||
window.startFaucetApp = (function(){
|
||
let started=false;
|
||
return function(){ if(started) return; started=true; boot(); };
|
||
|
||
function boot(){
|
||
|
||
/* ===================== config ===================== */
|
||
const SEPOLIA_RPC = 'https://ethereum-sepolia-rpc.publicnode.com';
|
||
const BSC_RPC = 'https://bsc-testnet-rpc.publicnode.com';
|
||
const BASE_RPC = 'https://base-sepolia-rpc.publicnode.com';
|
||
const ARB_RPC = 'https://arbitrum-sepolia-rpc.publicnode.com';
|
||
const OP_RPC = 'https://optimism-sepolia-rpc.publicnode.com';
|
||
const POLY_RPC = 'https://polygon-amoy-bor-rpc.publicnode.com';
|
||
const AVAX_RPC = 'https://avalanche-fuji-c-chain-rpc.publicnode.com';
|
||
const SOLANA_RPC = 'https://api.devnet.solana.com';
|
||
// multiple servers, first-reachable wins (sticky): public testnet endpoints go down
|
||
const XRPL_WS_URLS = ['wss://s.altnet.rippletest.net:51233', 'wss://testnet.xrpl-labs.com'];
|
||
const STELLAR_HORIZON = 'https://horizon-testnet.stellar.org';
|
||
const PYUSD_MINT = 'CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM'; // PYUSD on Solana devnet (6 dec)
|
||
const SOL_USDC_MINT = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; // Circle USDC on Solana devnet (6 dec)
|
||
const SOL_EURC_MINT = 'HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr'; // Circle EURC on Solana devnet (6 dec)
|
||
const USDC_CONTRACT = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238';
|
||
const EURC_CONTRACT = '0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4'; // Circle EURC on Sepolia (6 dec)
|
||
const PYUSD_CONTRACT = '0xCaC524BcA292aaade2DF8A05cC58F0a65B1B3bB9'; // PayPal PYUSD on Sepolia (6 dec)
|
||
const CIRBTC_CONTRACT = '0x3a3fe695F684Bf9b9e43CF43C2b895Ea5e392bB3'; // Circle Wrapped Bitcoin on Sepolia (8 dec)
|
||
const USDT_NILE_ID = 'TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf';
|
||
const ELECTRUM_WS_URLS = ['wss://chipnet.imaginary.cash:50004', 'wss://cbch.loping.net:62104'];
|
||
|
||
const isEvm = a => /^0x[0-9a-fA-F]{40}$/.test(a.trim());
|
||
const isTron = a => /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(a.trim());
|
||
const isSignet = a => /^tb1[0-9ac-hj-np-z]{20,90}$/i.test(a.trim());
|
||
const isBch = a => { try { return cashaddrHash160(a.trim()).length === 20; } catch { return false; } };
|
||
const isSol = a => /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(a.trim());
|
||
const isXrp = a => /^r[1-9A-HJ-NP-Za-km-z]{24,34}$/.test(a.trim());
|
||
const isXlm = a => /^G[A-Z2-7]{55}$/.test(a.trim());
|
||
|
||
// order: embeddable faucets first; ETH + BSC (can't embed) kept last.
|
||
const CHAINS = [
|
||
{ id:'bch', name:'Bitcoin Cash · chipnet', color:'var(--bch)', badge:'BCH', short:'BCH',
|
||
placeholder:'bchtest:q… address', validate:isBch, addr:'../Explorer/index.html?addr={a}',
|
||
note:'Native BCH testnet (chipnet). BCH is gas + value. Click an address to see its BCH + CashToken breakdown in the explorer.',
|
||
assets:[{sym:'BCH', kind:'electrum'}],
|
||
// embeddable, with a caveat: the captcha is bound to a session cookie (/captcha 500s
|
||
// without it). Browsers that block third-party cookies (stock Chrome) show a broken
|
||
// captcha image — the frame hint routes those users to New tab. Browsers that deliver
|
||
// the cookie (Firefox partitioning, custom/configured engines) get the full inline flow.
|
||
// chipnet.bch.ninja declares frame-ancestors 'none' so the inline embed is impossible —
|
||
// but it's the superior faucet (sats + CashToken FT/NFT genesis in one flow, no captcha).
|
||
// googol.cash kept as the sats-only fallback.
|
||
faucets:[{name:'chipnet.bch.ninja', url:'https://chipnet.bch.ninja/', embeddable:false, tag:'sats + CashToken FT/NFT genesis · up to 0.1% of balance'},
|
||
{name:'tbch.googol.cash', url:'https://tbch.googol.cash/', embeddable:true, tag:'set network: chipnet · captcha may need own tab'}],
|
||
explorer:[{name:'chipnet.chaingraph.cash', url:'https://chipnet.chaingraph.cash/'},{name:'cbch.loping.net', url:'https://cbch.loping.net/'}] },
|
||
{ id:'btc', name:'Bitcoin · signet testnet', color:'var(--btc)', badge:'BTC', short:'BTC',
|
||
placeholder:'tb1q… address', validate:isSignet, addr:'https://mempool.space/signet/address/{a}',
|
||
note:'Signet — the test network for Bitcoin.',
|
||
assets:[{sym:'BTC', kind:'signet'}],
|
||
faucets:[{name:'signetfaucet.com', url:'https://signetfaucet.com/', embeddable:true},
|
||
{name:'bitcoinsignetfaucet.com', url:'https://bitcoinsignetfaucet.com/', embeddable:true},
|
||
{name:'signet.dcorral.com', url:'https://signet.dcorral.com/', embeddable:true}],
|
||
explorer:[{name:'mempool.space/signet', url:'https://mempool.space/signet/'}] },
|
||
{ id:'tron', name:'Tron · Nile testnet', color:'var(--tron)', badge:'TRX · USDT', short:'Tron',
|
||
placeholder:'T… address', validate:isTron, addr:'https://nile.tronscan.org/#/address/{a}',
|
||
note:'TRX = energy/gas · test USDT (TRC-20). Use “+ Assets” to show more tokens the address holds.',
|
||
assets:[{sym:'TRX', kind:'tron-native'},{sym:'USDT', kind:'trc20', id:USDT_NILE_ID}],
|
||
faucets:[{name:'nileex', url:'https://nileex.io/join/getJoinPage', embeddable:true, tag:'2,000 TRX + 50,000 USDT / 24h'}],
|
||
explorer:[{name:'nile.tronscan.org', url:'https://nile.tronscan.org/'}] },
|
||
{ id:'sol', name:'Solana · devnet', color:'var(--sol)', badge:'SOL · PYUSD · USDC · EURC', short:'Solana',
|
||
placeholder:'Solana address (base58)', validate:isSol, addr:'https://explorer.solana.com/address/{a}?cluster=devnet',
|
||
note:'Solana devnet. SOL = gas/rent · PYUSD (PayPal) · USDC + EURC (Circle). Use “+ Assets” to show tokens.',
|
||
assets:[{sym:'SOL', kind:'sol-native'},{sym:'PYUSD', kind:'spl', mint:PYUSD_MINT, dec:6},{sym:'USDC', kind:'spl', mint:SOL_USDC_MINT, dec:6},{sym:'EURC', kind:'spl', mint:SOL_EURC_MINT, dec:6}],
|
||
faucets:[{name:'Google Web3 (PYUSD)', url:'https://cloud.google.com/application/web3/faucet/solana/devnet/pyusd', embeddable:false, tag:'devnet PYUSD'},
|
||
{name:'Circle (USDC)', url:'https://faucet.circle.com/', embeddable:false, tag:'USDC — pick Solana'},
|
||
{name:'Solana faucet (SOL)', url:'https://faucet.solana.com/', embeddable:false, tag:'devnet SOL'}],
|
||
explorer:[{name:'explorer.solana.com', url:'https://explorer.solana.com/?cluster=devnet'}] },
|
||
{ id:'xrp', name:'XRP Ledger · testnet', color:'var(--xrp)', badge:'XRP · USDC', short:'XRP',
|
||
placeholder:'r… address', validate:isXrp, addr:'https://testnet.xrpl.org/accounts/{a}',
|
||
note:'XRP Ledger testnet. XRP = native · USDC (Circle — needs a trust line to the issuer). Use “+ Assets” to show USDC.',
|
||
assets:[{sym:'XRP', kind:'xrp-native'},{sym:'USDC', kind:'xrpl-token', currency:'USDC'}],
|
||
faucets:[{name:'XRPL testnet faucet', url:'https://xrpl.org/xrp-testnet-faucet.html', embeddable:false, tag:'XRP'},
|
||
{name:'Circle (USDC)', url:'https://faucet.circle.com/', embeddable:false, tag:'USDC — pick XRPL'}],
|
||
explorer:[{name:'testnet.xrpl.org', url:'https://testnet.xrpl.org/'}] },
|
||
{ id:'xlm', name:'Stellar · testnet', color:'var(--xlm)', badge:'XLM · USDC', short:'Stellar',
|
||
placeholder:'G… address', validate:isXlm, addr:'https://stellar.expert/explorer/testnet/account/{a}',
|
||
note:'Stellar testnet. XLM = native · USDC (Circle). Use “+ Assets” to show USDC.',
|
||
assets:[{sym:'XLM', kind:'xlm-native'},{sym:'USDC', kind:'stellar-asset', code:'USDC'}],
|
||
faucets:[{name:'Stellar friendbot (XLM)', url:'https://laboratory.stellar.org/#account-creator?network=test', embeddable:false, tag:'XLM'},
|
||
{name:'Circle (USDC)', url:'https://faucet.circle.com/', embeddable:false, tag:'USDC — pick Stellar'}],
|
||
explorer:[{name:'stellar.expert', url:'https://stellar.expert/explorer/testnet'}] },
|
||
{ id:'eth', name:'Ethereum · Sepolia testnet', color:'var(--eth)', badge:'ETH · USDC · EURC · PYUSD · cirBTC', short:'ETH',
|
||
placeholder:'0x… address', validate:isEvm, addr:'https://sepolia.etherscan.io/address/{a}',
|
||
note:'ETH = gas · test USDC + EURC + PYUSD (PayPal) + cirBTC (Circle wrapped BTC). Use “+ Assets” to show tokens. Faucets require login and block embedding — they open in a new tab.',
|
||
blockscout:'https://eth-sepolia.blockscout.com',
|
||
assets:[{sym:'ETH', kind:'evm-native', rpc:SEPOLIA_RPC},
|
||
{sym:'USDC', kind:'erc20', contract:USDC_CONTRACT, dec:6, rpc:SEPOLIA_RPC},
|
||
{sym:'EURC', kind:'erc20', contract:EURC_CONTRACT, dec:6, rpc:SEPOLIA_RPC},
|
||
{sym:'PYUSD', kind:'erc20', contract:PYUSD_CONTRACT, dec:6, rpc:SEPOLIA_RPC},
|
||
{sym:'cirBTC', kind:'erc20', contract:CIRBTC_CONTRACT, dec:8, dp:5, rpc:SEPOLIA_RPC}],
|
||
faucets:[{name:'Google Web3', url:'https://cloud.google.com/application/web3/faucet/ethereum/sepolia', embeddable:false, tag:'~0.05 ETH / day'},
|
||
{name:'Circle (USDC / EURC)', url:'https://faucet.circle.com/', embeddable:false, tag:'test USDC + EURC'}],
|
||
explorer:[{name:'sepolia.etherscan.io', url:'https://sepolia.etherscan.io/'}] },
|
||
{ id:'bsc', name:'BNB Smart Chain · testnet', color:'var(--bsc)', badge:'BNB', short:'BSC',
|
||
placeholder:'0x… address', validate:isEvm, addr:'https://testnet.bscscan.com/address/{a}',
|
||
note:'EVM chain (Chapel, ID 97). One 0x address works on every EVM chain. Faucet blocks embedding — opens in a new tab.',
|
||
assets:[{sym:'BNB', kind:'evm-native', rpc:BSC_RPC}],
|
||
faucets:[{name:'BNB Chain faucet', url:'https://www.bnbchain.org/en/testnet-faucet', embeddable:false, tag:'0.3 tBNB / 24h'}],
|
||
explorer:[{name:'testnet.bscscan.com', url:'https://testnet.bscscan.com/'}] },
|
||
{ id:'base', name:'Base · Sepolia testnet', color:'var(--base)', badge:'ETH · USDC', short:'Base',
|
||
placeholder:'0x… address', validate:isEvm, addr:'https://sepolia.basescan.org/address/{a}',
|
||
note:'Coinbase L2 (OP Stack, chain 84532). ETH = gas · test USDC. Same 0x address as every EVM chain.',
|
||
blockscout:'https://base-sepolia.blockscout.com',
|
||
assets:[{sym:'ETH', kind:'evm-native', rpc:BASE_RPC},
|
||
{sym:'USDC', kind:'erc20', contract:'0x036CbD53842c5426634e7929541eC2318f3dCF7e', dec:6, rpc:BASE_RPC}],
|
||
faucets:[{name:'Chainlink faucet', url:'https://faucets.chain.link/base-sepolia', embeddable:false, tag:'gas — pick network'},
|
||
{name:'Circle (USDC)', url:'https://faucet.circle.com/', embeddable:false, tag:'test USDC — pick network'}],
|
||
explorer:[{name:'sepolia.basescan.org', url:'https://sepolia.basescan.org/'}] },
|
||
{ id:'arbitrum', name:'Arbitrum · Sepolia testnet', color:'var(--arb)', badge:'ETH · USDC', short:'Arbitrum',
|
||
placeholder:'0x… address', validate:isEvm, addr:'https://sepolia.arbiscan.io/address/{a}',
|
||
note:'Ethereum L2 rollup (chain 421614). ETH = gas · test USDC.',
|
||
blockscout:'https://arbitrum-sepolia.blockscout.com',
|
||
assets:[{sym:'ETH', kind:'evm-native', rpc:ARB_RPC},
|
||
{sym:'USDC', kind:'erc20', contract:'0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', dec:6, rpc:ARB_RPC}],
|
||
faucets:[{name:'Chainlink faucet', url:'https://faucets.chain.link/arbitrum-sepolia', embeddable:false, tag:'gas — pick network'},
|
||
{name:'Circle (USDC)', url:'https://faucet.circle.com/', embeddable:false, tag:'test USDC — pick network'}],
|
||
explorer:[{name:'sepolia.arbiscan.io', url:'https://sepolia.arbiscan.io/'}] },
|
||
{ id:'optimism', name:'Optimism · Sepolia testnet', color:'var(--op)', badge:'ETH · USDC', short:'OP',
|
||
placeholder:'0x… address', validate:isEvm, addr:'https://sepolia-optimism.etherscan.io/address/{a}',
|
||
note:'Ethereum L2 (OP Stack, chain 11155420). ETH = gas · test USDC.',
|
||
blockscout:'https://optimism-sepolia.blockscout.com',
|
||
assets:[{sym:'ETH', kind:'evm-native', rpc:OP_RPC},
|
||
{sym:'USDC', kind:'erc20', contract:'0x5fd84259d66Cd46123540766Be93DFE6D43130D7', dec:6, rpc:OP_RPC}],
|
||
faucets:[{name:'Chainlink faucet', url:'https://faucets.chain.link/optimism-sepolia', embeddable:false, tag:'gas — pick network'},
|
||
{name:'Circle (USDC)', url:'https://faucet.circle.com/', embeddable:false, tag:'test USDC — pick network'}],
|
||
explorer:[{name:'sepolia-optimism.etherscan.io', url:'https://sepolia-optimism.etherscan.io/'}] },
|
||
{ id:'polygon', name:'Polygon · Amoy testnet', color:'var(--poly)', badge:'POL · USDC', short:'Polygon',
|
||
placeholder:'0x… address', validate:isEvm, addr:'https://amoy.polygonscan.com/address/{a}',
|
||
note:'Polygon PoS testnet (Amoy, chain 80002). POL = gas · test USDC.',
|
||
assets:[{sym:'POL', kind:'evm-native', rpc:POLY_RPC},
|
||
{sym:'USDC', kind:'erc20', contract:'0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582', dec:6, rpc:POLY_RPC}],
|
||
faucets:[{name:'Polygon faucet', url:'https://faucet.polygon.technology/', embeddable:false, tag:'POL — pick Amoy'},
|
||
{name:'Chainlink faucet', url:'https://faucets.chain.link/polygon-amoy', embeddable:false, tag:'gas — pick network'},
|
||
{name:'Circle (USDC)', url:'https://faucet.circle.com/', embeddable:false, tag:'test USDC — pick network'}],
|
||
explorer:[{name:'amoy.polygonscan.com', url:'https://amoy.polygonscan.com/'}] },
|
||
{ id:'avax', name:'Avalanche · Fuji testnet', color:'var(--avax)', badge:'AVAX · USDC', short:'Avalanche',
|
||
placeholder:'0x… address', validate:isEvm, addr:'https://testnet.snowtrace.io/address/{a}',
|
||
note:'Avalanche C-Chain testnet (Fuji, chain 43113). AVAX = gas · test USDC.',
|
||
assets:[{sym:'AVAX', kind:'evm-native', rpc:AVAX_RPC},
|
||
{sym:'USDC', kind:'erc20', contract:'0x5425890298aed601595a70AB815c96711a31Bc65', dec:6, rpc:AVAX_RPC}],
|
||
faucets:[{name:'Avalanche faucet', url:'https://faucet.avax.network/', embeddable:false, tag:'AVAX — pick Fuji'},
|
||
{name:'Chainlink faucet', url:'https://faucets.chain.link/fuji', embeddable:false, tag:'gas — pick network'},
|
||
{name:'Circle (USDC)', url:'https://faucet.circle.com/', embeddable:false, tag:'test USDC — pick network'}],
|
||
explorer:[{name:'testnet.snowtrace.io', url:'https://testnet.snowtrace.io/'}] },
|
||
];
|
||
const CHAIN_BY_ID = Object.fromEntries(CHAINS.map(c => [c.id, c]));
|
||
|
||
/* asset registry: each chain has a keyed set of assets (native + tokens, incl.
|
||
runtime-discovered ones). key = token contract/id for tokens, else the symbol. */
|
||
const assetKey = a => a.kind === 'trc20' ? a.id : a.kind === 'erc20' ? a.contract
|
||
: a.kind === 'cashtoken' ? a.category : a.kind === 'spl' ? a.mint : a.sym;
|
||
const chainAssets = {}, assetOrder = {};
|
||
CHAINS.forEach(c => {
|
||
chainAssets[c.id] = new Map(); assetOrder[c.id] = [];
|
||
c.assets.forEach(a => { const k = assetKey(a); chainAssets[c.id].set(k, {...a, key:k}); assetOrder[c.id].push(k); });
|
||
});
|
||
const nativeKey = chainId => assetOrder[chainId][0];
|
||
function registerAsset(chainId, a){
|
||
const k = assetKey(a);
|
||
if(!chainAssets[chainId].has(k)){ chainAssets[chainId].set(k, {...a, key:k}); assetOrder[chainId].push(k); }
|
||
return k;
|
||
}
|
||
|
||
// EVM chains share ONE watchlist "domain": the same 0x address is tracked once and
|
||
// shown on whichever EVM network is selected. Non-EVM chains are their own domain.
|
||
['eth','bsc','base','arbitrum','optimism','polygon','avax'].forEach(id => { if(CHAIN_BY_ID[id]) CHAIN_BY_ID[id].group = 'evm'; });
|
||
const wlDomain = chainId => (CHAIN_BY_ID[chainId] && CHAIN_BY_ID[chainId].group) || chainId;
|
||
// Render units: one card per standalone chain + one card per group.
|
||
const RENDER_UNITS = [];
|
||
const _seenGroup = new Set();
|
||
for(const c of CHAINS){
|
||
if(c.group){ if(!_seenGroup.has(c.group)){ _seenGroup.add(c.group); RENDER_UNITS.push({ unit:c.group, group:true, chains: CHAINS.filter(x => x.group === c.group) }); } }
|
||
else RENDER_UNITS.push({ unit:c.id, group:false, chain:c });
|
||
}
|
||
|
||
// Inline chain symbols (self-contained SVG; colored via currentColor = chain color).
|
||
const BTC_MARK = `<svg viewBox="0 0 32 32" aria-hidden="true">`
|
||
+ `<rect x="12.9" y="4.3" width="1.9" height="5" rx=".9"/><rect x="16.3" y="4.3" width="1.9" height="5" rx=".9"/>`
|
||
+ `<rect x="12.9" y="22.7" width="1.9" height="5" rx=".9"/><rect x="16.3" y="22.7" width="1.9" height="5" rx=".9"/>`
|
||
+ `<text x="15.8" y="23.4" text-anchor="middle" font-family="system-ui,'Segoe UI',Arial" font-size="21" font-weight="800">B</text></svg>`;
|
||
const ICONS = {
|
||
bch: BTC_MARK, // Bitcoin Cash — ₿ mark, distinguished by the green chain color
|
||
btc: BTC_MARK, // Bitcoin — ₿ mark, orange
|
||
tron: `<svg viewBox="0 0 32 32" aria-hidden="true">`
|
||
+ `<path d="M5 7 L27 11.5 L15.5 27 Z" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linejoin="round"/>`
|
||
+ `<path d="M5 7 L16.2 15 L27 11.5" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linejoin="round"/>`
|
||
+ `<path d="M16.2 15 L15.5 27" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linejoin="round"/></svg>`,
|
||
eth: `<svg viewBox="0 0 32 32" aria-hidden="true">`
|
||
+ `<path d="M16 3 L8 16 L16 20.8 Z" opacity=".9"/><path d="M16 3 L24 16 L16 20.8 Z" opacity=".55"/>`
|
||
+ `<path d="M16 22.4 L8 17.6 L16 29 Z" opacity=".9"/><path d="M16 22.4 L24 17.6 L16 29 Z" opacity=".55"/></svg>`,
|
||
bsc: `<svg viewBox="0 0 32 32" aria-hidden="true">`
|
||
+ `<path d="M16 5 l3.6 3.6 -3.6 3.6 -3.6 -3.6 z"/><path d="M8.4 12.4 l3.6 3.6 -3.6 3.6 -3.6 -3.6 z"/>`
|
||
+ `<path d="M23.6 12.4 l3.6 3.6 -3.6 3.6 -3.6 -3.6 z"/><path d="M16 19.8 l3.6 3.6 -3.6 3.6 -3.6 -3.6 z"/>`
|
||
+ `<path d="M16 12.4 l3.6 3.6 -3.6 3.6 -3.6 -3.6 z"/></svg>`,
|
||
base: `<svg viewBox="0 0 32 32" aria-hidden="true"><circle cx="16" cy="16" r="12" fill="currentColor"/></svg>`,
|
||
arbitrum: `<svg viewBox="0 0 32 32" aria-hidden="true"><path d="M16 4 L27 10 L27 22 L16 28 L5 22 L5 10 Z" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round"/></svg>`,
|
||
optimism: `<svg viewBox="0 0 32 32" aria-hidden="true"><circle cx="16" cy="16" r="9.5" fill="none" stroke="currentColor" stroke-width="5"/></svg>`,
|
||
polygon: `<svg viewBox="0 0 32 32" aria-hidden="true"><path d="M16 4 L27 10 L27 22 L16 28 L5 22 L5 10 Z" fill="currentColor"/></svg>`,
|
||
sol: `<svg viewBox="0 0 32 32" aria-hidden="true">`
|
||
+ `<path d="M8 7 L28 7 L24 11 L4 11 Z"/><path d="M4 14 L24 14 L28 18 L8 18 Z"/><path d="M8 21 L28 21 L24 25 L4 25 Z"/></svg>`,
|
||
avax: `<svg viewBox="0 0 32 32" aria-hidden="true"><path d="M16 5 L28 26 L20.5 26 L18 21.5 L11 21.5 L16 12.5 L19.4 18.6 L21 18.6 Z M13.5 21.5 L4 26 L11.5 26 Z" fill="currentColor"/></svg>`,
|
||
xrp: `<svg viewBox="0 0 32 32" aria-hidden="true"><path d="M8 7 L16 15 L24 7 M8 25 L16 17 L24 25" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
|
||
xlm: `<svg viewBox="0 0 32 32" aria-hidden="true"><path d="M16 3 L18.6 13.4 L29 16 L18.6 18.6 L16 29 L13.4 18.6 L3 16 L13.4 13.4 Z" fill="currentColor"/></svg>`,
|
||
};
|
||
|
||
/* ===================== storage ===================== */
|
||
const WKEY='faucet.watchlist.v1', LKEY='faucet.labels.v1', HKEY='faucet.hidden.v1', VKEY='faucet.view',
|
||
TKEY='faucet.tab', AKEY='faucet.auto', PKEY='faucet.panelmode', CKEY='faucet.collapsed';
|
||
const ls = { get:k=>{try{return localStorage.getItem(k);}catch{return null;}}, set:(k,v)=>{try{localStorage.setItem(k,v);}catch{}} };
|
||
const seed = (window.FAUCET_SEED && typeof window.FAUCET_SEED === 'object') ? window.FAUCET_SEED : {};
|
||
const parseObj = k => { try { return JSON.parse(ls.get(k)) || {}; } catch { return {}; } };
|
||
|
||
// active EVM network shown by the consolidated EVM card
|
||
const EVMKEY = 'faucet.evmnet';
|
||
const EVM_CHAINS = CHAINS.filter(c => c.group === 'evm').map(c => c.id);
|
||
let evmActive = ls.get(EVMKEY);
|
||
if(!EVM_CHAINS.includes(evmActive)) evmActive = EVM_CHAINS[0];
|
||
|
||
// EVM-dropdown ordering: mcap (native-token market cap, default) · tvl · alpha · activity
|
||
const ORDERKEY = 'faucet.order';
|
||
const ORDER_MODES = ['mcap','tvl','alpha','activity'];
|
||
let orderMode = ls.get(ORDERKEY);
|
||
if(!ORDER_MODES.includes(orderMode)) orderMode = 'mcap'; // migrates old 'default'
|
||
// static rank tables — lower = first; chains without a rank sort last (99).
|
||
// mcap: Base has no native token → last. tvl: DeFi TVL approx, 2026.
|
||
const MCAP_RANK = { eth:1, bsc:2, avax:3, polygon:4, arbitrum:5, optimism:6 };
|
||
const TVL_RANK = { eth:1, bsc:2, base:3, arbitrum:4, avax:5, polygon:6, optimism:7 };
|
||
// higher activity = lower number. Solana/Tron/BNB lead by tx + daily-active addresses;
|
||
// Base fast-growing L2; ETH by monthly-active/value; BTC/BCH lower tx activity.
|
||
const ACTIVITY_RANK = { sol:1, tron:2, bsc:3, base:4, eth:5, polygon:6, arbitrum:7, avax:8, optimism:9, btc:10, bch:11 };
|
||
const rankOf = (table, id) => table[id] ?? 99;
|
||
|
||
// discovered tokens persist globally (contract defs are chain-wide, not per-profile)
|
||
const TOKENS_KEY = 'faucet.tokens.v1';
|
||
const discoveredTokens = parseObj(TOKENS_KEY);
|
||
Object.entries(discoveredTokens).forEach(([cid, arr]) => { if(chainAssets[cid]) arr.forEach(a => registerAsset(cid, a)); });
|
||
function persistToken(chainId, a){
|
||
const arr = discoveredTokens[chainId] = discoveredTokens[chainId] || [];
|
||
if(!arr.some(x => (x.id||x.contract) === (a.id||a.contract))){ arr.push(a); ls.set(TOKENS_KEY, JSON.stringify(discoveredTokens)); }
|
||
}
|
||
|
||
/* profiles = local "accounts": each has its own watchlist / labels / hidden set */
|
||
const PROF_KEY = 'faucet.profiles.v1';
|
||
function loadProfiles(){
|
||
let s; try { s = JSON.parse(ls.get(PROF_KEY)); } catch { s = null; }
|
||
if(!s || !s.profiles || !s.order || !s.order.length){
|
||
// migrate any pre-profiles single-list data into a "default" profile
|
||
s = { active:'default', order:['default'],
|
||
profiles:{ default:{ name:'default', wl:parseObj(WKEY), lb:parseObj(LKEY), hd:parseObj(HKEY) } } };
|
||
}
|
||
return s;
|
||
}
|
||
let PROFILES = loadProfiles();
|
||
const saveProfiles = () => ls.set(PROF_KEY, JSON.stringify(PROFILES));
|
||
let watchlist, labels, hidden, shownAssets;
|
||
function bindProfile(){
|
||
const p = PROFILES.profiles[PROFILES.active];
|
||
p.wl = p.wl || {}; p.lb = p.lb || {}; p.hd = p.hd || {}; p.sa = p.sa || {};
|
||
watchlist = p.wl; labels = p.lb; hidden = p.hd; shownAssets = p.sa;
|
||
}
|
||
bindProfile();
|
||
const saveWL = saveProfiles, saveLabels = saveProfiles, saveHidden = saveProfiles;
|
||
// which asset keys to show for a chain (default: native only), in registry order
|
||
function shownFor(chainId){
|
||
const set = shownAssets[chainId];
|
||
if(!set) return [nativeKey(chainId)];
|
||
return assetOrder[chainId].filter(k => set.includes(k));
|
||
}
|
||
function toggleShown(chainId, key){
|
||
const cur = shownAssets[chainId] ? shownAssets[chainId].slice() : [nativeKey(chainId)];
|
||
const i = cur.indexOf(key);
|
||
if(i >= 0) cur.splice(i,1); else cur.push(key);
|
||
shownAssets[chainId] = cur; saveProfiles();
|
||
}
|
||
// watchlist / labels / hidden / seed are keyed by DOMAIN (wlDomain) so EVM chains share them
|
||
const lkey = (chainId, addr) => wlDomain(chainId) + '|' + addr.toLowerCase();
|
||
const getLabel = (chainId, addr, base) => (labels[lkey(chainId,addr)] ?? base ?? '');
|
||
function setLabel(chainId, addr, label){ const k=lkey(chainId,addr); if(label) labels[k]=label; else delete labels[k]; saveLabels(); }
|
||
const isHidden = (chainId, addr) => (hidden[wlDomain(chainId)]||[]).includes(addr.toLowerCase());
|
||
function hideSeed(chainId, addr){ const d=wlDomain(chainId); (hidden[d]=hidden[d]||[]).push(addr.toLowerCase()); saveHidden(); }
|
||
|
||
function effective(chainId){
|
||
const dom = wlDomain(chainId), out=[], seen=new Set();
|
||
for(const e of (seed[dom]||[])) { const k=e.addr.toLowerCase(); if(!seen.has(k) && !isHidden(chainId,e.addr)){ seen.add(k); out.push({addr:e.addr, label:getLabel(chainId,e.addr,e.label||''), seed:true}); } }
|
||
for(const e of (watchlist[dom]||[])) { const k=e.addr.toLowerCase(); if(!seen.has(k)){ seen.add(k); out.push({addr:e.addr, label:getLabel(chainId,e.addr,e.label||''), seed:false}); } }
|
||
return out;
|
||
}
|
||
function addAddr(chainId, addr, label){
|
||
const dom = wlDomain(chainId); addr = addr.trim(); const k = addr.toLowerCase();
|
||
if(effective(chainId).some(e => e.addr.toLowerCase() === k)) return 'already tracked';
|
||
(watchlist[dom] = watchlist[dom] || []).push({addr, label:(label||'').trim()});
|
||
saveWL(); return null;
|
||
}
|
||
function removeAddr(chainId, addr){
|
||
const dom = wlDomain(chainId), k = addr.toLowerCase();
|
||
watchlist[dom] = (watchlist[dom]||[]).filter(e => e.addr.toLowerCase() !== k);
|
||
saveWL();
|
||
}
|
||
// change an address in place; a seed address is forked into a user entry (seed original hidden)
|
||
function editAddress(chainId, oldAddr, newAddr, seedEntry, curLabel){
|
||
const dom = wlDomain(chainId);
|
||
if(effective(chainId).some(e => e.addr.toLowerCase() === newAddr.toLowerCase())) return 'already tracked';
|
||
if(seedEntry){
|
||
hideSeed(chainId, oldAddr);
|
||
(watchlist[dom] = watchlist[dom] || []).push({addr:newAddr, label:curLabel||''});
|
||
} else {
|
||
const arr = watchlist[dom] || [];
|
||
const idx = arr.findIndex(e => e.addr.toLowerCase() === oldAddr.toLowerCase());
|
||
if(idx >= 0) arr[idx].addr = newAddr;
|
||
}
|
||
const ok = lkey(chainId, oldAddr);
|
||
if(labels[ok]){ labels[lkey(chainId,newAddr)] = labels[ok]; delete labels[ok]; saveLabels(); }
|
||
saveWL(); return null;
|
||
}
|
||
|
||
/* ===================== formatting + EVM rpc ===================== */
|
||
function fmtUnits(v, decimals, dp){
|
||
v = BigInt(v); const neg = v < 0n; if(neg) v = -v;
|
||
const base = 10n ** BigInt(decimals);
|
||
const whole = (v / base).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||
let frac = (v % base).toString().padStart(decimals,'0').slice(0, dp).replace(/0+$/,'');
|
||
return (neg?'-':'') + whole + (frac ? '.'+frac : '');
|
||
}
|
||
async function rpc(method, params, url){
|
||
const r = await fetch(url, {method:'POST', headers:{'content-type':'application/json'},
|
||
body: JSON.stringify({jsonrpc:'2.0', id:1, method, params})});
|
||
const j = await r.json();
|
||
if(j.error) throw new Error(j.error.message || 'rpc error');
|
||
return j.result;
|
||
}
|
||
async function solRpc(method, params){
|
||
const r = await fetch(SOLANA_RPC, {method:'POST', headers:{'content-type':'application/json'},
|
||
body: JSON.stringify({jsonrpc:'2.0', id:1, method, params})});
|
||
const j = await r.json();
|
||
if(j.error) throw new Error(j.error.message || 'solana rpc error');
|
||
return j.result;
|
||
}
|
||
|
||
/* XRP Ledger — WebSocket JSON API (the JSON-RPC endpoint has no CORS) */
|
||
let _xrplWs=null, _xrplP=null, _xrplId=1, _xrplIdx=0; const _xrplPending=new Map();
|
||
function xrplOpen(url){
|
||
return new Promise((resolve, reject) => {
|
||
let ws; try { ws = new WebSocket(url); } catch(e){ return reject(e); }
|
||
const to = setTimeout(() => { try{ws.close();}catch{} reject(new Error('xrpl connect timeout')); }, 8000);
|
||
ws.onopen = () => { clearTimeout(to); _xrplWs=ws; resolve(ws); };
|
||
ws.onerror = () => { clearTimeout(to); reject(new Error('cannot reach ' + url)); };
|
||
ws.onclose = () => { if(_xrplWs===ws){ _xrplWs=null; _xrplP=null; } for(const [,p] of _xrplPending) p.reject(new Error('xrpl closed')); _xrplPending.clear(); };
|
||
ws.onmessage = (ev) => { let m; try { m=JSON.parse(ev.data); } catch { return; }
|
||
const p=_xrplPending.get(m.id); if(p){ _xrplPending.delete(m.id); m.status==='success' ? p.resolve(m.result) : p.reject(new Error(m.error||'xrpl error')); } };
|
||
});
|
||
}
|
||
function xrplConnect(){
|
||
if(_xrplP) return _xrplP;
|
||
_xrplP = (async () => {
|
||
let lastErr;
|
||
for(let i=0;i<XRPL_WS_URLS.length;i++){
|
||
const idx = (_xrplIdx + i) % XRPL_WS_URLS.length;
|
||
try { const ws = await xrplOpen(XRPL_WS_URLS[idx]); _xrplIdx = idx; return ws; }
|
||
catch(e){ lastErr = e; }
|
||
}
|
||
_xrplP = null;
|
||
throw lastErr || new Error('cannot reach XRPL testnet');
|
||
})();
|
||
return _xrplP;
|
||
}
|
||
function xrplCall(command, fields){
|
||
return xrplConnect().then(ws => new Promise((resolve, reject) => {
|
||
const id=_xrplId++; _xrplPending.set(id, {resolve, reject});
|
||
ws.send(JSON.stringify({ id, command, ...fields }));
|
||
setTimeout(() => { if(_xrplPending.has(id)){ _xrplPending.delete(id); reject(new Error('xrpl timeout')); } }, 12000);
|
||
}));
|
||
}
|
||
// XRPL currency: 3-char codes stored literally; longer names (e.g. USDC) as 40-hex
|
||
const xrplCcy = c => { if(!c) return ''; if(c.length===40 && /^[0-9A-Fa-f]+$/.test(c)){ let s=''; for(let i=0;i<40;i+=2){ const b=parseInt(c.substr(i,2),16); if(b) s+=String.fromCharCode(b); } return s; } return c; };
|
||
|
||
/* Stellar — Horizon REST (CORS-friendly); one call serves XLM + all assets */
|
||
const _stellarCache = new Map();
|
||
async function stellarAccount(addr){
|
||
const c=_stellarCache.get(addr); if(c && Date.now()-c.t < 8000) return c.data;
|
||
const r = await fetch(STELLAR_HORIZON + '/accounts/' + addr);
|
||
const data = r.status === 404 ? null : await r.json();
|
||
_stellarCache.set(addr, {t:Date.now(), data});
|
||
return data;
|
||
}
|
||
|
||
/* ===================== electrum (chipnet) over WebSocket ===================== */
|
||
let _ws=null,_connectP=null,_nextId=1,_elIdx=0; const _pending=new Map();
|
||
function electrumOpen(url){
|
||
return new Promise((resolve, reject) => {
|
||
let ws, buf='';
|
||
try { ws = new WebSocket(url); } catch(e){ return reject(e); }
|
||
const to = setTimeout(() => { try{ws.close();}catch{} reject(new Error('electrum connect timeout')); }, 8000);
|
||
const dispatch = (line) => { if(!line.trim()) return; let j; try{ j=JSON.parse(line);}catch{return;}
|
||
const p=_pending.get(j.id); if(p){ _pending.delete(j.id); j.error ? p.reject(new Error(j.error.message||'electrum error')) : p.resolve(j.result); } };
|
||
ws.onopen = () => { clearTimeout(to); _ws=ws; resolve(ws); };
|
||
ws.onerror = () => { clearTimeout(to); reject(new Error('cannot reach ' + url)); };
|
||
ws.onclose = () => { if(_ws===ws){ _ws=null; _connectP=null; } for(const [,p] of _pending) p.reject(new Error('electrum closed')); _pending.clear(); };
|
||
ws.onmessage = (ev) => { buf += typeof ev.data==='string'?ev.data:''; let nl;
|
||
while((nl=buf.indexOf('\n'))>=0){ dispatch(buf.slice(0,nl)); buf=buf.slice(nl+1); }
|
||
if(buf.trim()){ try{ JSON.parse(buf); dispatch(buf); buf=''; }catch{} } };
|
||
});
|
||
}
|
||
function electrumConnect(){
|
||
if(_connectP) return _connectP;
|
||
_connectP = (async () => {
|
||
let lastErr;
|
||
for(let i=0;i<ELECTRUM_WS_URLS.length;i++){
|
||
const idx = (_elIdx + i) % ELECTRUM_WS_URLS.length;
|
||
try { const ws = await electrumOpen(ELECTRUM_WS_URLS[idx]); _elIdx = idx; return ws; }
|
||
catch(e){ lastErr = e; }
|
||
}
|
||
_connectP = null;
|
||
throw lastErr || new Error('cannot reach chipnet electrum');
|
||
})();
|
||
return _connectP;
|
||
}
|
||
function electrumCall(method, params){
|
||
return electrumConnect().then(ws => new Promise((resolve, reject) => {
|
||
const id=_nextId++; _pending.set(id,{resolve,reject});
|
||
ws.send(JSON.stringify({id, method, params}) + '\n');
|
||
setTimeout(() => { if(_pending.has(id)){ _pending.delete(id); reject(new Error('electrum timeout')); } }, 12000);
|
||
}));
|
||
}
|
||
const CASH_B32='qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||
function cashaddrHash160(addr){
|
||
const payload=(addr.includes(':')?addr.split(':')[1]:addr).toLowerCase();
|
||
const words=[...payload].map(c=>{const v=CASH_B32.indexOf(c); if(v<0) throw new Error('bad address'); return v;});
|
||
const data=words.slice(0, words.length-8);
|
||
let acc=0,bits=0; const bytes=[];
|
||
for(const v of data){ acc=(acc<<5)|v; bits+=5; while(bits>=8){ bits-=8; bytes.push((acc>>bits)&0xff); } }
|
||
return new Uint8Array(bytes.slice(1));
|
||
}
|
||
async function scripthashFor(addr){
|
||
const h=cashaddrHash160(addr);
|
||
if(h.length!==20) throw new Error('unsupported address (need P2PKH)');
|
||
const script=new Uint8Array(25); script.set([0x76,0xa9,0x14],0); script.set(h,3); script.set([0x88,0xac],23);
|
||
if(!(window.crypto && crypto.subtle)) throw new Error('crypto.subtle unavailable — use https/localhost or a Chromium/Firefox browser');
|
||
const digest=new Uint8Array(await crypto.subtle.digest('SHA-256', script));
|
||
return [...digest].reverse().map(b=>b.toString(16).padStart(2,'0')).join('');
|
||
}
|
||
|
||
/* tronscan account cache — one call per address serves TRX-holdings + all TRC-20 */
|
||
const _tronCache = new Map();
|
||
async function tronAccount(addr){
|
||
const c = _tronCache.get(addr);
|
||
if(c && (Date.now() - c.t) < 8000) return c.data;
|
||
const r = await fetch('https://nileapi.tronscan.org/api/account?address=' + addr);
|
||
const data = await r.json();
|
||
_tronCache.set(addr, {t:Date.now(), data});
|
||
return data;
|
||
}
|
||
// scan tracked Tron addresses and register any TRC-20 tokens they hold
|
||
async function discoverTronTokens(chainId){
|
||
let added = 0;
|
||
for(const e of effective(chainId)){
|
||
try {
|
||
const j = await tronAccount(e.addr);
|
||
for(const t of (j.trc20token_balances || [])){
|
||
if(t.tokenType === 'trc20' && t.tokenId && Number(t.balance) > 0){
|
||
const before = chainAssets[chainId].size;
|
||
const tok = {sym: t.tokenAbbr || t.tokenName || 'TRC20', kind:'trc20', id: t.tokenId, dec: t.tokenDecimal || 6};
|
||
registerAsset(chainId, tok);
|
||
if(chainAssets[chainId].size > before){ added++; persistToken(chainId, tok); }
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
return added;
|
||
}
|
||
// scan tracked EVM addresses on chains with a Blockscout API and register held ERC-20s.
|
||
// Testnet addresses attract airdrop spam (some hold 700+ tokens), so candidates are
|
||
// ranked by holder count and only the top few new ones are registered per scan.
|
||
// Spam tokens put URLs / claim-bait in the symbol — real tickers are short alphanumerics.
|
||
const EVM_SCAN_CAP = 15;
|
||
const saneSymbol = s => /^[A-Za-z0-9$_-]{1,12}$/.test(s || '');
|
||
async function discoverEvmTokens(chainId){
|
||
const chain = CHAIN_BY_ID[chainId]; if(!chain.blockscout) return 0;
|
||
const nativeRpc = chain.assets[0].rpc;
|
||
const found = new Map(); // contract → candidate (deduped across addresses)
|
||
for(const e of effective(chainId)){
|
||
try {
|
||
const r = await fetch(chain.blockscout + '/api/v2/addresses/' + e.addr + '/token-balances');
|
||
if(!r.ok) continue;
|
||
for(const t of (await r.json() || [])){
|
||
const tok = t.token || {};
|
||
if(tok.type === 'ERC-20' && tok.address_hash && Number(t.value) > 0 && saneSymbol(tok.symbol) && !found.has(tok.address_hash)){
|
||
const dec = +tok.decimals || 18;
|
||
found.set(tok.address_hash, { holders: +tok.holders_count || 0,
|
||
asset: {sym: tok.symbol || 'ERC20', kind:'erc20', contract: tok.address_hash, dec, dp: dec > 6 ? 5 : 2, rpc: nativeRpc} });
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
let added = 0;
|
||
for(const {asset} of [...found.values()].sort((a,b) => b.holders - a.holders)){
|
||
if(added >= EVM_SCAN_CAP) break;
|
||
const before = chainAssets[chainId].size;
|
||
registerAsset(chainId, asset);
|
||
if(chainAssets[chainId].size > before){ added++; persistToken(chainId, asset); }
|
||
}
|
||
return added;
|
||
}
|
||
|
||
/* ===================== balance getters ===================== */
|
||
// returns the numeric balance only — the ticker is shown once via the chip's label
|
||
async function fetchAsset(asset, addr){
|
||
switch(asset.kind){
|
||
case 'evm-native':
|
||
return fmtUnits(BigInt(await rpc('eth_getBalance',[addr,'latest'], asset.rpc)), 18, 5);
|
||
case 'erc20': {
|
||
const data = '0x70a08231' + addr.replace(/^0x/,'').toLowerCase().padStart(64,'0');
|
||
return fmtUnits(BigInt(await rpc('eth_call',[{to:asset.contract, data},'latest'], asset.rpc)), asset.dec, asset.dp ?? 2);
|
||
}
|
||
case 'tron-native': {
|
||
const r = await fetch('https://nile.trongrid.io/wallet/getaccount',{method:'POST',
|
||
headers:{'content-type':'application/json'}, body:JSON.stringify({address:addr, visible:true})});
|
||
const j = await r.json();
|
||
return fmtUnits(BigInt(j.balance || 0), 6, 2);
|
||
}
|
||
case 'trc20': {
|
||
const j = await tronAccount(addr);
|
||
const t = (j.trc20token_balances || []).find(x => x.tokenId === asset.id);
|
||
if(!t) return '0';
|
||
return fmtUnits(BigInt(t.balance), t.tokenDecimal || asset.dec || 6, 2);
|
||
}
|
||
case 'signet': {
|
||
const r = await fetch('https://mempool.space/signet/api/address/' + addr);
|
||
const j = await r.json(); const c=j.chain_stats, m=j.mempool_stats;
|
||
// BTC: keep 0-conf separate — RBF means unconfirmed funds can still be replaced
|
||
const conf = BigInt(c.funded_txo_sum) - BigInt(c.spent_txo_sum);
|
||
const unc = BigInt(m.funded_txo_sum) - BigInt(m.spent_txo_sum);
|
||
let s = fmtUnits(conf, 8, 5);
|
||
if(unc !== 0n) s += ' (' + (unc>0n?'+':'') + fmtUnits(unc, 8, 5) + ' unconf)';
|
||
return s;
|
||
}
|
||
case 'electrum': {
|
||
const sh = await scripthashFor(addr);
|
||
const r = await electrumCall('blockchain.scripthash.get_balance', [sh]);
|
||
// BCH: no RBF + DSproof → 0-conf is final, fold into the total
|
||
const total = BigInt(r.confirmed||0) + BigInt(r.unconfirmed||0);
|
||
return fmtUnits(total, 8, 5);
|
||
}
|
||
case 'sol-native': {
|
||
const r = await solRpc('getBalance', [addr]);
|
||
return fmtUnits(BigInt((r && r.value) || 0), 9, 5);
|
||
}
|
||
case 'spl': {
|
||
const r = await solRpc('getTokenAccountsByOwner', [addr, {mint: asset.mint}, {encoding:'jsonParsed'}]);
|
||
let amt = 0n, dec = asset.dec || 6;
|
||
for(const acc of ((r && r.value) || [])){
|
||
const ta = acc.account?.data?.parsed?.info?.tokenAmount;
|
||
if(ta){ amt += BigInt(ta.amount || 0); dec = ta.decimals ?? dec; }
|
||
}
|
||
return fmtUnits(amt, dec, 2);
|
||
}
|
||
case 'xrp-native': {
|
||
try { const r = await xrplCall('account_info', {account:addr, ledger_index:'validated'});
|
||
return fmtUnits(BigInt(r.account_data.Balance || 0), 6, 5); } // drops → XRP
|
||
catch(e){ if(/actNotFound|not.?found/i.test(String(e.message))) return '0'; throw e; }
|
||
}
|
||
case 'xrpl-token': {
|
||
let r; try { r = await xrplCall('account_lines', {account:addr, ledger_index:'validated'}); }
|
||
catch(e){ if(/actNotFound|not.?found/i.test(String(e.message))) return '0'; throw e; }
|
||
let sum = 0; for(const l of (r.lines || [])){ if(xrplCcy(l.currency) === asset.currency) sum += Number(l.balance) || 0; }
|
||
return sum.toLocaleString('en-US', { maximumFractionDigits: 6 });
|
||
}
|
||
case 'xlm-native': {
|
||
const a = await stellarAccount(addr); if(!a) return '0';
|
||
const n = (a.balances || []).find(b => b.asset_type === 'native');
|
||
return n ? Number(n.balance).toLocaleString('en-US', { maximumFractionDigits: 5 }) : '0';
|
||
}
|
||
case 'stellar-asset': {
|
||
const a = await stellarAccount(addr); if(!a) return '0';
|
||
let sum = 0; for(const b of (a.balances || [])){ if(b.asset_code === asset.code) sum += Number(b.balance) || 0; }
|
||
return sum.toLocaleString('en-US', { maximumFractionDigits: 6 });
|
||
}
|
||
}
|
||
return '—';
|
||
}
|
||
async function loadSpan(span){
|
||
const chainId = span.dataset.chain;
|
||
const asset = chainAssets[chainId] && chainAssets[chainId].get(span.dataset.assetKey);
|
||
const addr = span.dataset.addr;
|
||
if(!asset || !addr) return;
|
||
span.classList.remove('err'); span.classList.add('loading'); span.textContent = '…';
|
||
try {
|
||
const out = await fetchAsset(asset, addr);
|
||
span.classList.remove('loading','err'); span.textContent = out;
|
||
} catch(e){
|
||
span.classList.remove('loading'); span.classList.add('err');
|
||
span.textContent = '⚠ error'; span.title = String(e && e.message || e);
|
||
}
|
||
}
|
||
// reload a set of balance spans, reflecting real loading status on the trigger button
|
||
function reloadSet(spans, btn){
|
||
spans = [...spans];
|
||
if(btn){
|
||
if(btn.classList.contains('iconbtn')){ btn.classList.add('busy','spinning'); }
|
||
else { btn._old = btn.textContent; btn.textContent = '↻ Refreshing…'; btn.disabled = true; }
|
||
}
|
||
return Promise.allSettled(spans.map(loadSpan)).finally(() => {
|
||
if(btn){
|
||
if(btn.classList.contains('iconbtn')){ btn.classList.remove('busy','spinning'); }
|
||
else { btn.textContent = btn._old || '↻ Refresh all'; btn.disabled = false; }
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ===================== rendering ===================== */
|
||
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||
const panels = document.getElementById('panels');
|
||
const tabbar = document.getElementById('tabbar');
|
||
|
||
function mkAddrLinkHTML(chain, addr){
|
||
const url = chain.addr.replace('{a}', encodeURIComponent(addr));
|
||
return `<a class="wl-addr" href="${esc(url)}" target="_blank" rel="noopener" title="Open in block explorer ↗">${esc(addr)}</a>`;
|
||
}
|
||
function watchlistHTML(chain){
|
||
const list = effective(chain.id);
|
||
const keys = shownFor(chain.id);
|
||
let rows = list.map(e => {
|
||
// one grid row per shown asset: [amount] [ticker]
|
||
const cells = keys.map(k => {
|
||
const a = chainAssets[chain.id].get(k); if(!a) return '';
|
||
return `<span class="bal loading" data-chain="${chain.id}" data-asset-key="${esc(k)}" data-addr="${esc(e.addr)}">…</span>`
|
||
+ `<span class="k">${esc(a.sym)}</span>`;
|
||
}).join('');
|
||
const tag = e.seed ? '<span class="wl-tag">dev</span>' : '';
|
||
const labelCls = e.label ? 'wl-label' : 'wl-label muted';
|
||
const labelTxt = e.label ? esc(e.label) : 'label';
|
||
return `<div class="wl-item" data-addr="${esc(e.addr)}" data-seed="${e.seed?1:0}">
|
||
<div class="wl-info">
|
||
<button class="iconbtn edit" title="Edit / delete this address">✎</button>
|
||
${mkAddrLinkHTML(chain, e.addr)}
|
||
<button class="iconbtn copy" title="Copy address" data-copy="${esc(e.addr)}">Copy</button>
|
||
<span class="${labelCls}" title="Click to rename">${labelTxt}</span>${tag}
|
||
</div>
|
||
<div class="bal-grid">${cells}</div>
|
||
</div>`;
|
||
}).join('');
|
||
if(!list.length) rows = `<div class="wl-empty">No addresses yet — add one below to watch its balance.</div>`;
|
||
return `<div class="row wl-region">
|
||
<div class="wl-region-head">
|
||
<div class="label">Tracked addresses — live balance</div>
|
||
<div class="wl-region-btns">
|
||
<button class="iconbtn wl-assets" title="Choose which assets to show">+ Assets</button>
|
||
<button class="iconbtn wl-refresh-all" title="Refresh all addresses on this chain">↻ Refresh balances</button>
|
||
</div>
|
||
</div>
|
||
<div class="assets-panel" hidden></div>
|
||
<div class="wl-list">${rows}</div>
|
||
<div class="wl-add">
|
||
<input class="wl-input" placeholder="${esc(chain.placeholder)}" spellcheck="false" autocomplete="off" />
|
||
<input class="wl-lbl" placeholder="label (optional)" autocomplete="off" />
|
||
<button class="addbtn">Add</button>
|
||
<span class="wl-err"></span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function faucetsHTML(chain){
|
||
const items = chain.faucets.map(f => {
|
||
const tag = f.tag ? `<span class="tag">${esc(f.tag)}</span>` : '';
|
||
if(f.embeddable){
|
||
return `<div class="faucet-item" data-url="${esc(f.url)}">
|
||
<div class="faucet-bar"><span class="fname">${esc(f.name)}</span>${tag}<span class="spacer"></span>
|
||
<button class="fbtn-embed">Open here ▾</button>
|
||
<a class="fbtn-open" href="${esc(f.url)}" target="_blank" rel="noopener">New tab</a>
|
||
</div><div class="frame-wrap"></div></div>`;
|
||
}
|
||
return `<div class="faucet-item blocked-item" data-url="${esc(f.url)}">
|
||
<div class="faucet-bar"><span class="fname">${esc(f.name)}</span>${tag}<span class="spacer"></span>
|
||
<button class="fbtn-embed blocked" disabled>Can't embed</button>
|
||
<a class="fbtn-open" href="${esc(f.url)}" target="_blank" rel="noopener">New tab</a>
|
||
</div></div>`;
|
||
}).join('');
|
||
return `<div class="row"><div class="label">Faucet — claim whenever you need</div>${items}</div>`;
|
||
}
|
||
|
||
function explorerHTML(chain){
|
||
const links = chain.explorer.map(x => `<a href="${esc(x.url)}" target="_blank" rel="noopener">${esc(x.name)}</a>`).join(' · ');
|
||
return `<div class="explorer">Explorer: ${links} → search an address</div>`;
|
||
}
|
||
|
||
// the chain a render unit currently shows (group → the active EVM network)
|
||
const unitChain = u => u.group ? CHAIN_BY_ID[evmActive] : u.chain;
|
||
// ordering (mcap / tvl / alpha / activity) applies to the EVM network dropdown only
|
||
function sortedChains(chains){
|
||
const arr = chains.slice();
|
||
if(orderMode === 'alpha') arr.sort((a,b) => a.name.localeCompare(b.name));
|
||
else if(orderMode === 'activity') arr.sort((a,b) => rankOf(ACTIVITY_RANK,a.id) - rankOf(ACTIVITY_RANK,b.id));
|
||
else if(orderMode === 'tvl') arr.sort((a,b) => rankOf(TVL_RANK,a.id) - rankOf(TVL_RANK,b.id));
|
||
else arr.sort((a,b) => rankOf(MCAP_RANK,a.id) - rankOf(MCAP_RANK,b.id));
|
||
return arr;
|
||
}
|
||
// order select is re-created with the current value on every rebuildUnit('evm')
|
||
const orderCtlHTML = () => `<label class="auto-lbl">Order
|
||
<select class="evm-order" title="Order of networks in this dropdown">
|
||
${[['mcap','Market cap'],['tvl','TVL'],['alpha','A → Z'],['activity','Activity']]
|
||
.map(([v,l]) => `<option value="${v}"${v===orderMode?' selected':''}>${l}</option>`).join('')}
|
||
</select></label>`;
|
||
const autoCtlHTML = u => `<div class="head-ctls">
|
||
${u.group ? orderCtlHTML() : ''}
|
||
<label class="auto-lbl">Auto-refresh
|
||
<select class="chain-auto" data-unit="${u.unit}" title="Auto-refresh this card's balances">
|
||
<option value="0">off</option><option value="15">15s</option><option value="30">30s</option><option value="60">60s</option>
|
||
</select>
|
||
</label></div>`;
|
||
function cardHTML(u){
|
||
const c = unitChain(u);
|
||
const titleEl = u.group
|
||
? `<select class="evm-net" title="Choose EVM network">${sortedChains(u.chains).map(x => `<option value="${x.id}"${x.id===evmActive?' selected':''}>${esc(x.name)}</option>`).join('')}</select>`
|
||
: `<h2>${esc(c.name)}</h2>`;
|
||
return `<div class="card" data-chain="${c.id}" data-unit="${u.unit}">
|
||
<div class="card-head">
|
||
<span class="cicon" style="color:${c.color}">${ICONS[c.id]||''}</span>
|
||
<span class="chev">▾</span>
|
||
${titleEl}
|
||
<span class="badge">${esc(c.badge)}</span>
|
||
${autoCtlHTML(u)}
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row"><div class="funds">${esc(c.note)}</div></div>
|
||
${watchlistHTML(c)}
|
||
${faucetsHTML(c)}
|
||
${explorerHTML(c)}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
function tabHTML(u, active){
|
||
const c = unitChain(u);
|
||
const short = u.group ? 'EVM' : c.short;
|
||
return `<div class="tab${active?' active':''}" data-unit="${u.unit}" data-chain="${c.id}"><span class="cicon sm" style="color:${c.color}">${ICONS[c.id]||''}</span>${esc(short)}</div>`;
|
||
}
|
||
function buildPanels(){
|
||
panels.innerHTML = RENDER_UNITS.map(cardHTML).join('');
|
||
tabbar.innerHTML = RENDER_UNITS.map((u,i) => tabHTML(u, i===0)).join('');
|
||
wireEmbeds();
|
||
}
|
||
// rebuild a single unit's card + tab (used when the EVM network dropdown changes)
|
||
function rebuildUnit(unit){
|
||
const u = RENDER_UNITS.find(x => x.unit === unit); if(!u) return;
|
||
const activeTab = tabbar.querySelector('.tab.active')?.dataset.unit;
|
||
const card = panels.querySelector(`.card[data-unit="${unit}"]`);
|
||
const tmpC = document.createElement('div'); tmpC.innerHTML = cardHTML(u); card.replaceWith(tmpC.firstElementChild);
|
||
const tab = tabbar.querySelector(`.tab[data-unit="${unit}"]`);
|
||
const tmpT = document.createElement('div'); tmpT.innerHTML = tabHTML(u, activeTab === unit); tab.replaceWith(tmpT.firstElementChild);
|
||
wireEmbeds();
|
||
applyCollapsed(); setActiveTab(activeTab || RENDER_UNITS[0].unit); applyChainVis();
|
||
const c = unitChain(u);
|
||
const sel = panels.querySelector(`.card[data-unit="${unit}"] .chain-auto`); if(sel) sel.value = String(autoByChain[unit] || 0);
|
||
reloadSet(panels.querySelector(`.card[data-unit="${unit}"]`).querySelectorAll('.bal[data-addr]'));
|
||
}
|
||
|
||
// re-render only one chain's watchlist region (keeps faucet iframes intact)
|
||
function rerenderWatchlist(chainId){
|
||
const card = panels.querySelector(`.card[data-chain="${chainId}"]`);
|
||
if(!card) return;
|
||
const region = card.querySelector('.wl-region');
|
||
const tmp = document.createElement('div');
|
||
tmp.innerHTML = watchlistHTML(CHAIN_BY_ID[chainId]);
|
||
region.replaceWith(tmp.firstElementChild);
|
||
reloadSet(card.querySelector('.wl-region').querySelectorAll('.bal[data-addr]'));
|
||
}
|
||
|
||
/* ===================== events (delegated on #panels) ===================== */
|
||
// shared inline editor: swaps `target` for an input + ✓/✕ buttons.
|
||
// onCommit(value) returns null on success or an error string (keeps editing).
|
||
function inlineEdit(target, opts){
|
||
if(target.parentElement.querySelector('.edit-wrap')) return;
|
||
const wrap = document.createElement('span');
|
||
wrap.className = 'edit-wrap' + (opts.wrapCls ? ' ' + opts.wrapCls : '');
|
||
const input = document.createElement('input');
|
||
input.className = opts.inputCls; input.value = opts.value; input.spellcheck = false;
|
||
if(opts.maxLength) input.maxLength = opts.maxLength;
|
||
const ok = document.createElement('button'); ok.className = 'iconbtn ok'; ok.textContent = '✓'; ok.title = 'Save';
|
||
const no = document.createElement('button'); no.className = 'iconbtn no'; no.textContent = '✕'; no.title = 'Cancel';
|
||
wrap.append(input, ok, no);
|
||
let done = false;
|
||
if(opts.extra){
|
||
const ex = document.createElement('button'); ex.className = 'iconbtn ' + opts.extra.cls;
|
||
ex.textContent = opts.extra.text; ex.title = opts.extra.title;
|
||
ex.addEventListener('mousedown', e => { e.preventDefault(); done = true; opts.extra.onClick(); });
|
||
wrap.append(ex);
|
||
}
|
||
target.replaceWith(wrap); input.focus(); input.select();
|
||
const finish = () => { if(!done){ done = true; opts.onCancel(wrap); } };
|
||
const commit = () => {
|
||
if(done) return;
|
||
const err = opts.onCommit(input.value.trim(), wrap);
|
||
if(err){ input.classList.add('bad'); input.title = err; input.focus(); return; }
|
||
done = true;
|
||
};
|
||
ok.addEventListener('mousedown', e => { e.preventDefault(); commit(); });
|
||
no.addEventListener('mousedown', e => { e.preventDefault(); finish(); });
|
||
input.addEventListener('input', () => { input.classList.remove('bad'); input.removeAttribute('title'); });
|
||
input.addEventListener('keydown', e => { if(e.key==='Enter'){ e.preventDefault(); commit(); } else if(e.key==='Escape'){ finish(); } });
|
||
wrap.addEventListener('focusout', () => { setTimeout(() => { if(!done && !wrap.contains(document.activeElement)) finish(); }, 0); });
|
||
}
|
||
function beginLabelEdit(item){
|
||
const chainId = item.closest('.card').dataset.chain; const addr = item.dataset.addr;
|
||
const labelEl = item.querySelector('.wl-label');
|
||
const original = labelEl.classList.contains('muted') ? '' : labelEl.textContent;
|
||
const restore = (val) => {
|
||
const span = document.createElement('span');
|
||
span.className = val ? 'wl-label' : 'wl-label muted'; span.title = 'Click to rename';
|
||
span.textContent = val || 'label';
|
||
(item.querySelector('.edit-wrap') || labelEl).replaceWith(span);
|
||
};
|
||
inlineEdit(labelEl, {
|
||
inputCls:'wl-label-input', value:original, maxLength:40,
|
||
onCommit:(val) => { setLabel(chainId, addr, val); restore(val); return null; },
|
||
onCancel:() => restore(original),
|
||
});
|
||
}
|
||
// edit the ADDRESS (click ✎); ✓ confirms, 🗑 deletes, address is otherwise a link
|
||
function beginAddressEdit(item){
|
||
const chainId = item.closest('.card').dataset.chain; const chain = CHAIN_BY_ID[chainId];
|
||
const oldAddr = item.dataset.addr; const seedEntry = item.dataset.seed === '1';
|
||
const labelEl = item.querySelector('.wl-label');
|
||
const curLabel = labelEl.classList.contains('muted') ? '' : labelEl.textContent;
|
||
const linkEl = item.querySelector('.wl-addr');
|
||
const restore = (a) => { const tmp=document.createElement('div'); tmp.innerHTML=mkAddrLinkHTML(chain, a); (item.querySelector('.edit-wrap')||linkEl).replaceWith(tmp.firstElementChild); };
|
||
inlineEdit(linkEl, {
|
||
wrapCls:'addr', inputCls:'wl-addr-input', value:oldAddr,
|
||
onCommit:(val) => {
|
||
if(val === oldAddr){ restore(oldAddr); return null; }
|
||
if(!chain.validate(val)) return 'Not a valid ' + chain.short + ' address';
|
||
const msg = editAddress(chainId, oldAddr, val, seedEntry, curLabel);
|
||
if(msg) return msg;
|
||
rerenderWatchlist(chainId); return null;
|
||
},
|
||
onCancel:() => restore(oldAddr),
|
||
extra:{ cls:'del', text:'🗑', title:'Delete this address', onClick:() => {
|
||
if(!confirm('Are you sure you want to delete this address?\n\n' + oldAddr)){ restore(oldAddr); return; }
|
||
seedEntry ? hideSeed(chainId, oldAddr) : removeAddr(chainId, oldAddr);
|
||
rerenderWatchlist(chainId);
|
||
}},
|
||
});
|
||
}
|
||
// assets selector panel
|
||
function assetsPanelHTML(chainId){
|
||
const shown = new Set(shownFor(chainId));
|
||
const chips = assetOrder[chainId].map(k => {
|
||
const a = chainAssets[chainId].get(k); const on = shown.has(k);
|
||
return `<button class="asset-chip${on?' on':''}" data-key="${esc(k)}">${on?'✓ ':''}${esc(a.sym)}</button>`;
|
||
}).join('');
|
||
const scan = (chainId === 'tron' || CHAIN_BY_ID[chainId].blockscout)
|
||
? `<button class="asset-scan">⟳ Scan token holdings</button>` : '';
|
||
return `<span class="assets-hint">Show which assets:</span>${chips}${scan}`;
|
||
}
|
||
// re-render only the address list (keeps the assets panel open)
|
||
function rerenderList(chainId){
|
||
const card = panels.querySelector(`.card[data-chain="${chainId}"]`);
|
||
const tmp = document.createElement('div'); tmp.innerHTML = watchlistHTML(CHAIN_BY_ID[chainId]);
|
||
card.querySelector('.wl-list').replaceWith(tmp.querySelector('.wl-list'));
|
||
reloadSet(card.querySelector('.wl-list').querySelectorAll('.bal[data-addr]'));
|
||
}
|
||
|
||
panels.addEventListener('click', (ev) => {
|
||
const t = ev.target;
|
||
// collapse toggle via header (cards view only), unless a header control was clicked
|
||
const head = t.closest('.card-head');
|
||
if(head && document.body.dataset.view === 'cards' && !t.closest('.head-ctls') && !t.closest('.evm-net')){
|
||
toggleCard(head.parentElement.dataset.unit); return;
|
||
}
|
||
if(t.closest('.wl-refresh-all')){
|
||
const card = t.closest('.card');
|
||
reloadSet(card.querySelectorAll('.bal[data-addr]'), t.closest('.wl-refresh-all')); return;
|
||
}
|
||
if(t.closest('.wl-assets')){
|
||
const panel = t.closest('.card').querySelector('.assets-panel');
|
||
if(panel.hasAttribute('hidden')){ panel.innerHTML = assetsPanelHTML(t.closest('.card').dataset.chain); panel.removeAttribute('hidden'); }
|
||
else { panel.setAttribute('hidden',''); panel.innerHTML = ''; }
|
||
return;
|
||
}
|
||
if(t.classList.contains('asset-chip')){
|
||
const chainId = t.closest('.card').dataset.chain;
|
||
toggleShown(chainId, t.dataset.key);
|
||
t.closest('.assets-panel').innerHTML = assetsPanelHTML(chainId);
|
||
rerenderList(chainId); return;
|
||
}
|
||
if(t.closest('.asset-scan')){
|
||
const btn = t.closest('.asset-scan'); const chainId = t.closest('.card').dataset.chain;
|
||
btn.textContent = 'Scanning…'; btn.disabled = true;
|
||
const discover = chainId === 'tron' ? discoverTronTokens : discoverEvmTokens;
|
||
discover(chainId).then(() => {
|
||
const panel = panels.querySelector(`.card[data-chain="${chainId}"] .assets-panel`);
|
||
if(panel && !panel.hasAttribute('hidden')) panel.innerHTML = assetsPanelHTML(chainId);
|
||
});
|
||
return;
|
||
}
|
||
if(t.classList.contains('copy') && t.dataset.copy != null){ doCopy(t, t.dataset.copy); return; }
|
||
if(t.classList.contains('edit')){ beginAddressEdit(t.closest('.wl-item')); return; }
|
||
if(t.classList.contains('wl-label')){ beginLabelEdit(t.closest('.wl-item')); return; }
|
||
if(t.classList.contains('addbtn')){
|
||
const region = t.closest('.wl-region'); const chainId = t.closest('.card').dataset.chain; const chain = CHAIN_BY_ID[chainId];
|
||
const inp = region.querySelector('.wl-input'); const lbl = region.querySelector('.wl-lbl'); const err = region.querySelector('.wl-err');
|
||
const addr = inp.value.trim(); err.textContent = '';
|
||
if(!addr){ err.textContent = 'Enter an address.'; return; }
|
||
if(!chain.validate(addr)){ err.textContent = 'That doesn’t look like a valid ' + chain.short + ' address.'; return; }
|
||
const msg = addAddr(chainId, addr, lbl.value);
|
||
if(msg){ err.textContent = msg; return; }
|
||
rerenderWatchlist(chainId);
|
||
}
|
||
});
|
||
panels.addEventListener('keydown', (ev) => {
|
||
if(ev.key === 'Enter' && (ev.target.classList.contains('wl-input') || ev.target.classList.contains('wl-lbl'))){
|
||
const region = ev.target.closest('.wl-region'); if(region) region.querySelector('.addbtn').click();
|
||
}
|
||
});
|
||
panels.addEventListener('change', (ev) => {
|
||
const auto = ev.target.closest('.chain-auto');
|
||
if(auto){ setChainAuto(auto.dataset.unit, auto.value); return; }
|
||
const ord = ev.target.closest('.evm-order');
|
||
if(ord){ orderMode = ord.value; ls.set(ORDERKEY, orderMode); rebuildUnit('evm'); return; }
|
||
const net = ev.target.closest('.evm-net');
|
||
if(net){ evmActive = net.value; ls.set(EVMKEY, evmActive); rebuildUnit('evm'); }
|
||
});
|
||
|
||
function wireEmbeds(){
|
||
panels.querySelectorAll('.faucet-item:not(.blocked-item)').forEach(item => {
|
||
if(item.dataset.wired) return; item.dataset.wired = '1'; // idempotent — rebuildUnit re-calls this
|
||
const btn = item.querySelector('.fbtn-embed');
|
||
const wrap = item.querySelector('.frame-wrap');
|
||
const url = item.dataset.url;
|
||
if(!btn || !wrap) return;
|
||
const reload = document.createElement('button');
|
||
reload.className = 'fbtn-reload'; reload.title = 'Reload this faucet'; reload.textContent = '↻';
|
||
btn.after(reload);
|
||
reload.addEventListener('click', () => {
|
||
const frame = wrap.querySelector('iframe'); if(!frame) return;
|
||
frame.src = url; reload.classList.remove('spin1'); void reload.offsetWidth; reload.classList.add('spin1');
|
||
});
|
||
btn.addEventListener('click', () => {
|
||
const open = wrap.classList.toggle('open');
|
||
btn.classList.toggle('active', open);
|
||
btn.textContent = open ? 'Collapse ▴' : 'Open here ▾';
|
||
reload.classList.toggle('show', open);
|
||
if(open && !wrap.dataset.loaded){
|
||
wrap.dataset.loaded = '1';
|
||
const frame = document.createElement('iframe');
|
||
frame.setAttribute('loading','lazy'); frame.setAttribute('referrerpolicy','no-referrer');
|
||
frame.src = url; wrap.appendChild(frame);
|
||
const hint = document.createElement('div');
|
||
hint.className = 'frame-hint';
|
||
hint.innerHTML = '<span>Blank, "refused to connect", or a broken captcha image? The faucet blocks framing ' +
|
||
'or needs first-party cookies — use <a href="'+url+'" target="_blank" rel="noopener">New tab ↗</a> instead.</span>' +
|
||
'<a href="'+url+'" target="_blank" rel="noopener">Open full ↗</a>';
|
||
wrap.after(hint);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ---- copy + toast ---- */
|
||
const toast = document.getElementById('toast'); let toastTimer;
|
||
function showToast(msg){ toast.textContent = msg; toast.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(()=>toast.classList.remove('show'), 1500); }
|
||
async function doCopy(btn, text){
|
||
try { await navigator.clipboard.writeText(text); }
|
||
catch(e){ const ta=document.createElement('textarea'); ta.value=text; document.body.appendChild(ta); ta.select(); try{document.execCommand('copy');}catch{} ta.remove(); }
|
||
const old = btn.textContent; btn.textContent = 'Copied ✓'; btn.classList.add('done');
|
||
showToast(text.slice(0,12) + '… copied');
|
||
setTimeout(() => { btn.textContent = old; btn.classList.remove('done'); }, 1300);
|
||
}
|
||
|
||
/* ---- view switch (cards / tabs) ---- */
|
||
const viewSeg = document.getElementById('view-seg');
|
||
function setView(v){
|
||
document.body.dataset.view = v;
|
||
viewSeg.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.view === v));
|
||
ls.set(VKEY, v);
|
||
if(v === 'tabs') setActiveTab(ls.get(TKEY) || RENDER_UNITS[0].unit);
|
||
else applyCollapsed();
|
||
}
|
||
viewSeg.addEventListener('click', e => { const b = e.target.closest('button'); if(b) setView(b.dataset.view); });
|
||
|
||
/* ---- chain visibility (header "Chains" dropdown) ---- */
|
||
const CHAINS_KEY = 'faucet.chains.v1';
|
||
const ALL_UNITS = RENDER_UNITS.map(u => u.unit);
|
||
let visibleUnits = (()=>{ try {
|
||
const v = JSON.parse(ls.get(CHAINS_KEY));
|
||
if(Array.isArray(v)){ const f = ALL_UNITS.filter(id => v.includes(id)); if(f.length) return f; }
|
||
} catch {}
|
||
return ALL_UNITS.slice(); })();
|
||
const chainsBtn = document.getElementById('chains-btn');
|
||
const chainsPanel = document.getElementById('chains-panel');
|
||
const unitLabel = u => u.group ? 'EVM Networks' : u.chain.name;
|
||
function applyChainVis(){
|
||
panels.querySelectorAll('.card').forEach(c => c.classList.toggle('unit-hidden', !visibleUnits.includes(c.dataset.unit)));
|
||
tabbar.querySelectorAll('.tab').forEach(t => t.classList.toggle('unit-hidden', !visibleUnits.includes(t.dataset.unit)));
|
||
const active = tabbar.querySelector('.tab.active')?.dataset.unit;
|
||
if(active && !visibleUnits.includes(active)) setActiveTab(visibleUnits[0]);
|
||
}
|
||
function chainsPanelHTML(){
|
||
return RENDER_UNITS.map(u => {
|
||
const on = visibleUnits.includes(u.unit);
|
||
const lock = on && visibleUnits.length <= 1; // can't uncheck the last visible unit
|
||
return `<label class="${on?'':'off'}${lock?' lock':''}"><input type="checkbox" data-unit="${u.unit}"${on?' checked':''}${lock?' disabled':''}/>${esc(unitLabel(u))}</label>`;
|
||
}).join('');
|
||
}
|
||
chainsBtn.addEventListener('click', () => {
|
||
if(chainsPanel.hasAttribute('hidden')){ chainsPanel.innerHTML = chainsPanelHTML(); chainsPanel.removeAttribute('hidden'); chainsBtn.classList.add('open'); }
|
||
else { chainsPanel.setAttribute('hidden',''); chainsBtn.classList.remove('open'); }
|
||
});
|
||
chainsPanel.addEventListener('change', (ev) => {
|
||
const cb = ev.target.closest('input[data-unit]'); if(!cb) return;
|
||
const id = cb.dataset.unit;
|
||
if(cb.checked) visibleUnits = ALL_UNITS.filter(u => visibleUnits.includes(u) || u === id); // keep RENDER_UNITS order
|
||
else { if(visibleUnits.length <= 1){ cb.checked = true; return; } visibleUnits = visibleUnits.filter(u => u !== id); }
|
||
ls.set(CHAINS_KEY, JSON.stringify(visibleUnits));
|
||
chainsPanel.innerHTML = chainsPanelHTML();
|
||
applyChainVis();
|
||
});
|
||
document.addEventListener('click', (ev) => {
|
||
if(!chainsPanel.hasAttribute('hidden') && !ev.target.closest('#chains-wrap')){
|
||
chainsPanel.setAttribute('hidden',''); chainsBtn.classList.remove('open');
|
||
}
|
||
});
|
||
|
||
const UNIT_IDS = new Set(RENDER_UNITS.map(u => u.unit));
|
||
function setActiveTab(unit){
|
||
if(!UNIT_IDS.has(unit)) unit = RENDER_UNITS[0].unit;
|
||
tabbar.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.unit === unit));
|
||
panels.querySelectorAll('.card').forEach(c => c.classList.toggle('active-tab', c.dataset.unit === unit));
|
||
ls.set(TKEY, unit);
|
||
}
|
||
tabbar.addEventListener('click', e => { const t = e.target.closest('.tab'); if(t) setActiveTab(t.dataset.unit); });
|
||
|
||
/* ---- collapsible panels + accordion mode (cards view) ---- */
|
||
const panelSeg = document.getElementById('panel-seg');
|
||
let panelMode = ls.get(PKEY) || 'multi';
|
||
let collapsed = new Set((()=>{ try { return JSON.parse(ls.get(CKEY)) || []; } catch { return []; } })());
|
||
function applyCollapsed(){ panels.querySelectorAll('.card').forEach(c => c.classList.toggle('collapsed', collapsed.has(c.dataset.unit))); }
|
||
function saveCollapsed(){ ls.set(CKEY, JSON.stringify([...collapsed])); }
|
||
function toggleCard(unit){
|
||
if(panelMode === 'accordion'){
|
||
const wasCollapsed = collapsed.has(unit);
|
||
RENDER_UNITS.forEach(u => collapsed.add(u.unit));
|
||
if(wasCollapsed) collapsed.delete(unit); // open the clicked one, close the rest
|
||
} else {
|
||
collapsed.has(unit) ? collapsed.delete(unit) : collapsed.add(unit);
|
||
}
|
||
applyCollapsed(); saveCollapsed();
|
||
}
|
||
function setPanelMode(m){
|
||
panelMode = m;
|
||
panelSeg.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.mode === m));
|
||
if(m === 'accordion'){
|
||
const open = RENDER_UNITS.map(u=>u.unit).filter(id => !collapsed.has(id));
|
||
if(open.length > 1){ const keep = open[0]; RENDER_UNITS.forEach(u => collapsed.add(u.unit)); collapsed.delete(keep); }
|
||
else if(open.length === 0){ collapsed.delete(RENDER_UNITS[0].unit); }
|
||
applyCollapsed(); saveCollapsed();
|
||
}
|
||
ls.set(PKEY, m);
|
||
}
|
||
panelSeg.addEventListener('click', e => { const b = e.target.closest('button'); if(b) setPanelMode(b.dataset.mode); });
|
||
|
||
/* ---- per-card auto-refresh (keyed by render unit) ---- */
|
||
let autoByChain = (()=>{ try { return JSON.parse(ls.get(AKEY)) || {}; } catch { return {}; } })();
|
||
const autoTimers = {};
|
||
function refreshChain(unit){
|
||
const card = panels.querySelector(`.card[data-unit="${unit}"]`);
|
||
if(card) reloadSet(card.querySelectorAll('.bal[data-addr]'));
|
||
}
|
||
function setChainAuto(unit, sec){
|
||
sec = +sec || 0;
|
||
if(autoTimers[unit]){ clearInterval(autoTimers[unit]); delete autoTimers[unit]; }
|
||
if(sec > 0){ autoTimers[unit] = setInterval(() => refreshChain(unit), sec*1000); }
|
||
const sel = panels.querySelector(`.card[data-unit="${unit}"] .chain-auto`);
|
||
if(sel){ sel.value = String(sec); sel.classList.toggle('live', sec > 0); }
|
||
if(sec > 0) autoByChain[unit] = sec; else delete autoByChain[unit];
|
||
ls.set(AKEY, JSON.stringify(autoByChain));
|
||
}
|
||
|
||
/* ---- profiles (local username / accounts) ---- */
|
||
const profSel = document.getElementById('profile-sel');
|
||
const genId = () => 'p' + Date.now().toString(36) + Math.floor(Math.random()*46656).toString(36);
|
||
function updateProfileUI(){
|
||
profSel.innerHTML = PROFILES.order.map(id =>
|
||
`<option value="${id}"${id===PROFILES.active?' selected':''}>${esc(PROFILES.profiles[id].name)}</option>`).join('');
|
||
document.getElementById('profile-del').disabled = PROFILES.order.length <= 1;
|
||
}
|
||
function reloadActiveProfile(){
|
||
bindProfile();
|
||
RENDER_UNITS.forEach(u => rerenderWatchlist(unitChain(u).id));
|
||
updateProfileUI();
|
||
}
|
||
function switchProfile(id){ if(PROFILES.profiles[id]){ PROFILES.active = id; saveProfiles(); reloadActiveProfile(); } }
|
||
function newProfile(){
|
||
const name = (prompt('New profile / username:') || '').trim(); if(!name) return;
|
||
const id = genId();
|
||
PROFILES.profiles[id] = { name, wl:{}, lb:{}, hd:{} };
|
||
PROFILES.order.push(id); PROFILES.active = id; saveProfiles(); reloadActiveProfile();
|
||
}
|
||
function renameProfile(){
|
||
const p = PROFILES.profiles[PROFILES.active];
|
||
const name = (prompt('Rename profile / username:', p.name) || '').trim(); if(!name) return;
|
||
p.name = name; saveProfiles(); updateProfileUI();
|
||
}
|
||
function deleteProfile(){
|
||
if(PROFILES.order.length <= 1) return;
|
||
const p = PROFILES.profiles[PROFILES.active];
|
||
if(!confirm('Delete profile "' + p.name + '" and all its tracked addresses?')) return;
|
||
const id = PROFILES.active;
|
||
delete PROFILES.profiles[id];
|
||
PROFILES.order = PROFILES.order.filter(x => x !== id);
|
||
PROFILES.active = PROFILES.order[0]; saveProfiles(); reloadActiveProfile();
|
||
}
|
||
profSel.addEventListener('change', e => switchProfile(e.target.value));
|
||
document.getElementById('profile-new').addEventListener('click', newProfile);
|
||
document.getElementById('profile-rename').addEventListener('click', renameProfile);
|
||
document.getElementById('profile-del').addEventListener('click', deleteProfile);
|
||
|
||
/* ---- owner account: unlockable encrypted seed (public deploys) ----
|
||
faucet-seed.enc.json is AES-256-GCM encrypted with a key derived from the
|
||
account holder's identity. Two modes, chosen by which loader can find the
|
||
acct/*.js modules from CoinSpectrum:
|
||
· phrase mode (v:2 blob) — seed phrase → BIP-32 derive at m/44'/145'/1417'/2/0
|
||
→ raw 32-byte AES key. Same seed the owner uses on CoinSpectrum;
|
||
deliberately a different path than CoinSpectrum's PATH_ENC so a
|
||
faucet compromise cannot decrypt the sync DEK.
|
||
· WIF mode (v:1 blob) — legacy path for standalone deploys without acct/*.
|
||
Decryption is entirely in-page; wrong key fails GCM auth.
|
||
Mirror of build-seed-enc.mjs. */
|
||
const ACCT_KEY = 'faucet.acct.v1'; // derived viewing key (base64), NOT the WIF/phrase
|
||
const ACCT_MODE_KEY = 'faucet.acct.mode.v1'; // 'phrase' | 'wif' | 'master' — matches the stored key
|
||
const FAUCET_ENC_PATH = "m/44'/145'/1417'/2/0"; // BIP-32 path for the owner wrapping key
|
||
const acctBtn = document.getElementById('acct-lock');
|
||
const acctPanel = document.getElementById('acct-panel');
|
||
const acctErr = document.getElementById('acct-err');
|
||
const acctPhrase = document.getElementById('acct-phrase');
|
||
const acctWif = document.getElementById('acct-wif');
|
||
const acctMasterPw = document.getElementById('acct-masterpw');
|
||
const acctHint = document.getElementById('acct-hint');
|
||
let acctUnlocked = false;
|
||
// Default mode chosen by the blob version — see chooseModeFromBlob() below.
|
||
// Falls back to 'wif' if the blob is absent or malformed.
|
||
let acctMode = 'wif';
|
||
const b64ToBytes = b64 => Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
||
|
||
/* -- phrase mode (via CoinSpectrum's CSX) -- */
|
||
// Load acct/*.js from a sibling folder (CoinSpectrum layout, /faucet/ + /acct/) or
|
||
// this folder (standalone). First one that resolves wins; failure = stay in WIF mode.
|
||
const ACCT_MODULE_ROOTS = ['../acct/', './acct/'];
|
||
const ACCT_MODULE_FILES = ['wordlist.js', 'csx.js']; // wallet.js/account.js not needed here
|
||
let acctModulePromise = null;
|
||
function loadAcctModules(){
|
||
if(acctModulePromise) return acctModulePromise;
|
||
if(!(window.isSecureContext && window.crypto && crypto.subtle)) return Promise.reject(new Error('needs a secure context (https or localhost)'));
|
||
acctModulePromise = (async () => {
|
||
let lastErr;
|
||
for(const root of ACCT_MODULE_ROOTS){
|
||
try {
|
||
for(const f of ACCT_MODULE_FILES){
|
||
const url = root + f;
|
||
const head = await fetch(url, {method:'HEAD'}).catch(() => null);
|
||
if(!head || !head.ok){ throw new Error('not present at ' + url); }
|
||
}
|
||
await Promise.all(ACCT_MODULE_FILES.map(f => new Promise((res, rej) => {
|
||
const s = document.createElement('script'); s.src = root + f; s.async = true;
|
||
s.onload = res; s.onerror = () => rej(new Error('failed to load ' + root + f));
|
||
document.head.appendChild(s);
|
||
})));
|
||
if(!window.CSX) throw new Error('CSX did not initialize');
|
||
return true;
|
||
} catch(e){ lastErr = e; }
|
||
}
|
||
throw lastErr || new Error('no acct/ modules found');
|
||
})();
|
||
return acctModulePromise;
|
||
}
|
||
async function phraseToKey(mnemonic){
|
||
const X = window.CSX;
|
||
const v = await X.validateMnemonic(mnemonic);
|
||
if(!v.ok) throw new Error('not a valid seed phrase: ' + v.reason);
|
||
const seedBytes = await X.mnemonicToSeed(mnemonic);
|
||
const node = await X.derive(seedBytes, FAUCET_ENC_PATH);
|
||
return node.k; // 32 raw bytes → AES-256 key
|
||
}
|
||
|
||
/* -- WIF mode (kept for standalone deploys without acct/*) -- */
|
||
const B58A = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||
function b58decode(s){
|
||
let n = 0n;
|
||
for(const c of s){ const i = B58A.indexOf(c); if(i < 0) throw new Error('not a WIF (bad character)'); n = n*58n + BigInt(i); }
|
||
const out = [];
|
||
while(n > 0n){ out.unshift(Number(n & 0xffn)); n >>= 8n; }
|
||
for(const c of s){ if(c === '1') out.unshift(0); else break; }
|
||
return new Uint8Array(out);
|
||
}
|
||
async function wifToPrivkey(wif){
|
||
const raw = b58decode(wif.trim());
|
||
if(raw.length < 37) throw new Error('not a WIF (too short)');
|
||
const payload = raw.slice(0, raw.length - 4), check = raw.slice(raw.length - 4);
|
||
const d1 = new Uint8Array(await crypto.subtle.digest('SHA-256', payload));
|
||
const d2 = new Uint8Array(await crypto.subtle.digest('SHA-256', d1));
|
||
if(d2[0] !== check[0] || d2[1] !== check[1] || d2[2] !== check[2] || d2[3] !== check[3])
|
||
throw new Error('not a WIF (checksum mismatch)');
|
||
return payload.slice(1, 33);
|
||
}
|
||
async function wifToRawKey(wif){
|
||
const priv = await wifToPrivkey(wif);
|
||
const base = await crypto.subtle.importKey('raw', priv, 'PBKDF2', false, ['deriveBits']);
|
||
const bits = await crypto.subtle.deriveBits(
|
||
{name:'PBKDF2', salt:new TextEncoder().encode('deviant-faucet-seed-v1'), iterations:100000, hash:'SHA-256'},
|
||
base, 256);
|
||
return new Uint8Array(bits);
|
||
}
|
||
|
||
/* -- master-password mode (v:3) — matches the Deviant chipnet-keystore master pw.
|
||
Salt is EMBEDDED in the blob (fresh 16 bytes per build), NOT a fixed string, so
|
||
an attacker who steals faucet-seed.enc.json cannot use precomputed tables shared
|
||
with any other site or blob. 200k iterations matches the keystore. -- */
|
||
async function masterPwToRawKey(pw){
|
||
const blob = window.FAUCET_SEED_ENC;
|
||
if(!blob || blob.v !== 3 || !blob.salt) throw new Error('this deployment has no v:3 (master-password) blob');
|
||
const iters = parseInt(String(blob.kdf || 'PBKDF2-SHA256/200000').split('/')[1], 10) || 200000;
|
||
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(pw), 'PBKDF2', false, ['deriveBits']);
|
||
const bits = await crypto.subtle.deriveBits(
|
||
{name:'PBKDF2', salt:b64ToBytes(blob.salt), iterations:iters, hash:'SHA-256'},
|
||
base, 256);
|
||
return new Uint8Array(bits);
|
||
}
|
||
|
||
/* -- decrypt with a raw 32-byte key (mode-agnostic) -- */
|
||
async function decryptSeedWithRaw(rawKey){
|
||
const blob = window.FAUCET_SEED_ENC;
|
||
const key = await crypto.subtle.importKey('raw', rawKey, {name:'AES-GCM'}, false, ['decrypt']);
|
||
const pt = await crypto.subtle.decrypt({name:'AES-GCM', iv:b64ToBytes(blob.iv)}, key, b64ToBytes(blob.ct));
|
||
return JSON.parse(new TextDecoder().decode(pt));
|
||
}
|
||
function updateAcctUI(){
|
||
acctBtn.hidden = !(window.FAUCET_SEED_ENC && !window.FAUCET_SEED);
|
||
acctBtn.textContent = acctUnlocked ? '🔓 Sign out' : '🔑 Sign in';
|
||
acctBtn.title = acctUnlocked ? 'Forget the viewing key on this device and lock the owner list'
|
||
: "Unlock the owner's address list";
|
||
}
|
||
function showAcctInputs(){
|
||
acctPhrase.hidden = acctWif.hidden = acctMasterPw.hidden = true;
|
||
if(acctMode === 'phrase'){
|
||
acctPhrase.hidden = false;
|
||
acctHint.innerHTML = "The seed phrase never leaves this page. It derives a per-purpose key (BIP-32 path <span style=\"font-family:var(--mono);color:#bfeae4\">" + FAUCET_ENC_PATH + "</span>) that decrypts the owner list. Only that derived key is remembered on this device if you tick the box.";
|
||
} else if(acctMode === 'master'){
|
||
acctMasterPw.hidden = false;
|
||
acctHint.innerHTML = "The master password never leaves this page. It derives a per-file key (PBKDF2-SHA256, 200k iters, fresh salt) that decrypts the owner list. Only that derived key is remembered on this device if you tick the box.";
|
||
} else {
|
||
acctWif.hidden = false;
|
||
acctWif.placeholder = 'WIF private key (account holder)';
|
||
acctHint.innerHTML = "The key is used <b>locally</b> to decrypt the owner's address list — it never leaves this page and is not stored. Only the derived viewing key is remembered if you tick the box.";
|
||
}
|
||
}
|
||
function applySeed(data){
|
||
Object.keys(seed).forEach(k => delete seed[k]);
|
||
Object.assign(seed, data);
|
||
acctUnlocked = true;
|
||
updateAcctUI();
|
||
reloadActiveProfile();
|
||
}
|
||
async function doUnlock(){
|
||
acctErr.textContent = '';
|
||
try {
|
||
let rawKey;
|
||
if(acctMode === 'phrase'){
|
||
const phrase = acctPhrase.value.trim();
|
||
if(!phrase) throw new Error('paste a seed phrase');
|
||
rawKey = await phraseToKey(phrase);
|
||
} else if(acctMode === 'master'){
|
||
const pw = acctMasterPw.value;
|
||
if(!pw) throw new Error('type the master password');
|
||
rawKey = await masterPwToRawKey(pw);
|
||
} else {
|
||
rawKey = await wifToRawKey(acctWif.value);
|
||
}
|
||
const data = await decryptSeedWithRaw(rawKey); // wrong key → GCM auth failure
|
||
if(document.getElementById('acct-remember').checked){
|
||
ls.set(ACCT_KEY, btoa(String.fromCharCode(...rawKey)));
|
||
ls.set(ACCT_MODE_KEY, acctMode);
|
||
}
|
||
acctPhrase.value = ''; acctWif.value = ''; acctMasterPw.value = '';
|
||
acctPanel.setAttribute('hidden', '');
|
||
applySeed(data);
|
||
showToast('Signed in — owner addresses unlocked');
|
||
} catch(e){
|
||
const msg = String(e && e.message || e);
|
||
acctErr.textContent = /seed phrase|WIF|master password/i.test(msg) ? msg : 'Wrong key — could not decrypt.';
|
||
}
|
||
}
|
||
async function tryAutoUnlock(){
|
||
// Blob version pins the default mode: v:3 → master-pw; else probe for CoinSpectrum
|
||
// acct/* modules (phrase mode); else fall back to WIF.
|
||
const blobVer = window.FAUCET_SEED_ENC && window.FAUCET_SEED_ENC.v;
|
||
if(blobVer === 3){ acctMode = 'master'; }
|
||
else { try { await loadAcctModules(); acctMode = 'phrase'; } catch { acctMode = 'wif'; } }
|
||
showAcctInputs();
|
||
updateAcctUI();
|
||
if(window.FAUCET_SEED || !window.FAUCET_SEED_ENC) return;
|
||
const stored = ls.get(ACCT_KEY);
|
||
if(!stored) return;
|
||
// If a stored viewing key was made under a different unlock mode, drop it — it
|
||
// was derived against a different blob version and cannot decrypt this one.
|
||
const storedMode = ls.get(ACCT_MODE_KEY) || 'wif';
|
||
if(storedMode !== acctMode){ try { localStorage.removeItem(ACCT_KEY); localStorage.removeItem(ACCT_MODE_KEY); } catch {} updateAcctUI(); return; }
|
||
try {
|
||
applySeed(await decryptSeedWithRaw(b64ToBytes(stored)));
|
||
} catch { try { localStorage.removeItem(ACCT_KEY); localStorage.removeItem(ACCT_MODE_KEY); } catch {} updateAcctUI(); }
|
||
}
|
||
acctBtn.addEventListener('click', () => {
|
||
if(acctUnlocked){
|
||
try { localStorage.removeItem(ACCT_KEY); localStorage.removeItem(ACCT_MODE_KEY); } catch {}
|
||
location.reload();
|
||
return;
|
||
}
|
||
if(acctPanel.hasAttribute('hidden')){
|
||
acctPanel.removeAttribute('hidden'); acctErr.textContent = '';
|
||
const focusField = acctMode === 'phrase' ? acctPhrase : acctMode === 'master' ? acctMasterPw : acctWif;
|
||
focusField.focus();
|
||
} else acctPanel.setAttribute('hidden', '');
|
||
});
|
||
document.getElementById('acct-unlock').addEventListener('click', doUnlock);
|
||
acctPhrase.addEventListener('keydown', e => { if(e.key === 'Enter' && (e.ctrlKey || e.metaKey)) doUnlock(); });
|
||
acctWif.addEventListener('keydown', e => { if(e.key === 'Enter') doUnlock(); });
|
||
acctMasterPw.addEventListener('keydown', e => { if(e.key === 'Enter') doUnlock(); });
|
||
document.addEventListener('click', ev => {
|
||
if(!acctPanel.hasAttribute('hidden') && !ev.target.closest('#acct-panel') && !ev.target.closest('#acct-lock'))
|
||
acctPanel.setAttribute('hidden', '');
|
||
});
|
||
|
||
/* ===================== init ===================== */
|
||
// file:// is a degraded context: requests go out with a null/opaque origin, which some
|
||
// balance APIs reject server-side (Solana, Stellar, Tron gas — measured), and the
|
||
// encrypted owner seed can't be fetched from disk. Warn and point at http(s).
|
||
if(location.protocol === 'file:'){
|
||
const w = document.createElement('div'); w.className = 'warn';
|
||
w.innerHTML = '📁 Opened from disk (<b>file://</b>) — browsers restrict pages loaded this way: some balance APIs reject the requests (Solana · Stellar · TRX) and the owner sign-in list cannot load. Open it over http(s) instead — run <code>python -m http.server</code> in this folder, or use the hosted site.';
|
||
document.querySelector('.warn').after(w);
|
||
}
|
||
buildPanels();
|
||
updateProfileUI();
|
||
setPanelMode(panelMode);
|
||
applyCollapsed();
|
||
setView(ls.get(VKEY) || 'tabs');
|
||
applyChainVis();
|
||
RENDER_UNITS.forEach(u => setChainAuto(u.unit, autoByChain[u.unit] || 0)); // restore per-card intervals
|
||
reloadSet(document.querySelectorAll('.bal[data-addr]'));
|
||
tryAutoUnlock(); // public deploy: restore the owner session if a viewing key is remembered
|
||
|
||
} // boot
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|