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/routes | |
| 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/routes')
| -rw-r--r-- | src/routes/+layout.svelte | 19 | ||||
| -rw-r--r-- | src/routes/+layout.ts | 2 | ||||
| -rw-r--r-- | src/routes/+page.svelte | 342 |
3 files changed, 0 insertions, 363 deletions
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte deleted file mode 100644 index fe115b3..0000000 --- a/src/routes/+layout.svelte +++ /dev/null @@ -1,19 +0,0 @@ -<script lang="ts"> - import '../app.css'; - - let { children } = $props(); -</script> - -<div - class="container mx-auto max-w-3xl space-y-5 rounded-2xl bg-gray-100 p-8 text-xl shadow-lg" - data-theme="light" -> - <div class="flex items-center justify-center gap-2"> - <img src="/bitcoin.svg" /> - <div> - <a href="/" class="mx-auto text-2xl">Adamant</a> - </div> - </div> - - {@render children()} -</div> diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts deleted file mode 100644 index 89da957..0000000 --- a/src/routes/+layout.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const ssr = false; -export const prerender = true; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte deleted file mode 100644 index 4694a63..0000000 --- a/src/routes/+page.svelte +++ /dev/null @@ -1,342 +0,0 @@ -<script> - import { hex } from '@scure/base'; - import { HDKey } from '@scure/bip32'; - import * as btc from '@scure/btc-signer'; - import { goto } from '$app/navigation'; - import { onMount, tick } from 'svelte'; - import { focus } from '$lib'; - import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39'; - import { wordlist } from '@scure/bip39/wordlists/english'; - import Copy from '$lib/Copy.svelte'; - - import 'iconify-icon'; - - import { getAddress, p2wpkh } from '@scure/btc-signer/payment'; - - const pastePassword = async () => { - password = await navigator.clipboard.readText(); - }; - - const pasteSeed = async () => { - mnemonic = await navigator.clipboard.readText(); - }; - - const togglePassword = () => (revealPassword = !revealPassword); - const toggleSeed = () => (revealSeed = !revealSeed); - - let NET = 'bitcoin'; - const versions = { - bitcoin: { - private: 0x0488ade4, - public: 0x0488b21e - }, - regtest: { - private: 0x04358394, - public: 0x043587cf - } - }[NET]; - - const network = { - bitcoin: { - bech32: 'bc', - pubKeyHash: 0x00, - scriptHash: 0x05, - wif: 0x80 - }, - regtest: { - bech32: 'bcrt', - pubKeyHash: 0x6f, - scriptHash: 0xc4, - wif: 0xef - } - }[NET]; - - let fees, rate; - let utxos = []; - - let api = 'https://mempool.space/api'; - let amt = $state(); - // let destination = $state('bc1qqz6nlwxwmfqw0v864p5sggqst2xf0mevtyc579'); - // let password = $state('123'); - // let mnemonic = $state( - // 'lizard armor follow dinner quarter talent truly among column rebuild arrest mix' - // ); - // let withdrawing = $state(true); - let destination = $state(); - let password = $state(''); - let mnemonic = $state(''); - let withdrawing = $state(); - let revealPassword = $state(false); - let revealSeed = $state(true); - - let balance = $state(); - let pending = $state(); - - let loading = $state(); - - let generate = async () => { - mnemonic = await generateMnemonic(wordlist); - }; - - onMount(async () => { - // setTimeout(checkBalance, 1000); - - if (NET === 'regtest') { - fees = { fastestFee: 281, halfHourFee: 271, hourFee: 256, economyFee: 44, minimumFee: 22 }; - } else { - fees = await fetch(`${api}/v1/fees/recommended`).then((r) => r.json()); - } - - rate = fees['halfHourFee']; - }); - - let getHex = async (txid) => { - if (txid instanceof Uint8Array) txid = hex.encode(txid); - return hex.decode(await fetch(`${api}/tx/${txid}/hex`).then((r) => r.text())); - }; - - let node = $derived.by(() => { - if (!mnemonic) return; - try { - const seed = mnemonicToSeedSync(mnemonic.trim(), password.trim()); - const root = HDKey.fromMasterSeed(seed, versions); - return root.derive(`m/84'/0'/0'/0/0`); - } catch (e) {} - }); - - let address = $derived(node ? p2wpkh(node.publicKey).address : undefined); - let txid = $state(); - - let checkBalance = async (e) => { - e?.preventDefault(); - loading = true; - utxos = await fetch(`${api}/address/${address}/utxo`).then((r) => r.json()); - - balance = 0; - pending = 0; - - for (let { value, status } of utxos) { - if (status.confirmed) balance += value; - else pending += value; - } - - loading = false; - }; - - let send = async () => { - let sats = 100000000; - let b = (n) => (Number(n) / sats).toFixed(8); - let inputs = []; - let outputs = []; - let fee, tx; - - let i = 0; - let total = 0n; - let amount = BigInt(amt); - - while (total < amount) { - total += BigInt(utxos[i].value); - i++; - if (i > utxos.length) throw new Error('insufficient funds'); - } - - let change = total - amount; - - tx = new btc.Transaction(); - - for await (let { input, vout, txid } of utxos.slice(0, i)) { - tx.addInput({ - txid, - index: vout, - nonWitnessUtxo: await getHex(txid), - sequence: 0xfffffffd - }); - } - - tx.addOutputAddress(destination, amount, network); - tx.addOutputAddress(address, change, network); - - console.log(tx.unsignedTx.length); - - while (i <= utxos.length) { - fee = BigInt(rate) * BigInt(tx.unsignedTx.length * 2); - - if (fee <= change) { - let q = new btc.Transaction(); - for (let i = 0; i < tx.inputsLength; i++) { - let input = tx.getInput(i); - - let nonWitnessUtxo = await getHex(input.txid); - q.addInput({ ...input, nonWitnessUtxo }); - } - q.addOutput(tx.getOutput(0)); - q.addOutputAddress(address, change - fee, network); - tx = q; - - break; - } else { - if (i === utxos.length) { - if (fee > amount) throw new Error('insufficient funds'); - else { - let q = new btc.Transaction(); - for (let i = 0; i < tx.inputsLength; i++) { - let input = tx.getInput(i); - - let nonWitnessUtxo = await getHex(input.txid); - q.addInput({ ...input, nonWitnessUtxo }); - } - - q.addOutputAddress(destination, total - fee, network); - - tx = q; - break; - } - } - - let { txid, vout } = utxos[++i]; - tx.addInput({ - hash: txid, - index: vout, - nonWitnessUtxo: await getHex(txid) - }); - } - } - - let privkey = node.privateKey; - for (let i = 0; i < tx.inputsLength; i++) { - tx.signIdx(privkey, 0); - let input = tx.getInput(i); - let utxo = input.nonWitnessUtxo.outputs[input.index]; - - inputs = [ - ...inputs, - { - address: btc.Address(network).encode(btc.OutScript.decode(utxo.script)), - amount: b(utxo.amount) - } - ]; - } - - outputs = tx.outputs.map((o) => ({ - address: btc.Address(network).encode(btc.OutScript.decode(o.script)), - amount: b(o.amount) - })); - - tx.finalize(); - - txid = await fetch(`${api}/tx`, { - method: 'POST', - body: tx.hex - }).then((r) => r.text()); - }; -</script> - -<form class="space-y-5" onsubmit={checkBalance}> - <div class="flex flex-wrap gap-2"> - {#if revealSeed} - <textarea - use:focus - class="textarea w-full min-w-[200px] sm:w-auto" - placeholder="Seed phrase" - bind:value={mnemonic} - ></textarea> - {:else} - <input - type="password" - class="input w-full min-w-[200px] grow sm:w-auto" - placeholder="Seed phrase" - bind:value={mnemonic} - /> - {/if} - <div class="flex flex-wrap w-full justify-center sm:w-auto"> - <button type="button" class="btn !w-auto grow" onclick={toggleSeed}> - <iconify-icon icon={revealSeed ? 'ph:eye-bold' : 'ph:eye-slash-bold'} width={32} /> - </button> - <Copy bind:text={mnemonic} /> - <button type="button" class="btn !w-auto grow" onclick={pasteSeed}> - <iconify-icon icon="ph:clipboard-text-bold" width={32} /> - </button> - <button type="button" class="btn !w-auto grow" onclick={generate}> - <iconify-icon icon="ph:dice-five-bold" width={32} /> - </button> - </div> - </div> - - <div class="flex flex-wrap gap-2"> - {#if revealPassword} - <input - class="input w-full min-w-[200px] sm:w-auto" - placeholder="Passphrase" - bind:value={password} - /> - {:else} - <input - type="password" - class="input w-full min-w-[200px] sm:w-auto" - placeholder="Passphrase" - bind:value={password} - /> - {/if} - - <div class="flex w-full justify-center sm:w-auto"> - <button type="button" class="btn !w-auto grow" onclick={togglePassword}> - <iconify-icon icon={revealPassword ? 'ph:eye-bold' : 'ph:eye-slash-bold'} width={32} /> - </button> - - <Copy bind:text={password} /> - - <button type="button" class="btn !w-auto grow" onclick={pastePassword}> - <iconify-icon icon="ph:clipboard-text-bold" width={32} /> - </button> - </div> - </div> - - <div> - {#if address} - <div class="flex flex-wrap items-center gap-2 break-all text-base"> - <div> - {address} - </div> - - <div class="flex w-full justify-center sm:w-auto"> - <Copy bind:text={address} /> - <button type="submit" class="btn !w-auto grow ml-auto"> - <iconify-icon icon="ph:coins-bold" width={32} /> - </button> - </div> - </div> - {/if} - </div> -</form> - -{#if loading} - <div> - <div class="loading loading-spinner"></div> - </div> -{:else if typeof balance !== 'undefined'} - <div class="flex items-center gap-2"> - <div> - Balance: {balance + pending} sats - </div> - {#if balance + pending > 0} - <button type="submit" class="btn ml-auto" onclick={() => (withdrawing = true)}> - Withdraw</button - > - {/if} - </div> -{/if} - -{#if txid} - <div class="flex"> - <div>Transaction completed!</div> - <div class="ml-auto"> - <a href={`https://mempool.space/tx/${txid}`} class="btn">Details</a> - </div> - </div> -{:else if withdrawing} - <input class="input w-full" placeholder="Destination address" bind:value={destination} /> - - <input class="input w-full" placeholder="Amount" bind:value={amt} /> - - <button type="submit" class="btn ml-auto" onclick={send}> Send</button> -{/if} |
