From 3a44e888b2604b67ca1a7391d2b5366ee5ff2ed6 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Tue, 2 Jun 2026 21:51:40 +0800 Subject: [PATCH] monero: price fetch, fix atomicToXmr, add balance chart from tx history --- apps/desktop/package.json | 4 + apps/desktop/src/bun/rpc/monero/index.ts | 15 +++ apps/desktop/src/lib/rpc-schema.ts | 1 + .../src/lib/states/monero-wallet.svelte.ts | 4 +- .../src/routes/monero/wallet/[name].svelte | 116 +++++++++++++++++- bun.lock | 14 ++- 6 files changed, 149 insertions(+), 5 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 100a448..2afe4d2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -19,6 +19,8 @@ "@alchemy/common": "^5.0.0", "@alchemy/wallet-apis": "^5.0.0", "@koins/utils": "workspace:*", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", "drizzle-orm": "^1.0.0-beta.22", "electrobun": "^1.18.1", "mode-watcher": "^1.1.0", @@ -34,6 +36,8 @@ "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "^4.2.2", "@tanstack/table-core": "^8.21.3", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.8", "@types/env-paths": "^2.1.2", "@types/node": "^25.7.0", "@types/qrcode": "^1.5.6", diff --git a/apps/desktop/src/bun/rpc/monero/index.ts b/apps/desktop/src/bun/rpc/monero/index.ts index 2ea61a9..8fa1bfc 100644 --- a/apps/desktop/src/bun/rpc/monero/index.ts +++ b/apps/desktop/src/bun/rpc/monero/index.ts @@ -309,5 +309,20 @@ export function createMoneroHandlers(state: { return null } }, + fetchMoneroPrice: async () => { + try { + const res = await fetch( + 'https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd', + { signal: AbortSignal.timeout(10000) }, + ) + if (!res.ok) return null + const data = await res.json() + if (!data?.monero?.usd) return null + return { usd: String(data.monero.usd) } + } catch (error) { + console.log('[monero][price] error:', error) + return null + } + }, } } diff --git a/apps/desktop/src/lib/rpc-schema.ts b/apps/desktop/src/lib/rpc-schema.ts index 6d2fa58..5d78e00 100644 --- a/apps/desktop/src/lib/rpc-schema.ts +++ b/apps/desktop/src/lib/rpc-schema.ts @@ -186,6 +186,7 @@ export type MoneroRpcRequests = { moneroCreateSubaddress: { params: { accountIndex: number; label?: string }; response: { index: number; address: string } } moneroGetTransferDetails: { params: { txid: string }; response: MoneroTransferDetails | null } moneroGetFeeEstimate: { params: {}; response: MoneroFeeEstimate | null } + fetchMoneroPrice: { params: {}; response: { usd: string } | null } } export type RPC = { diff --git a/apps/desktop/src/lib/states/monero-wallet.svelte.ts b/apps/desktop/src/lib/states/monero-wallet.svelte.ts index 828e551..054ff79 100644 --- a/apps/desktop/src/lib/states/monero-wallet.svelte.ts +++ b/apps/desktop/src/lib/states/monero-wallet.svelte.ts @@ -17,8 +17,8 @@ export const moneroNetwork = { export const atomicToXmr = (atomic: string): string => { const n = BigInt(atomic) - const whole = n / 10_000_000_000_000n - const frac = (n % 10_000_000_000_000n) + const whole = n / 1_000_000_000_000n + const frac = (n % 1_000_000_000_000n) .toString() .padStart(12, '0') .replace(/0+$/, '') diff --git a/apps/desktop/src/routes/monero/wallet/[name].svelte b/apps/desktop/src/routes/monero/wallet/[name].svelte index 11ccd4e..4c7b61e 100644 --- a/apps/desktop/src/routes/monero/wallet/[name].svelte +++ b/apps/desktop/src/routes/monero/wallet/[name].svelte @@ -30,6 +30,10 @@ import { navigate, route } from 'sv-router/generated' import { CopyButton } from '$lib/components/ui/copy-button' import { onMount } from 'svelte' + import { LineChart } from 'layerchart' + import { scaleUtc } from 'd3-scale' + import { curveLinear } from 'd3-shape' + import * as Chart from '$lib/components/ui/chart/index.js' const w = moneroWallet @@ -48,6 +52,31 @@ fees: string[] estimatedFee: string } | null>(null) + let moneroPrice = $state<{ usd: string } | null>(null) + + const balanceHistory = $derived.by(() => { + const sorted = [...w.txs].reverse() + if (sorted.length === 0) return [] + const points: { date: Date; balance: number }[] = [] + let cum = 0n + for (const tx of sorted) { + const amt = BigInt(tx.amount) + if (tx.direction === 'in') cum += amt + else cum -= amt + points.push({ + date: new Date(Number(tx.timestamp) * 1000), + balance: parseFloat(atomicToXmr(cum.toString())), + }) + } + points.push({ + date: new Date(), + balance: parseFloat(atomicToXmr(cum.toString())), + }) + return points + }) + + $inspect(balanceHistory) + let accountDialogOpen = $state(false) let accountLabel = $state('') let subaddressDialogOpen = $state(false) @@ -61,7 +90,12 @@ $effect(() => { if (w.walletOpen && !autoRefreshTimer) { - autoRefreshTimer = setInterval(() => w.refresh(), 30000) + autoRefreshTimer = setInterval(() => { + w.refresh() + electrobun.rpc?.request + .fetchMoneroPrice({}) + .then((p) => (moneroPrice = p)) + }, 30000) } else if (!w.walletOpen && autoRefreshTimer) { clearInterval(autoRefreshTimer) autoRefreshTimer = undefined @@ -87,6 +121,9 @@ navigate('/monero') } w.refresh() + electrobun.rpc?.request + .fetchMoneroPrice({}) + .then((p) => (moneroPrice = p)) }) @@ -163,7 +200,19 @@

Balance

-

{w.balance} XMR

+
+

{w.balance} XMR

+ {#if moneroPrice} +

+ ≈ ${( + parseFloat(w.balance) * parseFloat(moneroPrice.usd) + ).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+ {/if} +
{#if w.unlockedAtomic !== w.balAtomic}

Unlocked: {w.unlocked} XMR @@ -301,6 +350,69 @@ + {#if balanceHistory.length > 1} + + + Balance + + + + + a.balance < b.balance ? a : b, + ).balance, + balanceHistory.reduce((a, b) => + a.balance > b.balance ? a : b, + ).balance, + ]} + props={{ + spline: { + curve: curveLinear, + motion: 'tween', + strokeWidth: 2, + }, + yAxis: {}, + xAxis: { + format: (v: Date) => + v.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + // hour: '2-digit', + }), + }, + highlight: { points: { r: 4 } }, + }}> + {#snippet tooltip()} + { + return value.toLocaleDateString() + }} + valueFormatter={(value: Number) => { + return value.toString().slice(0, 8) + }} /> + {/snippet} + + + + + {/if} + diff --git a/bun.lock b/bun.lock index c287057..8d8b178 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,8 @@ "@alchemy/common": "^5.0.0", "@alchemy/wallet-apis": "^5.0.0", "@koins/utils": "workspace:*", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", "drizzle-orm": "^1.0.0-beta.22", "electrobun": "^1.18.1", "mode-watcher": "^1.1.0", @@ -31,6 +33,8 @@ "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "^4.2.2", "@tanstack/table-core": "^8.21.3", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.8", "@types/env-paths": "^2.1.2", "@types/node": "^25.7.0", "@types/qrcode": "^1.5.6", @@ -331,6 +335,14 @@ "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + "@types/env-paths": ["@types/env-paths@2.1.2", "", { "dependencies": { "env-paths": "*" } }, "sha512-T3JRXIjZ11Eaz2dI3FrwUbqpuokrsXZxqpOmOR0w83IiuTOCZEqFE5sVZ1ewYc24Ni1BHHyj6vX05jb6+S8GLA=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -937,7 +949,7 @@ "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "sv-router": ["sv-router@0.16.2", "", { "dependencies": { "esm-env": "^1.2.2" }, "peerDependencies": { "svelte": "^5" }, "bin": { "sv-router": "./src/cli/index.js" } }, "sha512-M/KpfwukqANkR+8xTX/gUmGFImV7nJby9MgOeDYTQLBfsId2/MbkR4ai15w0qtmsrj0tVQht2ZrVnc/A1+0l3Q=="], + "sv-router": ["sv-router@0.16.3", "", { "dependencies": { "esm-env": "^1.2.2" }, "peerDependencies": { "svelte": "^5" }, "bin": { "sv-router": "./src/cli/index.js" } }, "sha512-nne8keA2GKZRdRE8r7HFx7wcR2ruqVPf/RZ9De5webNgaDbfKHEeh5qpAe0Hg0cczXnZh/qcgRsSNDphbVd0Gw=="], "svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="],