166 lines
7 KiB
JavaScript
166 lines
7 KiB
JavaScript
|
|
// BNS wallet: a wallet app with an embedded .bch resolver and browser pane.
|
|||
|
|
// This is the "resolver inside the wallet" architecture: the wallet holds keys,
|
|||
|
|
// resolves names straight from the BCH chain, and renders the result in-app.
|
|||
|
|
import express from "express";
|
|||
|
|
import { loadWallet } from "./lib/wallet.js";
|
|||
|
|
import { buildIndex, resolveName, registerName, updateName, normalizeName } from "./lib/bns.js";
|
|||
|
|
import { APP_PORT, TLD } from "./config.js";
|
|||
|
|
|
|||
|
|
const wallet = await loadWallet();
|
|||
|
|
const app = express();
|
|||
|
|
app.use(express.json());
|
|||
|
|
|
|||
|
|
// tiny index cache so browsing doesn't re-scan the beacon on every keystroke
|
|||
|
|
let cache = { at: 0, index: new Map() };
|
|||
|
|
async function index() {
|
|||
|
|
if (Date.now() - cache.at > 15_000) {
|
|||
|
|
cache = { at: Date.now(), index: await buildIndex(wallet.provider) };
|
|||
|
|
}
|
|||
|
|
return cache.index;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
app.get("/api/status", async (_req, res) => {
|
|||
|
|
res.json({
|
|||
|
|
cashaddr: wallet.cashaddr,
|
|||
|
|
tokenaddr: wallet.tokenaddr,
|
|||
|
|
balanceSat: String(await wallet.getBalance("sat")),
|
|||
|
|
names: [...(await index()).values()].map((e) => ({ ...e })),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get("/api/resolve/:name", async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const entry = (await index()).get(normalizeName(req.params.name));
|
|||
|
|
if (!entry) return res.status(404).json({ error: "NXDOMAIN" });
|
|||
|
|
res.json(entry);
|
|||
|
|
} catch (e) {
|
|||
|
|
res.status(400).json({ error: e.message });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post("/api/register", async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { name, records } = req.body;
|
|||
|
|
cache.at = 0;
|
|||
|
|
res.json(await registerName(wallet, name, records));
|
|||
|
|
} catch (e) {
|
|||
|
|
res.status(400).json({ error: e.message });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post("/api/update", async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { name, records } = req.body;
|
|||
|
|
cache.at = 0;
|
|||
|
|
res.json(await updateName(wallet, name, records));
|
|||
|
|
} catch (e) {
|
|||
|
|
res.status(400).json({ error: e.message });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// The "browser" endpoint: renders what a .bch name points to.
|
|||
|
|
app.get("/view/:name", async (req, res) => {
|
|||
|
|
let entry;
|
|||
|
|
try {
|
|||
|
|
entry = (await index()).get(normalizeName(req.params.name));
|
|||
|
|
} catch (e) {
|
|||
|
|
return res.status(400).send(`<pre>${e.message}</pre>`);
|
|||
|
|
}
|
|||
|
|
if (!entry) {
|
|||
|
|
return res.status(404).send(
|
|||
|
|
`<div style="font-family:system-ui;padding:2rem"><h2>NXDOMAIN</h2>
|
|||
|
|
<p><code>${req.params.name}</code> is not registered on the chain.</p></div>`);
|
|||
|
|
}
|
|||
|
|
if (entry.records.h) return res.send(entry.records.h); // on-chain website
|
|||
|
|
if (entry.records.u) return res.redirect(entry.records.u);
|
|||
|
|
res.send(`<pre style="padding:2rem">${JSON.stringify(entry, null, 2)}</pre>`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get("/", (_req, res) => {
|
|||
|
|
res.send(UI);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const UI = /* html */ `<!doctype html>
|
|||
|
|
<meta charset="utf8">
|
|||
|
|
<title>BNS Wallet — .bch browser</title>
|
|||
|
|
<style>
|
|||
|
|
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
|||
|
|
body { margin: 0; display: grid; grid-template-columns: 320px 1fr; height: 100vh; }
|
|||
|
|
aside { border-right: 1px solid #8884; padding: 14px; overflow-y: auto; }
|
|||
|
|
main { display: flex; flex-direction: column; }
|
|||
|
|
h1 { font-size: 1.05rem; margin: 0 0 10px; }
|
|||
|
|
h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; opacity: .6; margin: 18px 0 6px; }
|
|||
|
|
code, .addr { font-family: ui-monospace, monospace; font-size: .72rem; word-break: break-all; }
|
|||
|
|
.bar { display: flex; gap: 8px; padding: 10px; border-bottom: 1px solid #8884; }
|
|||
|
|
.bar input { flex: 1; padding: 8px 12px; border-radius: 999px; border: 1px solid #8886; font-size: .95rem; }
|
|||
|
|
button { padding: 7px 14px; border-radius: 8px; border: 1px solid #8886; cursor: pointer; background: #4b7bec; color: #fff; }
|
|||
|
|
button.ghost { background: transparent; color: inherit; }
|
|||
|
|
iframe { flex: 1; border: 0; width: 100%; }
|
|||
|
|
.name { padding: 6px 8px; border: 1px solid #8884; border-radius: 8px; margin: 4px 0; cursor: pointer; }
|
|||
|
|
.name:hover { background: #4b7bec22; }
|
|||
|
|
input, textarea { width: 100%; box-sizing: border-box; margin: 3px 0; padding: 6px; border-radius: 6px; border: 1px solid #8886; background: transparent; color: inherit; }
|
|||
|
|
#status { font-size: .8rem; opacity: .75; padding: 4px 12px; border-bottom: 1px solid #8884; min-height: 1.2em; }
|
|||
|
|
</style>
|
|||
|
|
<aside>
|
|||
|
|
<h1>⛓️ BNS Wallet</h1>
|
|||
|
|
<h2>Wallet</h2>
|
|||
|
|
<div class="addr" id="addr">…</div>
|
|||
|
|
<div>Balance: <b id="bal">…</b> sat</div>
|
|||
|
|
<h2>Registered names (on-chain)</h2>
|
|||
|
|
<div id="names">loading…</div>
|
|||
|
|
<h2>Register a name</h2>
|
|||
|
|
<input id="rname" placeholder="name (a-z, 0-9, -)">
|
|||
|
|
<input id="rurl" placeholder="https:// URL (optional)">
|
|||
|
|
<textarea id="rhtml" rows="3" placeholder="…or inline HTML stored on-chain"></textarea>
|
|||
|
|
<button onclick="registerName()">Register on BCH</button>
|
|||
|
|
</aside>
|
|||
|
|
<main>
|
|||
|
|
<div class="bar">
|
|||
|
|
<button class="ghost" onclick="go()">⟳</button>
|
|||
|
|
<input id="url" placeholder="name${TLD} — resolved from the BCH chain, not DNS" onkeydown="if(event.key==='Enter')go()">
|
|||
|
|
<button onclick="go()">Go</button>
|
|||
|
|
</div>
|
|||
|
|
<div id="status"></div>
|
|||
|
|
<iframe id="page" src="about:blank"></iframe>
|
|||
|
|
</main>
|
|||
|
|
<script>
|
|||
|
|
const $ = (id) => document.getElementById(id);
|
|||
|
|
async function refresh() {
|
|||
|
|
const s = await (await fetch("/api/status")).json();
|
|||
|
|
$("addr").textContent = s.cashaddr;
|
|||
|
|
$("bal").textContent = s.balanceSat;
|
|||
|
|
$("names").innerHTML = s.names.length
|
|||
|
|
? s.names.map(n => \`<div class="name" onclick="open2('\${n.name}')"><b>\${n.name}${TLD}</b><br><code>\${JSON.stringify(n.records)}</code></div>\`).join("")
|
|||
|
|
: "<i>none yet</i>";
|
|||
|
|
}
|
|||
|
|
function open2(name) { $("url").value = name + "${TLD}"; go(); }
|
|||
|
|
async function go() {
|
|||
|
|
const q = $("url").value.trim(); if (!q) return;
|
|||
|
|
$("status").textContent = "resolving " + q + " via BCH chain…";
|
|||
|
|
const r = await fetch("/api/resolve/" + encodeURIComponent(q));
|
|||
|
|
if (!r.ok) { $("status").textContent = q + " → NXDOMAIN (not on chain)"; $("page").src = "/view/" + encodeURIComponent(q); return; }
|
|||
|
|
const e = await r.json();
|
|||
|
|
$("status").textContent = q + " → " + (e.records.u ?? (e.records.h ? "on-chain HTML, " + e.records.h.length + " bytes" : JSON.stringify(e.records)))
|
|||
|
|
+ " [certificate category " + e.category.slice(0, 16) + "…, reg tx " + e.txid.slice(0, 16) + "…]";
|
|||
|
|
$("page").src = "/view/" + encodeURIComponent(q);
|
|||
|
|
}
|
|||
|
|
async function registerName() {
|
|||
|
|
const records = {};
|
|||
|
|
if ($("rurl").value.trim()) records.u = $("rurl").value.trim();
|
|||
|
|
if ($("rhtml").value.trim()) records.h = $("rhtml").value.trim();
|
|||
|
|
if (!Object.keys(records).length) { alert("give a URL or inline HTML"); return; }
|
|||
|
|
$("status").textContent = "broadcasting registration…";
|
|||
|
|
const r = await fetch("/api/register", { method: "POST", headers: { "content-type": "application/json" },
|
|||
|
|
body: JSON.stringify({ name: $("rname").value.trim(), records }) });
|
|||
|
|
const j = await r.json();
|
|||
|
|
$("status").textContent = r.ok ? "registered! txid " + j.txid : "error: " + j.error;
|
|||
|
|
refresh();
|
|||
|
|
}
|
|||
|
|
refresh(); setInterval(refresh, 20000);
|
|||
|
|
</script>`;
|
|||
|
|
|
|||
|
|
app.listen(APP_PORT, () => {
|
|||
|
|
console.log(`BNS wallet+browser: http://localhost:${APP_PORT}`);
|
|||
|
|
console.log(`wallet: ${wallet.cashaddr}`);
|
|||
|
|
});
|