aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2025-07-15 12:51:33 -0700
committerAdam Soltys <[email protected]>2025-07-15 12:51:33 -0700
commit9fe20ee5ef5528364d0a497e4e50e7a6f69142c5 (patch)
treef890529da632b3fdd59b82d028ae23b37d50929d
parent3e4ee5782a530b1e3bfa8358d6fcd2a5feeac99b (diff)
downloadcoinosv3-9fe20ee5ef5528364d0a497e4e50e7a6f69142c5.tar.gz
coinosv3-9fe20ee5ef5528364d0a497e4e50e7a6f69142c5.zip
basic wallet
-rwxr-xr-xbun.lockbbin60922 -> 66510 bytes
-rw-r--r--package.json8
-rw-r--r--src/app.css22
-rw-r--r--src/lib/Mnemonic.svelte16
-rw-r--r--src/lib/index.ts4
-rw-r--r--src/routes/+layout.svelte21
-rw-r--r--src/routes/+page.svelte279
-rw-r--r--src/routes/create/+page.svelte44
-rw-r--r--src/routes/created/+page.svelte0
-rw-r--r--static/bg.pngbin0 -> 182111 bytes
-rw-r--r--static/bg.webpbin0 -> 27804 bytes
-rw-r--r--static/bitcoin.svg7
-rw-r--r--static/type.svg19
13 files changed, 316 insertions, 104 deletions
diff --git a/bun.lockb b/bun.lockb
index 1e10611..c6687a9 100755
--- a/bun.lockb
+++ b/bun.lockb
Binary files differ
diff --git a/package.json b/package.json
index 0179457..b8b1aea 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,8 @@
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
+ "@tailwindcss/forms": "^0.5.9",
+ "@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
@@ -28,6 +30,10 @@
"vite": "^7.0.4"
},
"dependencies": {
- "@scure/bip39": "^1.6.0"
+ "@scure/base": "^1.2.6",
+ "@scure/bip32": "^1.7.0",
+ "@scure/bip39": "^1.6.0",
+ "@scure/btc-signer": "^1.8.1",
+ "daisyui": "^5.0.46"
}
}
diff --git a/src/app.css b/src/app.css
index d4b5078..cafcee5 100644
--- a/src/app.css
+++ b/src/app.css
@@ -1 +1,23 @@
@import 'tailwindcss';
+@plugin '@tailwindcss/forms';
+@plugin '@tailwindcss/typography';
+@plugin "daisyui";
+
+html { height: 100%; }
+html, body {
+ margin: 0;
+ padding: 0;
+ overflow-x: hidden;
+}
+
+body {
+ padding-top: 40px;
+}
+
+html {
+ background-image: url('/bg.webp');
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
+}
+
diff --git a/src/lib/Mnemonic.svelte b/src/lib/Mnemonic.svelte
deleted file mode 100644
index 117c98f..0000000
--- a/src/lib/Mnemonic.svelte
+++ /dev/null
@@ -1,16 +0,0 @@
-<script>
- let { mnemonic } = $props();
-</script>
-
-<div class="card">
- <div
- class="card-body text-secondary grid grid-flow-col grid-rows-6 sm:grid-rows-none grid-cols-2 sm:grid-cols-3 sm:grid-flow-row gap-x-8 gap-y-2 justify-center whitespace-nowrap m-auto"
- >
- {#each mnemonic.split(" ") as w, i}
- <div>
- <span class="select-none text-neutral">{i + 1}.</span>
- <span>{@html w === "-" ? "&mdash;" : w}</span>
- </div>
- {/each}
- </div>
-</div>
diff --git a/src/lib/index.ts b/src/lib/index.ts
index 02f71ba..c05c77e 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -1,7 +1,9 @@
-import { writable } from 'svelte/store';
import { browser } from "$app/environment";
+import { writable } from "svelte/store";
export const focus = (el: HTMLElement) =>
browser && screen.width > 1280 && setTimeout(() => el.focus(), 1);
export const mnemonic = writable();
+export const password = writable();
+export const address = writable();
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 1729999..ceb238b 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -4,14 +4,15 @@
let { children } = $props();
</script>
-<div class="container mx-auto mt-10 space-y-5 rounded-2xl bg-gray-100 p-8 text-xl shadow-lg">
- <div class="flex">
- <a href="/" class="mx-auto text-2xl">Bitcoin Wallet</a>
- </div>
-
- {@render children()}
-</div>
+ <div
+ class="container mx-auto max-w-3xl space-y-5 rounded-2xl bg-gray-100 p-8 text-xl shadow-lg"
+ >
+ <div class="flex items-center justify-center gap-2">
+ <img src="/bitcoin.svg" />
+ <div>
+ <a href="/" class="mx-auto text-2xl">Bitcoin Wallet</a>
+ </div>
+ </div>
-<div class="my-4 flex">
- <a href="https://github.com/asoltys/paper" class="mx-auto">Source code</a>
-</div>
+ {@render children()}
+ </div>
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 44bcb6a..db8986d 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,14 +1,267 @@
-<div class="flex flex-wrap justify-center gap-2">
- <a
- href="/create"
- class="flex w-full justify-center gap-2 rounded-2xl border bg-white p-4 md:w-60"
- >
- <img src="/random.svg" class="w-8" />
- <div class="my-auto">Generate</div>
- </a>
+<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';
- <a href="/type" class="flex w-full justify-center gap-2 rounded-2xl border bg-white p-4 md:w-60">
- <img src="/type.svg" class="w-8" />
- <div class="my-auto">Import</div>
- </a>
-</div>
+ import { getAddress, p2wpkh } from '@scure/btc-signer/payment';
+
+ 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(800);
+ // 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 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;
+ const seed = mnemonicToSeedSync(mnemonic, password);
+ const root = HDKey.fromMasterSeed(seed, versions);
+ return root.derive(`m/84'/0'/0'/0/0`);
+ });
+
+ 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 items-center gap-2">
+ <textarea use:focus class="textarea grow" placeholder="Seed phrase" bind:value={mnemonic}
+ ></textarea>
+ <button type="button" class="btn flex gap-1" onclick={generate}>
+ <img src="/random.svg" class="w-8" />
+ <div>Generate</div>
+ </button>
+ </div>
+
+ <input class="input w-full" placeholder="Passphrase" bind:value={password} />
+
+ {#if address}
+ <div class="flex items-center gap-2">
+ <div>
+ Address: {address}
+ </div>
+
+ <button type="submit" class="btn ml-auto"> Check balance </button>
+ </div>
+ {/if}
+</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} sats
+ {#if pending}
+ <span class="text-orange-600">({pending} pending)</span>
+ {/if}
+ </div>
+ {#if balance + pending > 0}
+ <button type="submit" class="btn ml-auto" onclick={() => (withdrawing = true)}>
+ Withdraw</button
+ >
+ {/if}
+ </div>
+{/if}
+
+{#if txid}
+ <a href={`https://mempool.space/tx/${txid}`}>{txid}</a>
+{: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}
diff --git a/src/routes/create/+page.svelte b/src/routes/create/+page.svelte
deleted file mode 100644
index 1d09990..0000000
--- a/src/routes/create/+page.svelte
+++ /dev/null
@@ -1,44 +0,0 @@
-<script>
- import { goto } from '$app/navigation';
- import { onMount, tick } from 'svelte';
- import { mnemonic, focus } from '$lib';
- import { generateMnemonic } from '@scure/bip39';
- import { wordlist } from '@scure/bip39/wordlists/english';
- import Mnemonic from '$lib/Mnemonic.svelte';
-
- let password;
-
- let submitted;
- let bm;
- onMount(async () => {
- $mnemonic = await generateMnemonic(wordlist);
- });
-
- let submit = async () => {
- submitted = true;
- await tick();
-
- goto('/created');
- };
-</script>
-
-<Mnemonic mnemonic={$mnemonic} />
-
-<form class="space-y-5 text-center" on:submit|preventDefault={submit}>
- <div>
- <input
- use:focus
- name="password"
- class="rounded-2xl bg-white p-4 text-2xl"
- placeholder="Passphrase"
- bind:value={password}
- />
- </div>
-
- <button
- type="submit"
- class="mx-auto flex w-full justify-center gap-2 rounded-2xl border bg-white p-4 md:w-60"
- >
- <div class="my-auto">Submit</div>
- </button>
-</form>
diff --git a/src/routes/created/+page.svelte b/src/routes/created/+page.svelte
deleted file mode 100644
index e69de29..0000000
--- a/src/routes/created/+page.svelte
+++ /dev/null
diff --git a/static/bg.png b/static/bg.png
new file mode 100644
index 0000000..f542416
--- /dev/null
+++ b/static/bg.png
Binary files differ
diff --git a/static/bg.webp b/static/bg.webp
new file mode 100644
index 0000000..290cb41
--- /dev/null
+++ b/static/bg.webp
Binary files differ
diff --git a/static/bitcoin.svg b/static/bitcoin.svg
new file mode 100644
index 0000000..04079be
--- /dev/null
+++ b/static/bitcoin.svg
@@ -0,0 +1,7 @@
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="64" width="64" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
+<g transform="translate(0.00630876,-0.00301984)">
+<path fill="#f7931a" d="m63.033,39.744c-4.274,17.143-21.637,27.576-38.782,23.301-17.138-4.274-27.571-21.638-23.295-38.78,4.272-17.145,21.635-27.579,38.775-23.305,17.144,4.274,27.576,21.64,23.302,38.784z"/>
+<path fill="#FFF" d="m46.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"/>
+</g>
+</svg>
diff --git a/static/type.svg b/static/type.svg
deleted file mode 100644
index 6568c5b..0000000
--- a/static/type.svg
+++ /dev/null
@@ -1,19 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
-<svg version="1.1" id="Uploaded to svgrepo.com" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
- width="800px" height="800px" viewBox="0 0 32 32" xml:space="preserve">
-<style type="text/css">
- .puchipuchi_een{fill:#111918;}
-</style>
-<path class="puchipuchi_een" d="M30,7H2C0.9,7,0,7.9,0,9v15c0,1.1,0.9,2,2,2h28c1.1,0,2-0.9,2-2V9C32,7.9,31.1,7,30,7z M18.5,10
- c0.828,0,1.5,0.672,1.5,1.5S19.328,13,18.5,13S17,12.328,17,11.5S17.672,10,18.5,10z M18.5,15c0.828,0,1.5,0.672,1.5,1.5
- S19.328,18,18.5,18S17,17.328,17,16.5S17.672,15,18.5,15z M13.5,10c0.828,0,1.5,0.672,1.5,1.5S14.328,13,13.5,13S12,12.328,12,11.5
- S12.672,10,13.5,10z M13.5,15c0.828,0,1.5,0.672,1.5,1.5S14.328,18,13.5,18S12,17.328,12,16.5S12.672,15,13.5,15z M8.5,10
- c0.828,0,1.5,0.672,1.5,1.5S9.328,13,8.5,13S7,12.328,7,11.5S7.672,10,8.5,10z M8.5,15c0.828,0,1.5,0.672,1.5,1.5S9.328,18,8.5,18
- S7,17.328,7,16.5S7.672,15,8.5,15z M3.5,23C2.672,23,2,22.328,2,21.5S2.672,20,3.5,20S5,20.672,5,21.5S4.328,23,3.5,23z M3.5,18
- C2.672,18,2,17.328,2,16.5S2.672,15,3.5,15S5,15.672,5,16.5S4.328,18,3.5,18z M3.5,13C2.672,13,2,12.328,2,11.5S2.672,10,3.5,10
- S5,10.672,5,11.5S4.328,13,3.5,13z M23.5,23h-15C7.671,23,7,22.329,7,21.5S7.671,20,8.5,20h15c0.829,0,1.5,0.671,1.5,1.5
- S24.329,23,23.5,23z M23.5,18c-0.828,0-1.5-0.672-1.5-1.5s0.672-1.5,1.5-1.5s1.5,0.672,1.5,1.5S24.328,18,23.5,18z M23.5,13
- c-0.828,0-1.5-0.672-1.5-1.5s0.672-1.5,1.5-1.5s1.5,0.672,1.5,1.5S24.328,13,23.5,13z M28.5,23c-0.828,0-1.5-0.672-1.5-1.5
- s0.672-1.5,1.5-1.5s1.5,0.672,1.5,1.5S29.328,23,28.5,23z M28.5,18c-0.828,0-1.5-0.672-1.5-1.5s0.672-1.5,1.5-1.5s1.5,0.672,1.5,1.5
- S29.328,18,28.5,18z M28.5,13c-0.828,0-1.5-0.672-1.5-1.5s0.672-1.5,1.5-1.5s1.5,0.672,1.5,1.5S29.328,13,28.5,13z"/>
-</svg> \ No newline at end of file