monero: price fetch, fix atomicToXmr, add balance chart from tx history

This commit is contained in:
2026-06-02 21:51:40 +08:00
parent 2c792bfcf8
commit 3a44e888b2
6 changed files with 149 additions and 5 deletions
+4
View File
@@ -19,6 +19,8 @@
"@alchemy/common": "^5.0.0", "@alchemy/common": "^5.0.0",
"@alchemy/wallet-apis": "^5.0.0", "@alchemy/wallet-apis": "^5.0.0",
"@koins/utils": "workspace:*", "@koins/utils": "workspace:*",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"drizzle-orm": "^1.0.0-beta.22", "drizzle-orm": "^1.0.0-beta.22",
"electrobun": "^1.18.1", "electrobun": "^1.18.1",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
@@ -34,6 +36,8 @@
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@tanstack/table-core": "^8.21.3", "@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/env-paths": "^2.1.2",
"@types/node": "^25.7.0", "@types/node": "^25.7.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
+15
View File
@@ -309,5 +309,20 @@ export function createMoneroHandlers(state: {
return null 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
}
},
} }
} }
+1
View File
@@ -186,6 +186,7 @@ export type MoneroRpcRequests = {
moneroCreateSubaddress: { params: { accountIndex: number; label?: string }; response: { index: number; address: string } } moneroCreateSubaddress: { params: { accountIndex: number; label?: string }; response: { index: number; address: string } }
moneroGetTransferDetails: { params: { txid: string }; response: MoneroTransferDetails | null } moneroGetTransferDetails: { params: { txid: string }; response: MoneroTransferDetails | null }
moneroGetFeeEstimate: { params: {}; response: MoneroFeeEstimate | null } moneroGetFeeEstimate: { params: {}; response: MoneroFeeEstimate | null }
fetchMoneroPrice: { params: {}; response: { usd: string } | null }
} }
export type RPC = { export type RPC = {
@@ -17,8 +17,8 @@ export const moneroNetwork = {
export const atomicToXmr = (atomic: string): string => { export const atomicToXmr = (atomic: string): string => {
const n = BigInt(atomic) const n = BigInt(atomic)
const whole = n / 10_000_000_000_000n const whole = n / 1_000_000_000_000n
const frac = (n % 10_000_000_000_000n) const frac = (n % 1_000_000_000_000n)
.toString() .toString()
.padStart(12, '0') .padStart(12, '0')
.replace(/0+$/, '') .replace(/0+$/, '')
@@ -30,6 +30,10 @@
import { navigate, route } from 'sv-router/generated' import { navigate, route } from 'sv-router/generated'
import { CopyButton } from '$lib/components/ui/copy-button' import { CopyButton } from '$lib/components/ui/copy-button'
import { onMount } from 'svelte' 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 const w = moneroWallet
@@ -48,6 +52,31 @@
fees: string[] fees: string[]
estimatedFee: string estimatedFee: string
} | null>(null) } | 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 accountDialogOpen = $state(false)
let accountLabel = $state('') let accountLabel = $state('')
let subaddressDialogOpen = $state(false) let subaddressDialogOpen = $state(false)
@@ -61,7 +90,12 @@
$effect(() => { $effect(() => {
if (w.walletOpen && !autoRefreshTimer) { 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) { } else if (!w.walletOpen && autoRefreshTimer) {
clearInterval(autoRefreshTimer) clearInterval(autoRefreshTimer)
autoRefreshTimer = undefined autoRefreshTimer = undefined
@@ -87,6 +121,9 @@
navigate('/monero') navigate('/monero')
} }
w.refresh() w.refresh()
electrobun.rpc?.request
.fetchMoneroPrice({})
.then((p) => (moneroPrice = p))
}) })
</script> </script>
@@ -163,7 +200,19 @@
</div> </div>
</div> </div>
<p class="mt-2 font-medium text-xs">Balance</p> <p class="mt-2 font-medium text-xs">Balance</p>
<div class="flex items-baseline gap-2">
<p class="font-mono text-lg">{w.balance} XMR</p> <p class="font-mono text-lg">{w.balance} XMR</p>
{#if moneroPrice}
<p class="font-mono text-sm text-muted-foreground">
≈ ${(
parseFloat(w.balance) * parseFloat(moneroPrice.usd)
).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
{/if}
</div>
{#if w.unlockedAtomic !== w.balAtomic} {#if w.unlockedAtomic !== w.balAtomic}
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Unlocked: {w.unlocked} XMR Unlocked: {w.unlocked} XMR
@@ -301,6 +350,69 @@
</CardContent> </CardContent>
</Card> </Card>
{#if balanceHistory.length > 1}
<Card>
<CardHeader>
<CardTitle>Balance</CardTitle>
</CardHeader>
<CardContent>
<Chart.Container
config={{
balance: { label: 'XMR', color: 'var(--chart-1)' },
}}>
<LineChart
data={balanceHistory}
x="date"
xScale={scaleUtc()}
// axis="x"
points
series={[
{
key: 'balance',
label: 'XMR',
color: 'var(--chart-1)',
},
]}
yDomain={[
balanceHistory.reduce((a, b) =>
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()}
<Chart.Tooltip
labelFormatter={(value: Date) => {
return value.toLocaleDateString()
}}
valueFormatter={(value: Number) => {
return value.toString().slice(0, 8)
}} />
{/snippet}
</LineChart>
</Chart.Container>
</CardContent>
</Card>
{/if}
<!-- Monero accounts & subaddresses --> <!-- Monero accounts & subaddresses -->
<Card> <Card>
<CardHeader> <CardHeader>
+13 -1
View File
@@ -16,6 +16,8 @@
"@alchemy/common": "^5.0.0", "@alchemy/common": "^5.0.0",
"@alchemy/wallet-apis": "^5.0.0", "@alchemy/wallet-apis": "^5.0.0",
"@koins/utils": "workspace:*", "@koins/utils": "workspace:*",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"drizzle-orm": "^1.0.0-beta.22", "drizzle-orm": "^1.0.0-beta.22",
"electrobun": "^1.18.1", "electrobun": "^1.18.1",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
@@ -31,6 +33,8 @@
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@tanstack/table-core": "^8.21.3", "@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/env-paths": "^2.1.2",
"@types/node": "^25.7.0", "@types/node": "^25.7.0",
"@types/qrcode": "^1.5.6", "@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-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/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=="], "@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=="], "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=="], "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=="],