diff options
| author | Adam Soltys <[email protected]> | 2025-07-15 12:51:33 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2025-07-15 12:51:33 -0700 |
| commit | 9fe20ee5ef5528364d0a497e4e50e7a6f69142c5 (patch) | |
| tree | f890529da632b3fdd59b82d028ae23b37d50929d /src | |
| parent | 3e4ee5782a530b1e3bfa8358d6fcd2a5feeac99b (diff) | |
| download | coinosv3-9fe20ee5ef5528364d0a497e4e50e7a6f69142c5.tar.gz coinosv3-9fe20ee5ef5528364d0a497e4e50e7a6f69142c5.zip | |
basic wallet
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.css | 22 | ||||
| -rw-r--r-- | src/lib/Mnemonic.svelte | 16 | ||||
| -rw-r--r-- | src/lib/index.ts | 4 | ||||
| -rw-r--r-- | src/routes/+layout.svelte | 21 | ||||
| -rw-r--r-- | src/routes/+page.svelte | 279 | ||||
| -rw-r--r-- | src/routes/create/+page.svelte | 44 | ||||
| -rw-r--r-- | src/routes/created/+page.svelte | 0 |
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 === "-" ? "—" : 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 |
