aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
7 files changed, 302 insertions, 84 deletions
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