aboutsummaryrefslogtreecommitdiffstats
path: root/src/api.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-14 23:01:03 -0700
committerAdam Soltys <[email protected]>2026-06-14 23:01:03 -0700
commitc5443946031ec32a7b3008fa751eb7916654a9f6 (patch)
tree372e8d0ccaa2dec58a6bcaaa19d3902b80eb4396 /src/api.js
parent683927e4132651b9198e5421fae16e9c60978fbc (diff)
downloadcoinosv3-c5443946031ec32a7b3008fa751eb7916654a9f6.tar.gz
coinosv3-c5443946031ec32a7b3008fa751eb7916654a9f6.zip
Rebuild as single-file offline BIP84 wallet
Replace the SvelteKit/daisyUI prototype with a vanilla JS + hand-rolled CSS app that Bun bundles (code + scure/noble crypto + QR + CSS) into one self-contained dist/index.html runnable offline from file://. Wallet core (src/wallet.js): - BIP84 native-segwit HD wallet from BIP39 seed (+optional passphrase) - gap-limit scan of receive/change chains, aggregate balance, tx history - fresh receive/change addresses; signs with witnessUtxo only (enables offline signing with no prev-tx fetch) - coin selection + fee estimation via btc-signer selectUTXO; send-max; manual coin control - offline JSON snapshot export/import (keyless) + signed-tx hex/QR export - realtime mempool.space WebSocket: track-addresses -> debounced auto refresh of balances/history (no polling); live status badge UI (src/app.js): clean light paper-wallet; create (with optional, skippable backup verification) / import flow; receive (QR) / send / history / coins / tools tabs; clickable logo returns home. API (src/api.js): mempool.space wrapper with retry/backoff for scan-burst rate limiting; single network choke-point for offline enforcement. Verified: BIP84 spec vectors, build/sign/parse for auto/max/manual flows, snapshot roundtrip, live scan + WebSocket, headless-Chrome UI smoke tests. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src/api.js')
-rw-r--r--src/api.js104
1 files changed, 104 insertions, 0 deletions
diff --git a/src/api.js b/src/api.js
new file mode 100644
index 0000000..c99ed40
--- /dev/null
+++ b/src/api.js
@@ -0,0 +1,104 @@
+// Thin wrapper around the mempool.space REST API.
+//
+// Every network call goes through here so that "offline mode" can be enforced
+// in exactly one place: when offline, any call throws and the UI relies on an
+// imported snapshot instead.
+
+const BASES = {
+ mainnet: 'https://mempool.space/api',
+ testnet: 'https://mempool.space/testnet/api',
+};
+
+export class Api {
+ constructor(net = 'mainnet') {
+ this.net = net;
+ this.offline = false;
+ }
+
+ base() {
+ return BASES[this.net] || BASES.mainnet;
+ }
+
+ explorerTx(txid) {
+ const path = this.net === 'testnet' ? 'testnet/tx' : 'tx';
+ return `https://mempool.space/${path}/${txid}`;
+ }
+
+ async #get(path, asText = false) {
+ if (this.offline) throw new Error('offline');
+ // Retry transient failures (mempool.space rate-limits bursts of requests
+ // during a scan, which can surface as network/CORS errors from file://).
+ let lastErr;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ if (attempt) await sleep(400 * attempt + 200);
+ try {
+ const res = await fetch(this.base() + path);
+ if (res.status === 429) throw new Error('rate limited');
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${path}`);
+ return asText ? res.text() : res.json();
+ } catch (e) {
+ lastErr = e;
+ }
+ }
+ throw lastErr;
+ }
+
+ // chain_stats / mempool_stats let us decide whether an address has ever been
+ // used without downloading its full history.
+ addressInfo(address) {
+ return this.#get(`/address/${address}`);
+ }
+
+ addressUtxos(address) {
+ return this.#get(`/address/${address}/utxo`);
+ }
+
+ // Returns up to ~50 of the most recent transactions touching the address
+ // (mempool first, then confirmed).
+ addressTxs(address) {
+ return this.#get(`/address/${address}/txs`);
+ }
+
+ async feeRates() {
+ try {
+ return await this.#get('/v1/fees/recommended');
+ } catch {
+ return { fastestFee: 20, halfHourFee: 10, hourFee: 5, economyFee: 2, minimumFee: 1 };
+ }
+ }
+
+ async price() {
+ try {
+ const p = await this.#get('/v1/prices');
+ return p && p.USD ? p.USD : null;
+ } catch {
+ return null;
+ }
+ }
+
+ async broadcast(hexTx) {
+ if (this.offline) throw new Error('offline');
+ const res = await fetch(this.base() + '/tx', { method: 'POST', body: hexTx });
+ const body = await res.text();
+ if (!res.ok) throw new Error(body || `${res.status} broadcast failed`);
+ return body.trim(); // txid
+ }
+}
+
+function sleep(ms) {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+// Run async jobs with a small concurrency cap so we stay friendly to the API.
+export async function pool(items, worker, concurrency = 4) {
+ const results = new Array(items.length);
+ let next = 0;
+ async function run() {
+ while (next < items.length) {
+ const i = next++;
+ results[i] = await worker(items[i], i);
+ }
+ }
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, run));
+ return results;
+}