9 Commits
11 changed files with 380 additions and 204 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ export default {
app: { app: {
name: 'koins', name: 'koins',
identifier: 'koins.tzgyn.com', identifier: 'koins.tzgyn.com',
version: '0.0.5', version: '0.0.10',
}, },
build: { build: {
// Vite builds to dist/, we copy from there // Vite builds to dist/, we copy from there
+79 -17
View File
@@ -41,13 +41,40 @@ function rpcConnection(
}) })
} }
function withTimeout<T>(
promise: Promise<T>,
ms: number,
label: string,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`${label} timed out after ${ms}ms`)),
ms,
)
promise.then(
(v) => {
clearTimeout(timer)
resolve(v)
},
(e) => {
clearTimeout(timer)
reject(e)
},
)
})
}
async function rawRpc( async function rawRpc(
state: MoneroWalletState, state: MoneroWalletState,
method: string, method: string,
params: Record<string, any> = {}, params: Record<string, any> = {},
): Promise<any> { ): Promise<any> {
const conn = rpcConnection(state) const conn = rpcConnection(state)
const res = await conn.sendJsonRequest(method, params) const res = await withTimeout(
conn.sendJsonRequest(method, params),
30000,
`wallet-rpc ${method}`,
)
if (res?.error) if (res?.error)
throw new Error(res.error.message || JSON.stringify(res.error)) throw new Error(res.error.message || JSON.stringify(res.error))
return res?.result return res?.result
@@ -62,7 +89,11 @@ async function waitForRpc(
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
try { try {
const conn = rpcConnection(state) const conn = rpcConnection(state)
const res = await conn.sendJsonRequest('get_version', {}) const res = await withTimeout(
conn.sendJsonRequest('get_version', {}),
5000,
'wallet-rpc probe',
)
if (res?.result?.version) { if (res?.result?.version) {
console.log(`[monero] RPC ready after ${Date.now() - start}ms`) console.log(`[monero] RPC ready after ${Date.now() - start}ms`)
return return
@@ -290,7 +321,11 @@ export async function createWallet(
language: 'English', language: 'English',
}) })
if (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const address = await state.wallet.getAddress(0, 0) const address = (await withTimeout(
state.wallet.getAddress(0, 0),
30000,
'wallet get_address',
)) as string
console.log(`[monero] wallet created: ${name} -> ${address}`) console.log(`[monero] wallet created: ${name} -> ${address}`)
return { mnemonic: result.mnemonic, address } return { mnemonic: result.mnemonic, address }
} }
@@ -329,7 +364,7 @@ export async function openWallet(
state.wallet = await connectClient(state) state.wallet = await connectClient(state)
} }
try { try {
await state.wallet.openWallet(name, password) await withTimeout(state.wallet.openWallet(name, password), 30000, `open_wallet ${name}`)
log(`[monero] wallet opened: ${name}`) log(`[monero] wallet opened: ${name}`)
} catch (e) { } catch (e) {
logError(`[monero] wallet open failed (${name})`, e) logError(`[monero] wallet open failed (${name})`, e)
@@ -348,8 +383,16 @@ export async function getBalance(
state: MoneroWalletState, state: MoneroWalletState,
): Promise<{ balance: bigint; unlocked: bigint }> { ): Promise<{ balance: bigint; unlocked: bigint }> {
if (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const balance = (await state.wallet.getBalance()) as bigint const balance = (await withTimeout(
const unlocked = (await state.wallet.getUnlockedBalance()) as bigint state.wallet.getBalance(),
30000,
'wallet get_balance',
)) as bigint
const unlocked = (await withTimeout(
state.wallet.getUnlockedBalance(),
30000,
'wallet get_unlocked_balance',
)) as bigint
console.log(`[monero] balance: ${balance} (unlocked: ${unlocked})`) console.log(`[monero] balance: ${balance} (unlocked: ${unlocked})`)
return { balance, unlocked } return { balance, unlocked }
} }
@@ -360,9 +403,10 @@ export async function getAddress(
subIdx = 0, subIdx = 0,
): Promise<string> { ): Promise<string> {
if (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const address = (await state.wallet.getAddress( const address = (await withTimeout(
accountIdx, state.wallet.getAddress(accountIdx, subIdx),
subIdx, 30000,
'wallet get_address',
)) as string )) as string
console.log(`[monero] address: ${address}`) console.log(`[monero] address: ${address}`)
return address return address
@@ -398,7 +442,11 @@ export async function getAccounts(
state: MoneroWalletState, state: MoneroWalletState,
): Promise<any[]> { ): Promise<any[]> {
if (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const accounts = (await state.wallet.getAccounts(true)) as any[] const accounts = (await withTimeout(
state.wallet.getAccounts(true),
30000,
'wallet get_accounts',
)) as any[]
console.log(`[monero] accounts: ${accounts?.length ?? 0} returned`) console.log(`[monero] accounts: ${accounts?.length ?? 0} returned`)
if (accounts?.length) { if (accounts?.length) {
for (const acct of accounts) { for (const acct of accounts) {
@@ -543,7 +591,11 @@ export async function getHeight(
state: MoneroWalletState, state: MoneroWalletState,
): Promise<number> { ): Promise<number> {
if (!state.wallet) throw new Error('Wallet RPC not started') if (!state.wallet) throw new Error('Wallet RPC not started')
const height = (await state.wallet.getHeight()) as number const height = (await withTimeout(
state.wallet.getHeight(),
30000,
'wallet get_height',
)) as number
console.log(`[monero] wallet height: ${height}`) console.log(`[monero] wallet height: ${height}`)
return height return height
} }
@@ -587,7 +639,11 @@ export async function getDaemonHeight(
const daemon = await connectToDaemonRpc( const daemon = await connectToDaemonRpc(
`http://${state.daemonAddress}`, `http://${state.daemonAddress}`,
) )
const height = (await daemon.getHeight()) as number const height = (await withTimeout(
daemon.getHeight(),
15000,
'daemon get_height',
)) as number
console.log(`[monero] daemon height: ${height}`) console.log(`[monero] daemon height: ${height}`)
return height return height
} catch (e) { } catch (e) {
@@ -665,9 +721,13 @@ export async function isWalletOpen(
): Promise<boolean> { ): Promise<boolean> {
if (!state.wallet) return false if (!state.wallet) return false
try { try {
await state.wallet.getHeight() await withTimeout(state.wallet.getHeight(), 8000, 'wallet get_height')
return true return true
} catch { } catch (e) {
// A timeout usually means the wallet-rpc is busy syncing, not that
// the wallet is closed. Assume still-open so the UI doesn't flip to
// "closed" and try to re-open an already-open wallet.
if (e instanceof Error && e.message.includes('timed out')) return true
return false return false
} }
} }
@@ -677,10 +737,12 @@ export async function isConnected(
): Promise<boolean> { ): Promise<boolean> {
if (!state.wallet) return false if (!state.wallet) return false
try { try {
const daemon = await connectToDaemonRpc( const daemon = await withTimeout(
`http://${state.daemonAddress}`, connectToDaemonRpc(`http://${state.daemonAddress}`),
8000,
'daemon connect',
) )
await daemon.getHeight() await withTimeout(daemon.getHeight(), 8000, 'daemon get_height')
return true return true
} catch (e) { } catch (e) {
console.log(`[monero] connection check failed:`, e) console.log(`[monero] connection check failed:`, e)
+43 -26
View File
@@ -108,12 +108,19 @@ export function createMoneroHandlers(state: {
console.log('[rpc] moneroRestoreWallet:', name) console.log('[rpc] moneroRestoreWallet:', name)
if (!state.manager) if (!state.manager)
throw new Error('Monero wallet RPC not started') throw new Error('Monero wallet RPC not started')
// Default to ~2 days of history instead of a full genesis scan
// (which can take hours against a remote daemon).
let height = restoreHeight
if (!height || height === 0) {
const tip = await walletGetDaemonHeight(state.manager)
if (tip > 1440) height = tip - 1440
}
await walletRestore( await walletRestore(
state.manager, state.manager,
name, name,
password, password,
mnemonic, mnemonic,
restoreHeight, height,
) )
const address = await walletGetAddress(state.manager) const address = await walletGetAddress(state.manager)
console.log( console.log(
@@ -144,25 +151,30 @@ export function createMoneroHandlers(state: {
console.log('[rpc] moneroGetBalance') console.log('[rpc] moneroGetBalance')
if (!state.manager) if (!state.manager)
throw new Error('Monero wallet RPC not started') throw new Error('Monero wallet RPC not started')
const { balance, unlocked } = await walletGetBalance( try {
state.manager, const { balance, unlocked } = await walletGetBalance(
) state.manager,
const address = await walletGetAddress(state.manager) )
const height = await walletGetHeight(state.manager) const address = await walletGetAddress(state.manager)
const daemonHeight = await walletGetDaemonHeight(state.manager) const height = await walletGetHeight(state.manager)
console.log('[rpc] balance:', { const daemonHeight = await walletGetDaemonHeight(state.manager)
balance: balance.toString(), console.log('[rpc] balance:', {
unlocked: unlocked.toString(), balance: balance.toString(),
address, unlocked: unlocked.toString(),
height, address,
daemonHeight, height,
}) daemonHeight,
return { })
balance: balance.toString(), return {
unlocked: unlocked.toString(), balance: balance.toString(),
address, unlocked: unlocked.toString(),
height, address,
daemonHeight, height,
daemonHeight,
}
} catch (e) {
logError('[rpc] moneroGetBalance failed', e)
throw e
} }
}, },
moneroGetTransactions: async ({ moneroGetTransactions: async ({
@@ -173,12 +185,17 @@ export function createMoneroHandlers(state: {
console.log('[rpc] moneroGetTransactions', { accountIndex }) console.log('[rpc] moneroGetTransactions', { accountIndex })
if (!state.manager) if (!state.manager)
throw new Error('Monero wallet RPC not started') throw new Error('Monero wallet RPC not started')
const txs = await walletGetTransactions( try {
state.manager, const txs = await walletGetTransactions(
accountIndex, state.manager,
) accountIndex,
console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`) )
return txs console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`)
return txs
} catch (e) {
logError('[rpc] moneroGetTransactions failed', e)
throw e
}
}, },
moneroWalletStatus: async () => { moneroWalletStatus: async () => {
if (!state.manager) { if (!state.manager) {
@@ -1,5 +1,7 @@
import { import {
electrobun, electrobun,
frontendLog,
frontendLogError,
type MoneroTxEntry, type MoneroTxEntry,
type MoneroAccountEntry, type MoneroAccountEntry,
type MoneroSendResult, type MoneroSendResult,
@@ -25,7 +27,7 @@ export const atomicToXmr = (atomic: string): string => {
return frac ? `${whole}.${frac}` : `${whole}` return frac ? `${whole}.${frac}` : `${whole}`
} }
export const MoneroWallet = () => { export const createMoneroWallet = () => {
let accountType = $state<'monero' | null>(null) let accountType = $state<'monero' | null>(null)
let ready = $state(false) let ready = $state(false)
let biometricAvailable = $state(false) let biometricAvailable = $state(false)
@@ -51,6 +53,29 @@ export const MoneroWallet = () => {
let passwordRequired = $state(false) let passwordRequired = $state(false)
let startInFlight: Promise<{ running: boolean; connected: boolean }> | null = null let startInFlight: Promise<{ running: boolean; connected: boolean }> | null = null
let loginInFlight: Promise<void> | null = null let loginInFlight: Promise<void> | null = null
let refreshInFlight: Promise<void> | null = null
let syncing = $state(false)
let syncRetryTimer: ReturnType<typeof setTimeout> | 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) => {
const timer = setTimeout(
() => reject(new Error(`${label} timed out after ${ms}ms`)),
ms,
)
promise.then(
(v) => {
clearTimeout(timer)
resolve(v)
},
(e) => {
clearTimeout(timer)
reject(e)
},
)
})
const beginOpening = () => { const beginOpening = () => {
openingDepth += 1 openingDepth += 1
@@ -80,14 +105,19 @@ export const MoneroWallet = () => {
return authed === true return authed === true
} }
let initPromise: Promise<void> | null = null
const init = async () => { const init = async () => {
await checkStatus() if (initPromise) return initPromise
if (installed && !running && !downloading) { initPromise = (async () => {
await start()
await checkStatus() await checkStatus()
} if (installed && !running && !downloading) {
await checkBiometric() await start()
ready = true await checkStatus()
}
await checkBiometric()
ready = true
})()
return initPromise
} }
const login = async () => { const login = async () => {
@@ -101,6 +131,11 @@ export const MoneroWallet = () => {
if (installed && !running) { if (installed && !running) {
await start() await start()
await checkStatus() await checkStatus()
} else if (startInFlight) {
// a start is already in flight (e.g. triggered by init);
// wait for it so we don't call open before the server is up
await startInFlight.catch(() => {})
await checkStatus()
} }
if (!running) { if (!running) {
error = 'Wallet server failed to start. Please try again.' error = 'Wallet server failed to start. Please try again.'
@@ -198,14 +233,22 @@ export const MoneroWallet = () => {
const checkStatus = async () => { const checkStatus = async () => {
if (!electrobun.rpc) return if (!electrobun.rpc) return
const [binStatus] = await tryCatch( const [binStatus] = await tryCatch(
electrobun.rpc.request.moneroBinaryStatus({}), rpcTimeout(
electrobun.rpc.request.moneroBinaryStatus({}),
15000,
'moneroBinaryStatus',
),
) )
if (binStatus) { if (binStatus) {
installed = binStatus.installed installed = binStatus.installed
downloading = binStatus.downloading downloading = binStatus.downloading
} }
const [status] = await tryCatch( const [status] = await tryCatch(
electrobun.rpc.request.moneroWalletStatus({}), rpcTimeout(
electrobun.rpc.request.moneroWalletStatus({}),
30000,
'moneroWalletStatus',
),
) )
if (status) { if (status) {
running = status.running running = status.running
@@ -357,31 +400,100 @@ export const MoneroWallet = () => {
if (remember) await moneroStorePassword(target, password) if (remember) await moneroStorePassword(target, password)
} }
const scheduleSyncRetry = () => {
if (syncRetryTimer) clearTimeout(syncRetryTimer)
syncRetryTimer = setTimeout(async () => {
syncRetryTimer = null
if (walletOpen && !loading) await refresh()
}, 15000)
}
const refresh = async () => { const refresh = async () => {
if (!electrobun.rpc) return const rpc = electrobun.rpc
loading = true if (!rpc) return
try { if (refreshInFlight) return refreshInFlight
const [bal] = await tryCatch( refreshInFlight = (async () => {
electrobun.rpc.request.moneroGetBalance({}), loading = true
) let failed = false
if (bal) { const wasSyncing = syncing
balAtomic = bal.balance let lastError: unknown = null
unlockedAtomic = bal.unlocked try {
address = bal.address const [bal, balErr] = await tryCatch(
height = bal.height rpcTimeout(
daemonHeight = bal.daemonHeight rpc.request.moneroGetBalance({}),
20000,
'moneroGetBalance',
),
)
if (balErr) {
failed = true
lastError = balErr
}
if (bal) {
balAtomic = bal.balance
unlockedAtomic = bal.unlocked
address = bal.address
height = bal.height
daemonHeight = bal.daemonHeight
}
const [result, txErr] = await tryCatch(
rpcTimeout(
rpc.request.moneroGetTransactions({
accountIndex: selectedAccountIndex,
}),
20000,
'moneroGetTransactions',
),
)
if (txErr) {
failed = true
lastError = txErr
}
txs = result ?? []
const ok = await fetchAccounts()
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
} }
const [result] = await tryCatch( })()
electrobun.rpc.request.moneroGetTransactions({ return refreshInFlight
accountIndex: selectedAccountIndex, }
}),
) const fetchPrice = async () => {
txs = result ?? [] const rpc = electrobun.rpc
await fetchAccounts() if (!rpc || priceInFlight) return
} catch (e) { priceInFlight = true
error = e instanceof Error ? e.message : 'Monero refresh failed' try {
const [p] = await tryCatch(rpc.request.fetchMoneroPrice({}))
if (p) price = p.usd
} finally { } finally {
loading = false priceInFlight = false
} }
} }
@@ -501,6 +613,12 @@ export const MoneroWallet = () => {
get passwordRequired() { get passwordRequired() {
return passwordRequired return passwordRequired
}, },
get syncing() {
return syncing
},
get price() {
return price
},
get error() { get error() {
return error return error
}, },
@@ -550,6 +668,7 @@ export const MoneroWallet = () => {
autoUnlock, autoUnlock,
unlockWallet, unlockWallet,
refresh, refresh,
fetchPrice,
send, send,
sendAll, sendAll,
createAccount, createAccount,
@@ -557,4 +676,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() export const moneroWallet = MoneroWallet()
@@ -13,7 +13,7 @@ export const xrpNetwork = {
explorerAddressUrl: 'https://xrpscan.com/account/', explorerAddressUrl: 'https://xrpscan.com/account/',
} as const } as const
export const XrpWallet = () => { export const createXrpWallet = () => {
let accountType = $state<'xrp' | null>(null) let accountType = $state<'xrp' | null>(null)
let ready = $state(false) let ready = $state(false)
let biometricAvailable = $state(false) let biometricAvailable = $state(false)
@@ -29,6 +29,9 @@ export const XrpWallet = () => {
let loading = $state(false) let loading = $state(false)
let error = $state('') let error = $state('')
let wallets = $state<XrpWalletInfo[]>([]) 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 currentWalletId = $state<string | null>(null)
let currentPasswordHash = $state<string | null>(null) let currentPasswordHash = $state<string | null>(null)
@@ -73,19 +76,24 @@ export const XrpWallet = () => {
biometricAvailable = ok === true biometricAvailable = ok === true
} }
let initPromise: Promise<void> | null = null
const init = async () => { const init = async () => {
if (!electrobun.rpc) return if (initPromise) return initPromise
const [walletList] = await tryCatch( initPromise = (async () => {
electrobun.rpc.request.xrpListWallets({}), if (!electrobun.rpc) return
) const [walletList] = await tryCatch(
if (walletList) { electrobun.rpc.request.xrpListWallets({}),
wallets = walletList )
if (walletList.length > 0 && !currentWalletId) { if (walletList) {
currentWalletId = walletList[0].id wallets = walletList
if (walletList.length > 0 && !currentWalletId) {
currentWalletId = walletList[0].id
}
} }
} await checkBiometric()
await checkBiometric() ready = true
ready = true })()
return initPromise
} }
const login = async () => { 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 ( const send = async (
to: string, to: string,
amount: string, amount: string,
@@ -399,6 +435,12 @@ export const XrpWallet = () => {
get wallets() { get wallets() {
return wallets return wallets
}, },
get balances() {
return balances
},
get balancesLoading() {
return balancesLoading
},
get currentWalletId() { get currentWalletId() {
return currentWalletId return currentWalletId
}, },
@@ -424,6 +466,7 @@ export const XrpWallet = () => {
login, login,
logout, logout,
refresh, refresh,
fetchBalances,
createWallet, createWallet,
importWallet, importWallet,
lock, 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() export const xrpWallet = XrpWallet()
+20 -88
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { moneroWallet } from '$lib/states/monero-wallet.svelte.js' import { moneroWallet } from '$lib/states/monero-wallet.svelte.js'
import { xrpWallet } from '$lib/states/xrp-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 { navigate } from 'sv-router/generated'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { import {
@@ -17,29 +16,19 @@
import Loader from '$lib/components/loader.svelte' import Loader from '$lib/components/loader.svelte'
import { frontendLog, frontendLogError } from '$lib/electrobun.js' 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) let feError = $state<string | null>(null)
const usd = (amount: string, price: string | null) =>
price ? parseFloat(amount) * parseFloat(price) : 0
const xrpTotalUsd = $derived.by(() => { const xrpTotalUsd = $derived.by(() => {
const price = xrpPrice const p = xrpWallet.price
return xrpWallet.wallets.reduce((sum, w) => { return xrpWallet.wallets.reduce((sum, w) => {
const b = xrpBalances[w.address]?.balance ?? 0 const b = xrpWallet.balances[w.address]?.balance ?? 0
return sum + (price ? b * parseFloat(price) : 0) return sum + (p ? b * parseFloat(p) : 0)
}, 0) }, 0)
}) })
const xrpTotalBalance = $derived.by(() => const xrpTotalBalance = $derived.by(() =>
xrpWallet.wallets.reduce((sum, w) => { xrpWallet.wallets.reduce((sum, w) => {
return sum + (xrpBalances[w.address]?.balance ?? 0) return sum + (xrpWallet.balances[w.address]?.balance ?? 0)
}, 0), }, 0),
) )
@@ -47,8 +36,8 @@
moneroWallet.walletOpen ? parseFloat(moneroWallet.balance || '0') : 0, moneroWallet.walletOpen ? parseFloat(moneroWallet.balance || '0') : 0,
) )
const xmrTotalUsd = $derived( const xmrTotalUsd = $derived(
moneroWallet.walletOpen && xmrPrice moneroWallet.walletOpen && moneroWallet.price
? xmrBalance * parseFloat(xmrPrice) ? xmrBalance * parseFloat(moneroWallet.price)
: 0, : 0,
) )
@@ -70,80 +59,21 @@
maximumFractionDigits: dp, 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(() => { onMount(() => {
frontendLog('dashboard mounted') 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(() => { $effect(() => {
if (xrpWallet.ready && xrpWallet.wallets.length > 0) { if (xrpWallet.ready && xrpWallet.wallets.length > 0) {
fetchXrp() xrpWallet.fetchBalances()
}
})
// Self-heal: if the monero wallet list never got loaded (e.g. the server
// was starting while the dashboard rendered), retry login once.
$effect(() => {
if (
moneroWallet.ready &&
!moneroWallet.walletOpen &&
!moneroWallet.opening &&
moneroWallet.wallets.length === 0 &&
!moneroWallet.error
) {
void moneroWallet.login()
} }
}) })
$effect(() => { $effect(() => {
if (moneroWallet.walletOpen) { if (moneroWallet.walletOpen) {
fetchXmr() moneroWallet.fetchPrice()
} }
}) })
@@ -205,7 +135,7 @@
{:else} {:else}
<div class="flex flex-col items-center pt-4 pb-2"> <div class="flex flex-col items-center pt-4 pb-2">
<p class="text-xs text-muted-foreground">Total Portfolio Value</p> <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> <div class="mt-2 h-9 w-44 rounded-md bg-muted animate-pulse"></div>
{:else} {:else}
<p class="mt-1 text-3xl font-semibold tabular-nums"> <p class="mt-1 text-3xl font-semibold tabular-nums">
@@ -229,16 +159,16 @@
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent class="flex flex-1 flex-col justify-center"> <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> <div class="h-14 flex items-center"><Loader /></div>
{:else} {:else}
<p class="font-mono text-xl tabular-nums">{fmtBal(xrpTotalBalance)}</p> <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"> <p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
${fmtUsd(xrpTotalUsd)} ${fmtUsd(xrpTotalUsd)}
</p> </p>
<p class="mt-2 text-xs text-muted-foreground"> <p class="mt-2 text-xs text-muted-foreground">
${fmtUsd(parseFloat(xrpPrice) )} / XRP ${fmtUsd(parseFloat(xrpWallet.price) )} / XRP
</p> </p>
{/if} {/if}
{/if} {/if}
@@ -266,12 +196,16 @@
<CardContent class="flex flex-1 flex-col justify-center"> <CardContent class="flex flex-1 flex-col justify-center">
{#if moneroWallet.walletOpen} {#if moneroWallet.walletOpen}
<p class="font-mono text-xl tabular-nums">{fmtBal(xmrBalance, 4)}</p> <p class="font-mono text-xl tabular-nums">{fmtBal(xmrBalance, 4)}</p>
{#if xmrPrice} {#if moneroWallet.syncing}
<p class="mt-0.5 text-xs text-muted-foreground">Syncing…</p>
{:else if moneroWallet.price}
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums"> <p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
${fmtUsd(xmrTotalUsd)} ${fmtUsd(xmrTotalUsd)}
</p> </p>
{/if}
{#if moneroWallet.price}
<p class="mt-2 text-xs text-muted-foreground"> <p class="mt-2 text-xs text-muted-foreground">
${fmtUsd(parseFloat(xmrPrice) )} / XMR ${fmtUsd(parseFloat(moneroWallet.price) )} / XMR
</p> </p>
{/if} {/if}
{:else if moneroWallet.passwordRequired && moneroWallet.wallets.length > 0} {:else if moneroWallet.passwordRequired && moneroWallet.wallets.length > 0}
@@ -299,8 +233,6 @@
<Loader /> <Loader />
<p class="text-sm text-muted-foreground">Opening wallet...</p> <p class="text-sm text-muted-foreground">Opening wallet...</p>
</div> </div>
{:else if loadingXmr}
<div class="h-14 flex items-center"><Loader /></div>
{:else} {:else}
<Button <Button
onclick={() => navigate('/monero')} onclick={() => navigate('/monero')}
+1
View File
@@ -31,6 +31,7 @@
if (!xrpWallet.ready) await xrpWallet.init() if (!xrpWallet.ready) await xrpWallet.init()
if (!moneroWallet.ready) await moneroWallet.init() if (!moneroWallet.ready) await moneroWallet.init()
await Promise.allSettled([ await Promise.allSettled([
xrpWallet.login(),
xrpWallet.autoUnlock(), xrpWallet.autoUnlock(),
moneroWallet.login(), moneroWallet.login(),
]) ])
@@ -24,8 +24,6 @@
let unlocking = $state(false) let unlocking = $state(false)
let unlockError = $state('') let unlockError = $state('')
let initStarted = false
async function unlockWithPassword() { async function unlockWithPassword() {
if (unlocking || !walletPassword.trim()) return if (unlocking || !walletPassword.trim()) return
unlocking = true unlocking = true
@@ -48,16 +46,6 @@
} }
}) })
$effect(() => {
if (!initStarted) {
initStarted = true
w.init().then(async () => {
if (w.accountType === 'monero') return
await w.login()
})
}
})
$effect(() => { $effect(() => {
if (w.running && !w.walletOpen && w.wallets.length > 0 && !w.loading && !w.opening) { if (w.running && !w.walletOpen && w.wallets.length > 0 && !w.loading && !w.opening) {
w.autoUnlock(w.wallets[0]) w.autoUnlock(w.wallets[0])
@@ -15,10 +15,6 @@
const w = moneroWallet const w = moneroWallet
let moneroSettingsPw = $state('') let moneroSettingsPw = $state('')
$effect(() => {
w.init()
})
</script> </script>
<div class="mx-auto max-w-xl"> <div class="mx-auto max-w-xl">
@@ -203,7 +203,7 @@
{:else} {:else}
<div class="space-y-6"> <div class="space-y-6">
<div class="flex flex-col items-center py-2"> <div class="flex flex-col items-center py-2">
{#if w.loading} {#if w.loading && !w.syncing}
<div class="h-12 w-40 rounded-lg bg-muted animate-pulse"></div> <div class="h-12 w-40 rounded-lg bg-muted animate-pulse"></div>
{:else} {:else}
<p class="text-sm text-muted-foreground">Balance</p> <p class="text-sm text-muted-foreground">Balance</p>
@@ -223,6 +223,14 @@
Unlocked: {w.unlocked} XMR Unlocked: {w.unlocked} XMR
</p> </p>
{/if} {/if}
{#if w.syncing}
<div class="mt-2 flex items-center gap-2">
<Loader />
<p class="text-xs text-muted-foreground">
Syncing wallet — balance updates when the scan completes
</p>
</div>
{/if}
<div class="mt-2 flex items-center gap-1.5"> <div class="mt-2 flex items-center gap-1.5">
<div class="size-2 rounded-full {w.connected ? 'bg-green-500' : 'bg-red-500'}"></div> <div class="size-2 rounded-full {w.connected ? 'bg-green-500' : 'bg-red-500'}"></div>
<span class="text-xs text-muted-foreground"> <span class="text-xs text-muted-foreground">
-12
View File
@@ -22,18 +22,6 @@
let inputUnlockPassword = $state('') let inputUnlockPassword = $state('')
let newSeed = $state<string | null>(null) 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(() => { $effect(() => {
if (w.accountType === 'xrp' && w.currentWalletId && w.seed && !newSeed) { if (w.accountType === 'xrp' && w.currentWalletId && w.seed && !newSeed) {
navigate('/xrp/wallet/:id', { navigate('/xrp/wallet/:id', {