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/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",
+15
View File
@@ -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
}
},
}
}
+1
View File
@@ -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 = {
@@ -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+$/, '')
@@ -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))
})
</script>
@@ -163,7 +200,19 @@
</div>
</div>
<p class="mt-2 font-medium text-xs">Balance</p>
<p class="font-mono text-lg">{w.balance} XMR</p>
<div class="flex items-baseline gap-2">
<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}
<p class="text-xs text-muted-foreground">
Unlocked: {w.unlocked} XMR
@@ -301,6 +350,69 @@
</CardContent>
</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 -->
<Card>
<CardHeader>