aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2025-11-26 14:53:20 -0800
committerAdam Soltys <[email protected]>2026-02-11 15:33:08 -0800
commit68e7fe9655f94efc573f3aab7acf60d03c4162a0 (patch)
tree48f35ea51823ac1cd0d74afc453628f5e128bf71 /src
parent5f1c31b6618f0b895fb98c8f2a841989625eaf16 (diff)
downloadcoinos-ui-68e7fe9655f94efc573f3aab7acf60d03c4162a0.tar.gz
coinos-ui-68e7fe9655f94efc573f3aab7acf60d03c4162a0.zip
ark balance
Diffstat (limited to 'src')
-rw-r--r--src/lib/ark.ts116
-rw-r--r--src/routes/(app)/+layout.svelte49
-rw-r--r--src/routes/(app)/ark/+page.server.ts11
-rw-r--r--src/routes/(app)/ark/+page.svelte115
-rw-r--r--src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.svelte66
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;