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 | |
| parent | 3e4ee5782a530b1e3bfa8358d6fcd2a5feeac99b (diff) | |
| download | coinosv3-9fe20ee5ef5528364d0a497e4e50e7a6f69142c5.tar.gz coinosv3-9fe20ee5ef5528364d0a497e4e50e7a6f69142c5.zip | |
basic wallet
| -rwxr-xr-x | bun.lockb | bin | 60922 -> 66510 bytes | |||
| -rw-r--r-- | package.json | 8 | ||||
| -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 | ||||
| -rw-r--r-- | static/bg.png | bin | 0 -> 182111 bytes | |||
| -rw-r--r-- | static/bg.webp | bin | 0 -> 27804 bytes | |||
| -rw-r--r-- | static/bitcoin.svg | 7 | ||||
| -rw-r--r-- | static/type.svg | 19 |
13 files changed, 316 insertions, 104 deletions
| 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 === "-" ? "—" : 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 Binary files differnew file mode 100644 index 0000000..f542416 --- /dev/null +++ b/static/bg.png diff --git a/static/bg.webp b/static/bg.webp Binary files differnew file mode 100644 index 0000000..290cb41 --- /dev/null +++ b/static/bg.webp 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 |
