Bns-indexer on VPS was only exposed on port 50011 via nginx, which corporate
firewalls, mobile carriers, and many VPNs block. Added an nginx location on
443 that proxies to the same 127.0.0.1:50010, giving us a browser-friendly
WSS endpoint on the standard HTTPS port.
* lib/electrum.js CHIPNET_ELECTRUM: wss://silentmode.st/electrum listed
first (used by registrar.connect() + everything that depends on it).
* lib/resolver-web.js CHIPNET_ELECTRUM: same, kept in lockstep per the
"change both together" comment.
* bns-register.js bundle rebuilt to bake the new list.
* portal.html imports the bundle with ?v= so Chrome's in-memory ES-module
map doesn't serve a stale copy across tabs.
Portal now connects successfully; next remaining issue is that
bns-indexer only supports (server.version, get_history, transaction.get) —
`blockchain.scripthash.listunspent` for wallet address queries is not
implemented, so the "list your names" step fails there. Follow-up: add a
GET /api/holdings/<address> to public-gateway.mjs backed by the existing
BCHN electrum access, so the portal can pull holdings over plain HTTP.
283 lines
13 KiB
HTML
283 lines
13 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Portal — your names on sirius.x</title>
|
|
<meta name="description" content="Sign in with your Bitcoin Cash seed phrase to see every BCNR name your wallet holds and manage records. Your keys never leave the browser.">
|
|
<style>
|
|
:root{
|
|
--bg:#0b0e14; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
|
|
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
|
|
--ok:#4fd1a5; --taken:#f6768a; --warn:#ffc75f;
|
|
}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;background:radial-gradient(1100px 560px at 50% -12%,#16202e,var(--bg));
|
|
color:var(--ink);font:16px/1.65 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;min-height:100vh}
|
|
a{color:var(--acid)}
|
|
.wrap{max-width:820px;margin:0 auto;padding:0 1.2rem}
|
|
.topnav{position:sticky;top:0;z-index:20;display:flex;gap:2px;align-items:center;flex-wrap:wrap;
|
|
padding:10px 1.2rem;background:rgba(11,14,20,.82);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
|
|
.topnav .brand{font-weight:600;color:var(--ink);text-decoration:none;margin-right:14px;font-size:15px;letter-spacing:.2px}
|
|
.topnav .brand .g{color:var(--acid)}
|
|
.topnav a{color:var(--mut);text-decoration:none;padding:6px 12px;border-radius:8px;font-size:14px}
|
|
.topnav a:hover{color:var(--ink);background:var(--panel)}
|
|
.topnav a.here{color:var(--acid);background:rgba(214,255,61,.08)}
|
|
.topnav a.portal{margin-left:auto;background:rgba(214,255,61,.10);color:var(--acid);font-weight:500}
|
|
.topnav a.portal:hover{background:rgba(214,255,61,.18)}
|
|
header.hero{text-align:center;padding:3rem 1.2rem 1rem}
|
|
.mark{font-size:44px;line-height:1}
|
|
h1{font-size:clamp(1.6rem,4.5vw,2.2rem);margin:.3rem 0 .3rem}
|
|
h1 .g{color:var(--acid)}
|
|
.tag{color:var(--mut);font-size:1rem;max-width:580px;margin:0 auto}
|
|
h2{font-size:1.25rem;margin:0 0 .4rem}
|
|
section{padding:2rem 0;border-top:1px solid var(--line);margin-top:2rem}
|
|
section:first-of-type{border-top:none;margin-top:1rem}
|
|
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin-top:1rem}
|
|
label{display:block;font-size:13px;color:var(--mut);margin:14px 0 5px}
|
|
textarea,input[type=password],input[type=text]{
|
|
width:100%;padding:11px 13px;border-radius:10px;border:1px solid #ffffff22;background:#0e131b;
|
|
color:var(--ink);font-size:14px;font-family:inherit;outline:none
|
|
}
|
|
textarea{min-height:74px;resize:vertical;font-family:ui-monospace,monospace;font-size:13px}
|
|
textarea:focus,input:focus{border-color:#4b7bec}
|
|
.btn{display:inline-block;text-decoration:none;padding:10px 18px;border-radius:10px;background:#4b7bec;color:#fff;font-size:14px;border:none;cursor:pointer;font-family:inherit}
|
|
.btn.ghost{background:transparent;border:1px solid var(--line);color:var(--ink)}
|
|
.btn.acid{background:var(--acid);color:#0b0e14;font-weight:600}
|
|
.btn.small{padding:6px 12px;font-size:12.5px}
|
|
.btn:hover{filter:brightness(1.12)}
|
|
.btn:disabled{opacity:.45;cursor:not-allowed;filter:none}
|
|
.row{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px;align-items:center}
|
|
.muted{color:var(--mut);font-size:13px}
|
|
.dim{color:var(--dim);font-size:12.5px}
|
|
code,.mono{background:#0e131b;border:1px solid var(--line);border-radius:6px;padding:1px 6px;font-family:ui-monospace,monospace;font-size:13px;word-break:break-all}
|
|
.badge{display:inline-block;font-size:11px;padding:2px 10px;border-radius:999px;font-weight:600}
|
|
.b-x{background:rgba(214,255,61,.14);color:var(--acid)}
|
|
.b-warn{background:rgba(255,199,95,.14);color:var(--warn)}
|
|
.err{color:var(--taken);font-size:13.5px;margin-top:10px}
|
|
.status{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;font-size:13px;color:var(--mut);margin-top:12px}
|
|
.status.ok{border-left:3px solid var(--ok)}
|
|
.status.err{border-left:3px solid var(--taken);color:var(--taken)}
|
|
.hidden{display:none}
|
|
.authbox{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}
|
|
.authbox .card{margin:0}
|
|
.authbox h3{margin:0 0 .3rem;font-size:1rem}
|
|
.authbox p{margin:0;color:var(--mut);font-size:13px}
|
|
.authbox .card > .cta{margin-top:14px}
|
|
.name-row{display:grid;grid-template-columns:1fr auto auto;gap:12px;align-items:center;
|
|
padding:14px 16px;background:var(--panel2);border:1px solid var(--line);border-radius:12px;margin-top:8px}
|
|
.name-row .n{font-family:ui-monospace,monospace;font-size:15px}
|
|
.name-row .rec{color:var(--mut);font-family:ui-monospace,monospace;font-size:12px;grid-column:1/-1;margin-top:6px;word-break:break-all}
|
|
.name-row .rec .k{color:var(--dim)}
|
|
footer{border-top:1px solid var(--line);padding:2rem 0 3rem;color:var(--dim);font-size:13px;text-align:center;margin-top:2.5rem}
|
|
@media (max-width:520px){
|
|
.name-row{grid-template-columns:1fr auto}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<nav class="topnav">
|
|
<a class="brand" href="./">⭐ <span class="g">sirius.x</span></a>
|
|
<a href="./#try">Try it</a>
|
|
<a href="./docs/">📚 Docs</a>
|
|
<a href="./theseus/">🧭 Theseus</a>
|
|
<a href="https://silentmode.st/" rel="noopener">Silent Mode ↗</a>
|
|
<a href="./portal.html" class="portal here">🔑 Sign in</a>
|
|
</nav>
|
|
|
|
<header class="hero">
|
|
<div class="mark">🔑</div>
|
|
<h1>Your <span class="g">names</span> portal</h1>
|
|
<p class="tag">Sign in with your Bitcoin Cash seed phrase or connect an external wallet to see
|
|
every BCNR name your wallet holds. Your keys never leave this browser tab.</p>
|
|
</header>
|
|
|
|
<div class="wrap">
|
|
|
|
<section id="signin">
|
|
<h2>Sign in</h2>
|
|
<p class="muted">Pick one. Nothing is transmitted — resolution is a read-only chain query,
|
|
and signing (for record edits) happens locally in the browser or in your external wallet.</p>
|
|
<div class="authbox" style="margin-top:14px">
|
|
<div class="card">
|
|
<h3>Import a seed phrase</h3>
|
|
<p>Paste your 12- or 24-word BIP-39 recovery phrase. It stays in memory only, wiped when you
|
|
close the tab or sign out.</p>
|
|
<label for="seed">Recovery phrase</label>
|
|
<textarea id="seed" placeholder="word1 word2 … word12" spellcheck="false" autocomplete="off"></textarea>
|
|
<div class="row">
|
|
<button class="btn acid" id="signin-btn">Sign in →</button>
|
|
<span class="dim">Chipnet only for now.</span>
|
|
</div>
|
|
<div id="signin-err" class="err hidden"></div>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Connect an external wallet</h3>
|
|
<p>Use Cashonize 0.9+ or Paytaca via WizardConnect. The wallet signs record edits — the seed
|
|
never touches this page.</p>
|
|
<div class="row">
|
|
<button class="btn ghost" id="wc-btn" disabled>WizardConnect
|
|
<span class="badge b-warn" style="margin-left:6px">soon</span></button>
|
|
</div>
|
|
<p class="dim" style="margin-top:8px">Portal-side WizardConnect wiring is in progress; the
|
|
register.html flow already ships the WizardConnect adapter, and the portal will reuse it.</p>
|
|
</div>
|
|
</div>
|
|
<div class="status" style="margin-top:14px">
|
|
Don't have a wallet? Create one during
|
|
<a href="https://silentmode.st/sirius/register.html">name registration</a> — the recovery phrase
|
|
is generated in your browser and encrypted with your password.
|
|
</div>
|
|
</section>
|
|
|
|
<section id="me" class="hidden">
|
|
<h2>Signed in</h2>
|
|
<div class="card">
|
|
<label>Receive address</label>
|
|
<div><code id="me-addr">—</code></div>
|
|
<label style="margin-top:14px">Token address (holds your name certificates)</label>
|
|
<div><code id="me-taddr">—</code></div>
|
|
<div class="row">
|
|
<button class="btn ghost small" id="signout-btn">Sign out</button>
|
|
<span class="dim" id="me-info">—</span>
|
|
</div>
|
|
</div>
|
|
|
|
<h2 style="margin-top:2rem">Names you hold</h2>
|
|
<div class="status" id="names-status">Loading names from the chain…</div>
|
|
<div id="names-list"></div>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
<footer>
|
|
Your keys, your names, your browser. ·
|
|
<a href="./">back to sirius.x</a> ·
|
|
<a href="https://silentmode.st/sirius/register.html">register a new name</a>
|
|
</footer>
|
|
|
|
<script type="importmap">
|
|
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
|
|
</script>
|
|
|
|
<script type="module">
|
|
// The registrar bundle exports everything we need: BuiltInWallet for local
|
|
// wallet unlock, resolveName/buildIndex for chain queries, getUtxosForAddresses
|
|
// for holdings. This portal is a read-first surface; record edits (UPD) will
|
|
// come next and will piggyback on the same signing path the registrar uses.
|
|
// Cache-buster bumped whenever the bundle changes. Chrome's in-memory ES
|
|
// module map ignores Cache-Control on the .js URL for the lifetime of a tab,
|
|
// so a plain "/js/bns-register.js" import can return a stale instance days
|
|
// after deploy. A query string forces the module loader to treat this as a
|
|
// new URL and fetch fresh bytes.
|
|
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260831b";
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
|
|
|
let wallet = null;
|
|
|
|
// ---------- sign in ----------
|
|
$("signin-btn").addEventListener("click", async () => {
|
|
const btn = $("signin-btn");
|
|
const errBox = $("signin-err");
|
|
errBox.classList.add("hidden");
|
|
const raw = $("seed").value.trim();
|
|
if (!raw) { errBox.textContent = "Enter your seed phrase to sign in."; errBox.classList.remove("hidden"); return; }
|
|
btn.disabled = true; btn.textContent = "Unlocking…";
|
|
try {
|
|
wallet = await BNS.BuiltInWallet.fromMnemonic(raw);
|
|
} catch (e) {
|
|
errBox.textContent = "Could not unlock: " + (e.message || e);
|
|
errBox.classList.remove("hidden");
|
|
btn.disabled = false; btn.textContent = "Sign in →";
|
|
return;
|
|
}
|
|
// Wipe the textarea immediately — the seed is now in the wallet object only.
|
|
$("seed").value = "";
|
|
btn.disabled = false; btn.textContent = "Sign in →";
|
|
await enterPortal();
|
|
});
|
|
|
|
$("signout-btn").addEventListener("click", () => {
|
|
wallet = null;
|
|
$("me").classList.add("hidden");
|
|
$("signin").classList.remove("hidden");
|
|
$("names-list").innerHTML = "";
|
|
$("names-status").textContent = "Loading names from the chain…";
|
|
});
|
|
|
|
async function enterPortal() {
|
|
$("signin").classList.add("hidden");
|
|
$("me").classList.remove("hidden");
|
|
$("me-addr").textContent = wallet.address;
|
|
$("me-taddr").textContent = wallet.tokenAddress;
|
|
$("me-info").textContent = "watched addresses: " + wallet.watchedAddresses.length;
|
|
await loadNames();
|
|
}
|
|
|
|
// ---------- load names ----------
|
|
// Approach: open ONE electrum client (same helper the registrar uses), then
|
|
// (1) fetch the wallet's token UTXOs across all watched addresses and pull out
|
|
// the categories it holds; (2) walk the BCNR index and keep entries whose
|
|
// category matches. Sharing one client keeps roundtrips down and avoids
|
|
// double-triggering server rate limits.
|
|
async function loadNames() {
|
|
const status = $("names-status");
|
|
const list = $("names-list");
|
|
list.innerHTML = "";
|
|
status.className = "status";
|
|
status.textContent = "Connecting to a chipnet electrum…";
|
|
let el = null;
|
|
try {
|
|
el = await BNS.connect();
|
|
status.textContent = "Scanning your addresses for name certificates…";
|
|
|
|
const addresses = wallet.watchedAddresses;
|
|
// getUtxosForAddresses(electrum, addresses) — the first arg is the client,
|
|
// not the address list. Getting that wrong was the "t is not iterable"
|
|
// error in the previous cut of this file.
|
|
const utxos = await BNS.getUtxosForAddresses(el, addresses);
|
|
const ownedCategories = new Set(
|
|
utxos.filter((u) => u.token && u.token.nft).map((u) => u.token.category || u.token.tokenId),
|
|
);
|
|
status.textContent = `Found ${ownedCategories.size} NFT(s) across ${addresses.length} addresses. Cross-checking against the name registry…`;
|
|
|
|
// Reuse the same client for the index build. Passing `client` skips the
|
|
// second connectAny round-trip; resolver-web.js honours it and leaves the
|
|
// client open (we close it ourselves below).
|
|
const index = await BNS.buildIndex({ client: el, WebSocket: window.WebSocket });
|
|
const mine = [];
|
|
for (const entry of index.values()) if (ownedCategories.has(entry.category)) mine.push(entry);
|
|
|
|
if (!mine.length) {
|
|
status.className = "status";
|
|
status.textContent = "No BCNR names found for this wallet. If you just registered, wait a minute for confirmation and reload.";
|
|
return;
|
|
}
|
|
status.className = "status ok";
|
|
status.textContent = `You own ${mine.length} name${mine.length === 1 ? "" : "s"}.`;
|
|
for (const e of mine.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
const div = document.createElement("div");
|
|
div.className = "name-row";
|
|
const records = e.records && Object.keys(e.records).length
|
|
? Object.entries(e.records).map(([k, v]) => `<span class="k">${esc(k)}=</span>${esc(String(v).slice(0, 80))}`).join(" · ")
|
|
: '<span class="k">(no records set)</span>';
|
|
div.innerHTML =
|
|
`<div class="n">${esc(e.name)}</div>` +
|
|
'<span class="badge b-x">on chain</span>' +
|
|
`<a class="btn small ghost" href="https://silentmode.st/bns/${encodeURIComponent(e.name)}/" target="_blank" rel="noopener">view →</a>` +
|
|
`<div class="rec">${records}</div>`;
|
|
list.appendChild(div);
|
|
}
|
|
} catch (e) {
|
|
status.className = "status err";
|
|
status.textContent = "Could not load names from the chain: " + (e.message || e);
|
|
} finally {
|
|
try { el?.close?.(); } catch {}
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|