monero: price fetch, fix atomicToXmr, add balance chart from tx history
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
Reference in New Issue
Block a user