diff options
| author | Adam Soltys <[email protected]> | 2026-06-14 23:01:03 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-14 23:01:03 -0700 |
| commit | c5443946031ec32a7b3008fa751eb7916654a9f6 (patch) | |
| tree | 372e8d0ccaa2dec58a6bcaaa19d3902b80eb4396 /src/api.js | |
| parent | 683927e4132651b9198e5421fae16e9c60978fbc (diff) | |
| download | coinosv3-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.js | 104 |
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; +} |
