8 Commits
10 changed files with 243 additions and 172 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ export default {
app: {
name: 'koins',
identifier: 'koins.tzgyn.com',
version: '0.0.7',
version: '0.0.11',
},
build: {
// Vite builds to dist/, we copy from there
+10
View File
@@ -151,6 +151,7 @@ export function createMoneroHandlers(state: {
console.log('[rpc] moneroGetBalance')
if (!state.manager)
throw new Error('Monero wallet RPC not started')
try {
const { balance, unlocked } = await walletGetBalance(
state.manager,
)
@@ -171,6 +172,10 @@ export function createMoneroHandlers(state: {
height,
daemonHeight,
}
} catch (e) {
logError('[rpc] moneroGetBalance failed', e)
throw e
}
},
moneroGetTransactions: async ({
accountIndex,
@@ -180,12 +185,17 @@ export function createMoneroHandlers(state: {
console.log('[rpc] moneroGetTransactions', { accountIndex })
if (!state.manager)
throw new Error('Monero wallet RPC not started')
try {
const txs = await walletGetTransactions(
state.manager,
accountIndex,
)
console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`)
return txs
} catch (e) {
logError('[rpc] moneroGetTransactions failed', e)
throw e
}
},
moneroWalletStatus: async () => {
if (!state.manager) {
@@ -1,5 +1,7 @@
import {
electrobun,
frontendLog,
frontendLogError,
type MoneroTxEntry,
type MoneroAccountEntry,
type MoneroSendResult,
@@ -25,7 +27,7 @@ export const atomicToXmr = (atomic: string): string => {
return frac ? `${whole}.${frac}` : `${whole}`
}
export const MoneroWallet = () => {
export const createMoneroWallet = () => {
let accountType = $state<'monero' | null>(null)
let ready = $state(false)
let biometricAvailable = $state(false)
@@ -54,6 +56,9 @@ export const MoneroWallet = () => {
let refreshInFlight: Promise<void> | null = null
let syncing = $state(false)
let syncRetryTimer: ReturnType<typeof setTimeout> | null = null
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
let price = $state<string | null>(null)
let priceInFlight = false
const rpcTimeout = <T>(promise: Promise<T>, ms: number, label: string): Promise<T> =>
new Promise<T>((resolve, reject) => {
@@ -101,7 +106,10 @@ export const MoneroWallet = () => {
return authed === true
}
let initPromise: Promise<void> | null = null
const init = async () => {
if (initPromise) return initPromise
initPromise = (async () => {
await checkStatus()
if (installed && !running && !downloading) {
await start()
@@ -109,6 +117,8 @@ export const MoneroWallet = () => {
}
await checkBiometric()
ready = true
})()
return initPromise
}
const login = async () => {
@@ -399,6 +409,31 @@ export const MoneroWallet = () => {
}, 15000)
}
const stopAutoRefresh = () => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
}
// Refresh runs in the store so the dashboard's connection/sync state
// stays live regardless of which page is mounted.
const startAutoRefresh = () => {
if (autoRefreshTimer || !walletOpen) return
autoRefreshTimer = setInterval(() => {
if (walletOpen && !loading) refresh()
}, 30000)
}
// $effect needs a root outside a component; the root lives for the
// lifetime of the singleton store.
$effect.root(() => {
$effect(() => {
if (walletOpen && !loading) startAutoRefresh()
else if (!walletOpen) stopAutoRefresh()
})
})
const refresh = async () => {
const rpc = electrobun.rpc
if (!rpc) return
@@ -406,6 +441,8 @@ export const MoneroWallet = () => {
refreshInFlight = (async () => {
loading = true
let failed = false
const wasSyncing = syncing
let lastError: unknown = null
try {
const [bal, balErr] = await tryCatch(
rpcTimeout(
@@ -414,7 +451,10 @@ export const MoneroWallet = () => {
'moneroGetBalance',
),
)
if (balErr) failed = true
if (balErr) {
failed = true
lastError = balErr
}
if (bal) {
balAtomic = bal.balance
unlockedAtomic = bal.unlocked
@@ -431,21 +471,38 @@ export const MoneroWallet = () => {
'moneroGetTransactions',
),
)
if (txErr) failed = true
if (txErr) {
failed = true
lastError = txErr
}
txs = result ?? []
const ok = await fetchAccounts()
if (!ok) failed = true
if (!ok) {
failed = true
lastError = new Error('moneroGetAccounts failed')
}
if (failed) {
// The wallet-rpc is likely busy syncing; keep the stale
// balance and retry shortly instead of showing an error.
syncing = true
scheduleSyncRetry()
frontendLogError(
`monero refresh failed (height ${height}/${daemonHeight})`,
lastError,
)
} else {
if (wasSyncing) {
frontendLog(`monero sync complete at height ${height}`)
}
syncing = false
}
} catch (e) {
syncing = true
scheduleSyncRetry()
frontendLogError(
`monero refresh failed (height ${height}/${daemonHeight})`,
e,
)
} finally {
loading = false
refreshInFlight = null
@@ -454,6 +511,18 @@ export const MoneroWallet = () => {
return refreshInFlight
}
const fetchPrice = async () => {
const rpc = electrobun.rpc
if (!rpc || priceInFlight) return
priceInFlight = true
try {
const [p] = await tryCatch(rpc.request.fetchMoneroPrice({}))
if (p) price = p.usd
} finally {
priceInFlight = false
}
}
const send = async (
address: string,
amountAtomic: string,
@@ -573,6 +642,9 @@ export const MoneroWallet = () => {
get syncing() {
return syncing
},
get price() {
return price
},
get error() {
return error
},
@@ -622,6 +694,7 @@ export const MoneroWallet = () => {
autoUnlock,
unlockWallet,
refresh,
fetchPrice,
send,
sendAll,
createAccount,
@@ -629,4 +702,15 @@ export const MoneroWallet = () => {
}
}
// Singleton: calling MoneroWallet() always returns the same instance, so
// no component can ever create a second, disconnected wallet state.
type MoneroWalletStore = ReturnType<typeof createMoneroWallet>
let moneroWalletInstance: MoneroWalletStore | null = null
export const MoneroWallet = (): MoneroWalletStore => {
if (!moneroWalletInstance) moneroWalletInstance = createMoneroWallet()
return moneroWalletInstance
}
export const moneroWallet = MoneroWallet()
@@ -13,7 +13,7 @@ export const xrpNetwork = {
explorerAddressUrl: 'https://xrpscan.com/account/',
} as const
export const XrpWallet = () => {
export const createXrpWallet = () => {
let accountType = $state<'xrp' | null>(null)
let ready = $state(false)
let biometricAvailable = $state(false)
@@ -29,6 +29,9 @@ export const XrpWallet = () => {
let loading = $state(false)
let error = $state('')
let wallets = $state<XrpWalletInfo[]>([])
let balances = $state<Record<string, { balance: number; funded: boolean }>>({})
let balancesLoading = $state(false)
let fetchBalancesInFlight = false
let currentWalletId = $state<string | null>(null)
let currentPasswordHash = $state<string | null>(null)
@@ -73,7 +76,10 @@ export const XrpWallet = () => {
biometricAvailable = ok === true
}
let initPromise: Promise<void> | null = null
const init = async () => {
if (initPromise) return initPromise
initPromise = (async () => {
if (!electrobun.rpc) return
const [walletList] = await tryCatch(
electrobun.rpc.request.xrpListWallets({}),
@@ -86,6 +92,8 @@ export const XrpWallet = () => {
}
await checkBiometric()
ready = true
})()
return initPromise
}
const login = async () => {
@@ -319,6 +327,34 @@ export const XrpWallet = () => {
})()
}
const fetchBalances = async () => {
const rpc = electrobun.rpc
if (!rpc || fetchBalancesInFlight) return
if (wallets.length === 0) return
fetchBalancesInFlight = true
balancesLoading = true
try {
const [p] = await tryCatch(rpc.request.fetchXrpPrice({}))
if (p) price = p.usd
await Promise.allSettled(
wallets.map(async (w) => {
const [res] = await tryCatch(
rpc.request.xrpGetBalance({ address: w.address }),
)
if (res) {
balances[w.address] = {
balance: parseFloat(res.balance),
funded: res.funded,
}
}
}),
)
} finally {
balancesLoading = false
fetchBalancesInFlight = false
}
}
const send = async (
to: string,
amount: string,
@@ -399,6 +435,12 @@ export const XrpWallet = () => {
get wallets() {
return wallets
},
get balances() {
return balances
},
get balancesLoading() {
return balancesLoading
},
get currentWalletId() {
return currentWalletId
},
@@ -424,6 +466,7 @@ export const XrpWallet = () => {
login,
logout,
refresh,
fetchBalances,
createWallet,
importWallet,
lock,
@@ -442,4 +485,15 @@ export const XrpWallet = () => {
}
}
// Singleton: calling XrpWallet() always returns the same instance, so no
// component can ever create a second, disconnected wallet state.
type XrpWalletStore = ReturnType<typeof createXrpWallet>
let xrpWalletInstance: XrpWalletStore | null = null
export const XrpWallet = (): XrpWalletStore => {
if (!xrpWalletInstance) xrpWalletInstance = createXrpWallet()
return xrpWalletInstance
}
export const xrpWallet = XrpWallet()
+37 -86
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { moneroWallet } from '$lib/states/monero-wallet.svelte.js'
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
import { electrobun } from '$lib/electrobun.js'
import { navigate } from 'sv-router/generated'
import { onMount } from 'svelte'
import {
@@ -17,29 +16,19 @@
import Loader from '$lib/components/loader.svelte'
import { frontendLog, frontendLogError } from '$lib/electrobun.js'
let xrpPrice = $state<string | null>(null)
let xmrPrice = $state<string | null>(null)
let xrpBalances = $state<Record<string, { balance: number; funded: boolean }>>({})
let loadingXrp = $state(true)
let loadingXmr = $state(true)
let fetchXrpInFlight = false
let feError = $state<string | null>(null)
const usd = (amount: string, price: string | null) =>
price ? parseFloat(amount) * parseFloat(price) : 0
const xrpTotalUsd = $derived.by(() => {
const price = xrpPrice
const p = xrpWallet.price
return xrpWallet.wallets.reduce((sum, w) => {
const b = xrpBalances[w.address]?.balance ?? 0
return sum + (price ? b * parseFloat(price) : 0)
const b = xrpWallet.balances[w.address]?.balance ?? 0
return sum + (p ? b * parseFloat(p) : 0)
}, 0)
})
const xrpTotalBalance = $derived.by(() =>
xrpWallet.wallets.reduce((sum, w) => {
return sum + (xrpBalances[w.address]?.balance ?? 0)
return sum + (xrpWallet.balances[w.address]?.balance ?? 0)
}, 0),
)
@@ -47,8 +36,8 @@
moneroWallet.walletOpen ? parseFloat(moneroWallet.balance || '0') : 0,
)
const xmrTotalUsd = $derived(
moneroWallet.walletOpen && xmrPrice
? xmrBalance * parseFloat(xmrPrice)
moneroWallet.walletOpen && moneroWallet.price
? xmrBalance * parseFloat(moneroWallet.price)
: 0,
)
@@ -58,6 +47,16 @@
const hasXmr = $derived(moneroWallet.wallets.length > 0)
const noWallets = $derived(!hasXrp && !hasXmr)
const moneroDot = $derived(
!moneroWallet.running
? 'bg-muted'
: !moneroWallet.connected
? 'bg-red-500'
: moneroWallet.syncing || moneroWallet.height < moneroWallet.daemonHeight
? 'bg-yellow-500 animate-pulse'
: 'bg-emerald-500',
)
const fmtUsd = (v: number) =>
v.toLocaleString(undefined, {
minimumFractionDigits: 2,
@@ -70,66 +69,21 @@
maximumFractionDigits: dp,
})
async function fetchXrp() {
if (fetchXrpInFlight) return
fetchXrpInFlight = true
loadingXrp = true
const rpc = electrobun.rpc
if (!rpc) {
loadingXrp = false
fetchXrpInFlight = false
return
}
try {
const p = await rpc.request.fetchXrpPrice({})
if (p) xrpPrice = p.usd
} catch { /* ignore */ }
await Promise.allSettled(
xrpWallet.wallets.map(async (w) => {
const res = await rpc.request.xrpGetBalance({
address: w.address,
})
xrpBalances[w.address] = {
balance: parseFloat(res.balance),
funded: res.funded,
}
}),
)
loadingXrp = false
fetchXrpInFlight = false
}
async function fetchXmr() {
loadingXmr = true
const rpc = electrobun.rpc
if (!rpc) {
loadingXmr = false
return
}
try {
const p = await rpc.request.fetchMoneroPrice({})
xmrPrice = p?.usd ?? null
} catch { /* ignore */ }
loadingXmr = false
}
onMount(() => {
frontendLog('dashboard mounted')
moneroWallet.init().catch((e) => frontendLogError('moneroWallet.init() failed', e))
xrpWallet.init().catch((e) => frontendLogError('xrpWallet.init() failed', e))
fetchXmr()
})
// Wait for the XRP wallet list to load, then fetch balances.
// Balances live in the stores so they survive navigation; the effects
// only trigger background refetches.
$effect(() => {
if (xrpWallet.ready && xrpWallet.wallets.length > 0) {
fetchXrp()
xrpWallet.fetchBalances()
}
})
$effect(() => {
if (moneroWallet.walletOpen) {
fetchXmr()
moneroWallet.fetchPrice()
}
})
@@ -191,7 +145,7 @@
{:else}
<div class="flex flex-col items-center pt-4 pb-2">
<p class="text-xs text-muted-foreground">Total Portfolio Value</p>
{#if loadingXrp || loadingXmr || moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen)}
{#if moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen) || (xrpWallet.wallets.length > 0 && !xrpWallet.price) || (moneroWallet.walletOpen && !moneroWallet.price)}
<div class="mt-2 h-9 w-44 rounded-md bg-muted animate-pulse"></div>
{:else}
<p class="mt-1 text-3xl font-semibold tabular-nums">
@@ -210,21 +164,19 @@
<img src="/icons/xrp.png" alt="" class="size-5 rounded-full" />
<CardTitle class="text-base">XRP</CardTitle>
</div>
<CardDescription class="text-xs">
{xrpWallet.wallets.length} wallet{xrpWallet.wallets.length !== 1 ? 's' : ''}
</CardDescription>
<CardDescription class="text-xs">XRP Ledger</CardDescription>
</CardHeader>
<CardContent class="flex flex-1 flex-col justify-center">
{#if !xrpWallet.ready || loadingXrp || (xrpWallet.wallets.length > 0 && Object.keys(xrpBalances).length === 0)}
{#if !xrpWallet.ready || (xrpWallet.wallets.length > 0 && Object.keys(xrpWallet.balances).length === 0)}
<div class="h-14 flex items-center"><Loader /></div>
{:else}
<p class="font-mono text-xl tabular-nums">{fmtBal(xrpTotalBalance)}</p>
{#if xrpPrice}
{#if xrpWallet.price}
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
${fmtUsd(xrpTotalUsd)}
</p>
<p class="mt-2 text-xs text-muted-foreground">
${fmtUsd(parseFloat(xrpPrice) )} / XRP
${fmtUsd(parseFloat(xrpWallet.price) )} / XRP
</p>
{/if}
{/if}
@@ -241,27 +193,28 @@
<img src="/icons/monero.png" alt="" class="size-5 rounded-full" />
<CardTitle class="text-base">Monero</CardTitle>
</div>
<CardDescription class="text-xs">
{moneroWallet.walletOpen
? moneroWallet.walletName || 'Wallet open'
: moneroWallet.wallets.length > 0
? `${moneroWallet.wallets.length} wallet${moneroWallet.wallets.length !== 1 ? 's' : ''}`
: 'Not set up'}
<CardDescription class="flex items-center gap-1.5 text-xs">
<span class="size-2 shrink-0 rounded-full {moneroDot}"></span>
{#if !moneroWallet.running}
Server offline
{:else if !moneroWallet.connected}
Connecting to daemon…
{:else}
{fmtBal(moneroWallet.height, 0)} / {fmtBal(moneroWallet.daemonHeight, 0)}
{/if}
</CardDescription>
</CardHeader>
<CardContent class="flex flex-1 flex-col justify-center">
{#if moneroWallet.walletOpen}
<p class="font-mono text-xl tabular-nums">{fmtBal(xmrBalance, 4)}</p>
{#if moneroWallet.syncing}
<p class="mt-0.5 text-xs text-muted-foreground">Syncing…</p>
{:else if xmrPrice}
{#if !moneroWallet.syncing && moneroWallet.price}
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
${fmtUsd(xmrTotalUsd)}
</p>
{/if}
{#if xmrPrice}
{#if moneroWallet.price}
<p class="mt-2 text-xs text-muted-foreground">
${fmtUsd(parseFloat(xmrPrice) )} / XMR
${fmtUsd(parseFloat(moneroWallet.price) )} / XMR
</p>
{/if}
{:else if moneroWallet.passwordRequired && moneroWallet.wallets.length > 0}
@@ -289,8 +242,6 @@
<Loader />
<p class="text-sm text-muted-foreground">Opening wallet...</p>
</div>
{:else if loadingXmr}
<div class="h-14 flex items-center"><Loader /></div>
{:else}
<Button
onclick={() => navigate('/monero')}
+1
View File
@@ -31,6 +31,7 @@
if (!xrpWallet.ready) await xrpWallet.init()
if (!moneroWallet.ready) await moneroWallet.init()
await Promise.allSettled([
xrpWallet.login(),
xrpWallet.autoUnlock(),
moneroWallet.login(),
])
@@ -24,8 +24,6 @@
let unlocking = $state(false)
let unlockError = $state('')
let initStarted = false
async function unlockWithPassword() {
if (unlocking || !walletPassword.trim()) return
unlocking = true
@@ -48,16 +46,6 @@
}
})
$effect(() => {
if (!initStarted) {
initStarted = true
w.init().then(async () => {
if (w.accountType === 'monero') return
await w.login()
})
}
})
$effect(() => {
if (w.running && !w.walletOpen && w.wallets.length > 0 && !w.loading && !w.opening) {
w.autoUnlock(w.wallets[0])
@@ -15,10 +15,6 @@
const w = moneroWallet
let moneroSettingsPw = $state('')
$effect(() => {
w.init()
})
</script>
<div class="mx-auto max-w-xl">
@@ -154,7 +154,6 @@
$effect(() => {
if (w.walletOpen && !autoRefreshTimer) {
autoRefreshTimer = setInterval(() => {
w.refresh()
electrobun.rpc?.request
.fetchMoneroPrice({})
.then((p) => (moneroPrice = p))
-12
View File
@@ -22,18 +22,6 @@
let inputUnlockPassword = $state('')
let newSeed = $state<string | null>(null)
let initStarted = false
$effect(() => {
if (!initStarted) {
initStarted = true
w.init().then(async () => {
if (w.accountType === 'xrp') return
await w.login()
})
}
})
$effect(() => {
if (w.accountType === 'xrp' && w.currentWalletId && w.seed && !newSeed) {
navigate('/xrp/wallet/:id', {