diff options
| author | Adam Soltys <[email protected]> | 2025-11-26 14:53:20 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-11 15:33:08 -0800 |
| commit | 68e7fe9655f94efc573f3aab7acf60d03c4162a0 (patch) | |
| tree | 48f35ea51823ac1cd0d74afc453628f5e128bf71 /src | |
| parent | 5f1c31b6618f0b895fb98c8f2a841989625eaf16 (diff) | |
| download | coinos-ui-68e7fe9655f94efc573f3aab7acf60d03c4162a0.tar.gz coinos-ui-68e7fe9655f94efc573f3aab7acf60d03c4162a0.zip | |
ark balance
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/ark.ts | 116 | ||||
| -rw-r--r-- | src/routes/(app)/+layout.svelte | 49 | ||||
| -rw-r--r-- | src/routes/(app)/ark/+page.server.ts | 11 | ||||
| -rw-r--r-- | src/routes/(app)/ark/+page.svelte | 115 | ||||
| -rw-r--r-- | src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.svelte | 66 |
5 files changed, 270 insertions, 87 deletions
diff --git a/src/lib/ark.ts b/src/lib/ark.ts index 2dec57d9..7649a6bf 100644 --- a/src/lib/ark.ts +++ b/src/lib/ark.ts @@ -1,20 +1,116 @@ import { persistLocal } from "$lib/store"; +import { get } from "svelte/store"; import { SingleKey, Wallet } from "@arkade-os/sdk"; +import { bytesToHex, randomBytes } from "@noble/hashes/utils"; +import { nip19 } from "nostr-tools"; +import { hex } from "@scure/base"; -const prefix = "ark"; -export const mnemonic = persistLocal(`${prefix}mnemonic`, ""); -export const password = persistLocal(`${prefix}password`, ""); -export const arkkey = persistLocal(`${prefix}key`, ""); +export const arkServerUrl = "http://localhost:7070"; +export const arkkey = persistLocal("arkkey", ""); -export const getWallet = async () => { - // const identity = SingleKey.fromHex(get(arkkey)); +export const generateKey = (): string => { + const privateKey = bytesToHex(randomBytes(32)); + arkkey.set(privateKey); + return privateKey; +}; - const identity = SingleKey.fromHex( - "ce66c68f8875c0c98a502c666303dc183a21600130013c06f9d1edf60207abf2", - ); +export const getKey = (): string => { + let key = get(arkkey); + if (!key) { + key = generateKey(); + } + return key; +}; + +export const hasKey = (): boolean => { + return !!get(arkkey); +}; + +export const clearKey = (): void => { + arkkey.set(""); +}; + +export const keyToNsec = (key?: string): string => { + const k = key || getKey(); + return nip19.nsecEncode(hex.decode(k)); +}; + +export const nsecToKey = (nsec: string): string => { + const { type, data } = nip19.decode(nsec); + if (type !== "nsec") throw new Error("Invalid nsec format"); + return bytesToHex(data); +}; + +export const restoreFromNsec = (nsec: string): string => { + const key = nsecToKey(nsec); + arkkey.set(key); + return key; +}; + +export const getWallet = async () => { + const key = getKey(); + const identity = SingleKey.fromHex(key); return Wallet.create({ identity, - arkServerUrl: "https://arkade.computer", + arkServerUrl, }); }; + +export const getAddress = async (): Promise<string> => { + const wallet = await getWallet(); + return wallet.getAddress(); +}; + +export const getBalance = async (): Promise<{ available: number; pending: number }> => { + const wallet = await getWallet(); + return wallet.getBalance(); +}; + +export const syncArkAddress = async ( + currentAddress: string | undefined, + updateFn: (arkAddress: string) => Promise<void> +): Promise<string> => { + const address = await getAddress(); + if (address !== currentAddress) { + await updateFn(address); + } + return address; +}; + +export const sendArk = async (address: string, amount: number): Promise<string> => { + const wallet = await getWallet(); + const vtxos = await wallet.getVtxos(); + + // Sort by expiry (spend oldest first) + const sorted = vtxos.sort( + (a, b) => (a.virtualStatus.batchExpiry ?? 0) - (b.virtualStatus.batchExpiry ?? 0) + ); + + // Select inputs + const inputs = []; + let selectedAmount = 0; + for (const vtxo of sorted) { + if (selectedAmount >= amount) break; + inputs.push(vtxo); + selectedAmount += vtxo.value; + } + + if (selectedAmount < amount) { + throw new Error(`Insufficient funds: have ${selectedAmount}, need ${amount}`); + } + + // Build outputs + const outputs: { address: string; amount: bigint }[] = [ + { address, amount: BigInt(amount) } + ]; + + // Add change output if needed + const change = selectedAmount - amount; + if (change > 0) { + const changeAddress = await wallet.getAddress(); + outputs.push({ address: changeAddress, amount: BigInt(change) }); + } + + return wallet.settle({ inputs, outputs }); +}; diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index cf29ad7c..ed76e103 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -1,5 +1,5 @@ <script> - import { getWallet } from "$lib/ark"; + import { getWallet, getAddress } from "$lib/ark"; import { SvelteToast } from "@zerodevx/svelte-toast"; import { onDestroy, onMount } from "svelte"; import { close, connect, send, socket } from "$lib/socket"; @@ -43,27 +43,30 @@ }); let handleArkPayment = async (notification) => { - console.log("NOTIF", notification); + console.log("ARK payment notification:", notification); let amount = notification.newVtxos.reduce((a, b) => a + b.value, 0); - console.log("AMOUNT", amount); + if (!amount) return; + success(`Received ⚡️${s(amount)}!`); - const wallet = await getWallet(); - const address = await wallet.getAddress(); - let invoice = await post(`/invoice`, { - invoice: { aid, type: "ark", amount }, - user, - }); + try { + let inv = await post(`/invoice`, { + invoice: { type: "ark", amount }, + user, + }); - let p = await post(`/post/ark/receive`, { - amount: vtxo.value, - iid: invoice.id, - }); + await post(`/ark/receive`, { + amount, + iid: inv.id, + }); - goto(`/invoice/${p.iid}`, { - invalidateAll: true, - noScroll: true, - }); + goto(`/invoice/${inv.id}`, { + invalidateAll: true, + noScroll: true, + }); + } catch (e) { + console.error("Failed to record ARK payment:", e); + } }; onMount(async () => { @@ -74,6 +77,18 @@ if (user) { const wallet = await getWallet(); wallet.notifyIncomingFunds(handleArkPayment); + + // Sync ARK address with backend if changed + const arkAddress = await getAddress(); + if (arkAddress !== user.arkAddress) { + try { + console.log("SYNCING", arkAddress); + await post("/post/user", { arkAddress }); + user.arkAddress = arkAddress; + } catch (e) { + console.error("Failed to sync ARK address:", e); + } + } } // if (window.NDEFReader) { diff --git a/src/routes/(app)/ark/+page.server.ts b/src/routes/(app)/ark/+page.server.ts new file mode 100644 index 00000000..bac0a02a --- /dev/null +++ b/src/routes/(app)/ark/+page.server.ts @@ -0,0 +1,11 @@ +import getRates from "$lib/rates"; + +export async function load({ depends, parent }) { + depends("app:payments"); + depends("app:items"); + + const { user } = await parent(); + + const rates = await getRates(); + return { rate: rates[user.currency] }; +} diff --git a/src/routes/(app)/ark/+page.svelte b/src/routes/(app)/ark/+page.svelte index 09d4d9a3..3089d887 100644 --- a/src/routes/(app)/ark/+page.svelte +++ b/src/routes/(app)/ark/+page.svelte @@ -1,44 +1,113 @@ <script> - import { onMount, tick } from "svelte"; - import { HDKey } from "@scure/bip32"; - import { wallet } from "$lib/ark"; + import { onMount } from "svelte"; + import { getBalance, getAddress, keyToNsec } from "$lib/ark"; + import Amount from "$comp/Amount.svelte"; + import { s, copy } from "$lib/utils"; + import { t } from "$lib/translations"; - let loading = $state(); - let balance = $state(); + let { data } = $props(); + let { user, rate } = $derived(data); + + let loading = $state(true); + let balance = $state({ available: 0, pending: 0 }); + let address = $state(""); + let showNsec = $state(false); + let nsec = $state(""); onMount(async () => { - setTimeout(checkBalance, 1000); + await refresh(); }); - let checkBalance = async (e) => { - e?.preventDefault(); + let refresh = async () => { loading = true; + try { + [balance, address] = await Promise.all([getBalance(), getAddress()]); + } catch (e) { + console.error("Failed to load ARK wallet:", e); + } + loading = false; + }; - balance = await wallet().getBalance(); + let revealNsec = () => { + nsec = keyToNsec(); + showNsec = true; + }; - loading = false; + let copyNsec = () => { + copy(nsec); }; </script> <div class="space-y-5"> - <h1 class="text-center text-3xl font-semibold">Ark</h1> + <div class="flex items-center justify-center gap-2"> + <iconify-icon noobserver icon="ph:vault-bold" width="32"></iconify-icon> + <h1 class="text-3xl font-semibold">Ark Wallet</h1> + </div> - <div class="container w-full mx-auto text-lg px-4 max-w-xl space-y-5"> + <div class="container w-full mx-auto px-4 max-w-xl space-y-5"> {#if loading} - <div> - <div class="loading loading-spinner"></div> + <div class="flex justify-center py-8"> + <div class="loading loading-spinner loading-lg"></div> </div> - {:else if typeof balance !== "undefined"} - <div class="flex items-center gap-2"> - <div> - {balance.available} sats + {:else} + <div class="text-center space-y-2"> + <Amount + amount={balance.available} + {rate} + currency={user?.currency || "USD"} + /> + {#if balance.pending > 0} + <div class="text-sm text-warning"> + +{s(balance.pending)} pending + </div> + {/if} + </div> + + <div class="flex gap-2 justify-center w-full text-xl"> + <a href="/invoice" class="btn flex-grow"> + <iconify-icon noobserver icon="ph:hand-coins-bold" width="24" flip="horizontal"></iconify-icon> + Receive + </a> + <a href="/send" class="btn flex-grow"> + <iconify-icon noobserver icon="ph:paper-plane-right-bold" width="24"></iconify-icon> + Send + </a> + </div> + + <button class="btn btn-ghost btn-sm w-full" onclick={refresh}> + <iconify-icon noobserver icon="ph:arrows-clockwise-bold" width="20"></iconify-icon> + Refresh + </button> + + <div class="divider"></div> + + <div class="space-y-2"> + <div class="text-sm text-secondary">Your ARK Address</div> + <div + class="text-xs break-all bg-base-200 p-3 rounded-lg cursor-pointer hover:bg-base-300" + onclick={() => copy(address)} + > + {address} </div> </div> - {/if} - <div class="flex gap-2 justify-center w-full"> - <a href="/receive" class="btn grow">Receive</a> - <a href="/send" class="btn grow">Send</a> - </div> + <div class="space-y-2"> + <div class="text-sm text-secondary">Backup Key</div> + {#if showNsec} + <div + class="text-xs break-all bg-warning/20 p-3 rounded-lg cursor-pointer border border-warning" + onclick={copyNsec} + > + {nsec} + </div> + <div class="text-xs text-warning">⚠️ Keep this secret! Anyone with this key can spend your funds.</div> + {:else} + <button class="btn btn-warning btn-sm" onclick={revealNsec}> + <iconify-icon noobserver icon="ph:eye-bold" width="20"></iconify-icon> + Reveal nsec + </button> + {/if} + </div> + {/if} </div> </div> diff --git a/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.svelte b/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.svelte index e77abdb5..37b8dad9 100644 --- a/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.svelte +++ b/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.svelte @@ -1,47 +1,44 @@ <script> - import { tick } from "svelte"; import { t } from "$lib/translations"; import { enhance } from "$app/forms"; - import Toggle from "$comp/Toggle.svelte"; import Spinner from "$comp/Spinner.svelte"; - import { page } from "$app/stores"; - import { toFiat, f, focus, s, sat, closest, network } from "$lib/utils"; + import { focus, s } from "$lib/utils"; import { pin } from "$lib/store"; import { goto, invalidate } from "$app/navigation"; import { rate } from "$lib/store"; import { applyAction } from "$app/forms"; - import { getWallet } from "$lib/ark"; + import { sendArk } from "$lib/ark"; import Amount from "$comp/Amount.svelte"; let { data, form } = $props(); - let passwordPrompt = $state(); - let password = $state(); - let cancel = $state(() => (passwordPrompt = false)); + let error = $state(""); - let togglePassword = () => (passwordPrompt = !passwordPrompt); let handler = async ({ cancel }) => { - let wallet = await getWallet(); - const txid = await wallet.sendBitcoin({ - address, - amount, - }); + submitting = true; + error = ""; - console.log(txid); + try { + const txid = await sendArk(address, amount); + console.log("ARK tx:", txid); - return async ({ result }) => { - if (result.type === "redirect") { - goto(result.location); - } else { - await applyAction(result); - invalidate("app:payments"); - } - }; + return async ({ result }) => { + submitting = false; + if (result.type === "redirect") { + goto(result.location); + } else { + await applyAction(result); + invalidate("app:payments"); + } + }; + } catch (e) { + submitting = false; + error = e.message || "Failed to send"; + cancel(); + } }; - let toggle = () => (submitting = !submitting); - let { account, amount, address, message } = $derived(data); let { feeRate } = $state(data); @@ -51,11 +48,8 @@ }); let { balance, currency } = data.user; - let submitting = $state(), - submit = $state(), - showSettings; - - let toggleSettings = () => (showSettings = !showSettings); + let submitting = $state(false), + submit = $state(); </script> <div @@ -63,11 +57,13 @@ > <h1 class="text-3xl md:text-4xl font-semibold mb-2">{$t("payments.send")}</h1> - {#if form?.message || message} + {#if form?.message || message || error} <div class="mb-5"> - <div class="text-red-600">{form?.message || message}</div> + <div class="text-red-600">{form?.message || message || error}</div> </div> - {:else} + {/if} + + {#if !error} <div class="text-xl text-secondary break-all">{address}</div> <Amount {amount} rate={$rate} {currency} /> @@ -96,10 +92,6 @@ {/if} </div> -{#if passwordPrompt} - <WalletPass bind:password bind:cancel submit={signTx} /> -{/if} - <style> .no-transition { view-transition-name: fee; |
