aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes
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/routes
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/routes')
-rw-r--r--src/routes/+layout.svelte19
-rw-r--r--src/routes/+layout.ts2
-rw-r--r--src/routes/+page.svelte342
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}