deviant/Parameters/dApp/Faucet/index.html
Local Dev 1ca645f680 feat(faucet): add chipnet.bch.ninja — sats + CashToken genesis in one flow
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>
2026-10-11 18:49:14 +02:00

1647 lines
99 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>