18 Commits
26 changed files with 954 additions and 241 deletions
+5 -1
View File
@@ -1,6 +1,10 @@
# Instructions for AI assistants # Instructions for AI assistants
- Never commit changes unless explicitly told to do so. Always ask first or wait for the user's command. - Never commit changes unless explicitly told to do so. Always ask first or wait for the user's command.
- "Apply the changes" / "apply it" / "do it" does NOT mean commit. It means make the file changes. Before any commit (main repo, tap repo, anywhere), stop and ask for explicit permission, and list which files will be committed. Wait for a clear "yes" / "commit it" / "commit and push" before running `git commit`.
- Never manually edit `.router/router.ts` — sv-router auto-generates it from the filesystem. Just create the route file in the correct filesystem location. - Never manually edit `.router/router.ts` — sv-router auto-generates it from the filesystem. Just create the route file in the correct filesystem location.
- Only commit when explicitly told to do so. When asked to commit, always write a single simple line commit message. - Only commit when explicitly told to do so. When asked to commit, always write a single simple line commit message.
- At the end of every response, always surface a "Pending from you" section listing anything you need from the user (decisions, permissions, credentials, confirming a plan, etc.). If nothing is pending, say "Nothing pending." - When asked to publish, always rebuild the DMG first (`bun run build:stable` from `apps/desktop`), wait for notarization to complete, verify with `xcrun stapler validate`, then publish the fresh DMG. Never publish a stale build.
- Never publish without explicit permission. Before any publish action — running `bun run build:stable`, `gh release upload`, updating the Homebrew cask, or pushing the tap repo — describe the intended action (what will be built, what SHA, which release) and ask for permission. Once you have asked and received a clear affirmative (any clear yes, "go", "publish", etc.), you may proceed through the whole pipeline without pausing between steps: build, notarize, verify, upload to the GitHub release, update the Homebrew cask, and push the tap repo. One permission covers the entire publish flow. You must still get a separate permission before starting each new publish cycle.
- Permission to commit ("commit", "commit and push", or similar) authorizes `git commit` and also `git push` of the committed changes. Only push the branches/files described in the permission.
- At the end of every response, always surface a "Pending from you" section listing anything you need from the user (decisions, permissions, credentials, confirming a plan, etc.). If nothing is pending, say "Nothing pending." List each pending item as a numbered entry (`1.`, `2.`, …) so the user can approve or act on them by number (e.g. "go ahead with 1, 2").
+3
View File
@@ -27,3 +27,6 @@ dist-ssr
artifacts artifacts
.env .env
.env.local .env.local
# compiled native helper (build artifact; source is biometric-helper.swift)
src/bun/native/biometric-helper
+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.1', version: '0.0.8',
}, },
build: { build: {
// Vite builds to dist/, we copy from there // Vite builds to dist/, we copy from there
@@ -1 +0,0 @@
https://icon.msgbyte.com/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 B

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 789 KiB

+30 -20
View File
@@ -11,9 +11,16 @@ import { existsSync } from 'fs'
import { getENV } from './lib/get-env' import { getENV } from './lib/get-env'
import { createMoneroHandlers } from './rpc/monero' import { createMoneroHandlers } from './rpc/monero'
import { createXrpHandlers } from './rpc/xrp' import { createXrpHandlers } from './rpc/xrp'
import { stop as walletStop } from './lib/monero'
import type { MoneroWalletState } from './lib/monero' import type { MoneroWalletState } from './lib/monero'
import { canPromptTouchID, promptTouchID } from './lib/biometric' import { canPromptTouchID, promptTouchID } from './lib/biometric'
import { getXrpWalletList } from './lib/xrp' import {
getSecret,
setSecret,
deleteSecret,
clearSecretsCache,
VAULT_SERVICE,
} from './lib/secrets-cache'
import type { RPC } from '../lib/rpc-schema' import type { RPC } from '../lib/rpc-schema'
import { log } from './lib/logger' import { log } from './lib/logger'
@@ -78,6 +85,24 @@ const moneroState = {
downloading: false, downloading: false,
} }
async function stopWalletServers(): Promise<void> {
if (moneroState.manager) {
const manager = moneroState.manager
moneroState.manager = null
await walletStop(manager)
}
}
// Save the monero wallet and shut down wallet-rpc gracefully on app quit
// (otherwise an orphaned process keeps the scan progress only in memory).
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.on(signal, () => {
log(`${signal} received, stopping wallet servers...`)
void stopWalletServers().finally(() => process.exit(0))
setTimeout(() => process.exit(0), 5000).unref?.()
})
}
const rpc = BrowserView.defineRPC<RPC>({ const rpc = BrowserView.defineRPC<RPC>({
maxRequestTime: 120000, maxRequestTime: 120000,
handlers: { handlers: {
@@ -89,23 +114,8 @@ const rpc = BrowserView.defineRPC<RPC>({
rpc.setRequestHandler({ rpc.setRequestHandler({
resetApp: async () => { resetApp: async () => {
try { try {
const wallets = await getXrpWalletList() await deleteSecret(VAULT_SERVICE, 'vault')
await Promise.all([ clearSecretsCache()
Bun.secrets.delete({
service: 'koins',
name: 'xrp_wallets',
}),
...wallets.flatMap((w) => [
Bun.secrets.delete({
service: 'koins',
name: w.vaultKey,
}),
Bun.secrets.delete({
service: 'koins',
name: `xrp_auth_${w.id}`,
}),
]),
])
console.log('[rpc] resetApp complete') console.log('[rpc] resetApp complete')
return true return true
} catch (e) { } catch (e) {
@@ -131,10 +141,10 @@ rpc.setRequestHandler({
} }
}, },
getSecret: async ({ name, service }) => { getSecret: async ({ name, service }) => {
return await Bun.secrets.get({ name, service }) return await getSecret(service, name)
}, },
setSecret: async ({ name, service, value }) => { setSecret: async ({ name, service, value }) => {
await Bun.secrets.set({ name, service, value }) await setSecret(service, name, value)
}, },
openExternal: async ({ url }) => { openExternal: async ({ url }) => {
Utils.openExternal(url) Utils.openExternal(url)
+210 -53
View File
@@ -1,17 +1,14 @@
import { import { MoneroWalletRpc, MoneroRpcConnection, connectToWalletRpc, connectToDaemonRpc } from 'monero-ts'
MoneroWalletRpc,
MoneroRpcConnection,
connectToWalletRpc,
connectToDaemonRpc,
} from 'monero-ts'
import { getBinaryPath, getWalletDir } from './binary' import { getBinaryPath, getWalletDir } from './binary'
import { existsSync, readdirSync } from 'fs' import { existsSync, readdirSync } from 'fs'
import { log, logError } from '../logger'
const DEFAULT_DAEMON = 'xmr-node.cakewallet.com:18081' const DEFAULT_DAEMON = 'xmr-node.cakewallet.com:18081'
export type MoneroWalletState = { export type MoneroWalletState = {
wallet: MoneroWalletRpc | null wallet: MoneroWalletRpc | null
process: Bun.Subprocess | null process: Bun.Subprocess | null
saveTimer: ReturnType<typeof setInterval> | null
rpcPort: number rpcPort: number
rpcUser: string rpcUser: string
rpcPassword: string rpcPassword: string
@@ -22,6 +19,7 @@ export function createMoneroWalletState(): MoneroWalletState {
return { return {
wallet: null, wallet: null,
process: null, process: null,
saveTimer: null,
rpcPort: 28084, rpcPort: 28084,
rpcUser: 'koins', rpcUser: 'koins',
rpcPassword: 'koins-local-rpc', rpcPassword: 'koins-local-rpc',
@@ -43,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
@@ -63,20 +88,17 @@ async function waitForRpc(
let lastErr = '' let lastErr = ''
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
try { try {
await Bun.connect({ const conn = rpcConnection(state)
hostname: '127.0.0.1', const res = await withTimeout(
port: state.rpcPort, conn.sendJsonRequest('get_version', {}),
socket: { 5000,
open(s) { 'wallet-rpc probe',
s.end() )
}, if (res?.result?.version) {
close() {}, console.log(`[monero] RPC ready after ${Date.now() - start}ms`)
data() {}, return
error() {}, }
}, lastErr = JSON.stringify(res)
})
console.log(`[monero] RPC ready after ${Date.now() - start}ms`)
return
} catch (e) { } catch (e) {
lastErr = String(e) lastErr = String(e)
} }
@@ -87,6 +109,28 @@ async function waitForRpc(
) )
} }
async function connectClient(
state: MoneroWalletState,
): Promise<MoneroWalletRpc> {
let lastErr = ''
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const client = (await connectToWalletRpc(
rpcUrl(state),
state.rpcUser,
state.rpcPassword,
)) as MoneroWalletRpc
console.log(`[monero] MoneroWalletRpc connected (attempt ${attempt})`)
return client
} catch (e) {
lastErr = String(e)
console.log(`[monero] connectToWalletRpc attempt ${attempt} failed:`, e)
if (attempt < 3) await new Promise((r) => setTimeout(r, 1000))
}
}
throw new Error(`Failed to connect to monero-wallet-rpc: ${lastErr}`)
}
export async function start( export async function start(
state: MoneroWalletState, state: MoneroWalletState,
daemonAddress: string = DEFAULT_DAEMON, daemonAddress: string = DEFAULT_DAEMON,
@@ -97,17 +141,44 @@ export async function start(
) )
state.daemonAddress = daemonAddress state.daemonAddress = daemonAddress
// Kill any existing monero-wallet-rpc on the port (stale process from dev reloads) // Kill any existing monero-wallet-rpc on the port (stale process from
// dev reloads or an unclean quit). Prefer SIGTERM so a live wallet gets
// a chance to save its scan progress before exiting.
try { try {
const proc = Bun.spawnSync(['lsof', '-ti', `tcp:${state.rpcPort}`], { const proc = Bun.spawnSync(['lsof', '-ti', `tcp:${state.rpcPort}`], {
stdout: 'pipe', stdout: 'pipe',
}) })
const pids = proc.stdout.toString().trim().split('\n').filter(Boolean) const pids = proc.stdout.toString().trim().split('\n').filter(Boolean)
for (const pid of pids) { if (pids.length) {
try { console.log(
process.kill(Number(pid), 'SIGKILL') `[monero] stale process(es) on port ${state.rpcPort}: ${pids.join(', ')}`,
console.log(`[monero] killed stale process ${pid} on port ${state.rpcPort}`) )
} catch {} for (const pid of pids) {
try {
process.kill(Number(pid), 'SIGTERM')
} catch {}
}
const deadline = Date.now() + 5000
while (Date.now() < deadline) {
const check = Bun.spawnSync(
['lsof', '-ti', `tcp:${state.rpcPort}`],
{ stdout: 'pipe' },
)
if (!check.stdout.toString().trim()) break
await new Promise((r) => setTimeout(r, 250))
}
const leftover = Bun.spawnSync(
['lsof', '-ti', `tcp:${state.rpcPort}`],
{ stdout: 'pipe' },
)
for (const pid of leftover.stdout.toString().trim().split('\n').filter(Boolean)) {
try {
process.kill(Number(pid), 'SIGKILL')
console.log(
`[monero] killed unresponsive process ${pid} on port ${state.rpcPort}`,
)
} catch {}
}
} }
} catch {} } catch {}
@@ -140,10 +211,33 @@ export async function start(
`[monero] wallet-rpc process exited with code ${exitCode}`, `[monero] wallet-rpc process exited with code ${exitCode}`,
error?.message ?? '', error?.message ?? '',
) )
log(
`[monero] wallet-rpc process exited with code ${exitCode} ${error?.message ?? ''}`,
)
if (state.saveTimer) {
clearInterval(state.saveTimer)
state.saveTimer = null
}
state.wallet = null
state.process = null
}, },
}) })
console.log(`[monero] wallet-rpc pid: ${state.process.pid}`) console.log(`[monero] wallet-rpc pid: ${state.process.pid}`)
// Auto-save the wallet every 60s so scan progress survives an
// unexpected kill (without `store`, the wallet file keeps its old
// refresh height and every restart resyncs from block 0).
state.saveTimer = setInterval(async () => {
try {
if (state.wallet) {
await rawRpc(state, 'store')
console.log(`[monero] wallet auto-saved`)
}
} catch (e) {
console.log(`[monero] wallet auto-save failed:`, e)
}
}, 60000)
const reader = (state.process!.stdout as ReadableStream<Uint8Array>).getReader() const reader = (state.process!.stdout as ReadableStream<Uint8Array>).getReader()
;(async () => { ;(async () => {
while (true) { while (true) {
@@ -173,27 +267,48 @@ export async function start(
console.log(`[monero] RPC server is ready`) console.log(`[monero] RPC server is ready`)
console.log(`[monero] connecting MoneroWalletRpc client...`) console.log(`[monero] connecting MoneroWalletRpc client...`)
state.wallet = (await connectToWalletRpc( state.wallet = await connectClient(state)
rpcUrl(state),
state.rpcUser,
state.rpcPassword,
)) as MoneroWalletRpc
console.log(`[monero] MoneroWalletRpc connected successfully`) console.log(`[monero] MoneroWalletRpc connected successfully`)
log(`[monero] wallet-rpc started, RPC ready`)
} }
export async function stop(state: MoneroWalletState) { export async function stop(state: MoneroWalletState) {
console.log(`[monero] stopping wallet manager...`) console.log(`[monero] stopping wallet manager...`)
if (state.saveTimer) {
clearInterval(state.saveTimer)
state.saveTimer = null
}
// Persist scan progress before shutting down.
try {
if (state.wallet) {
await rawRpc(state, 'store')
console.log(`[monero] wallet saved before stop`)
log(`[monero] wallet saved before stop`)
}
} catch (e) {
console.log(`[monero] wallet save before stop failed:`, e)
}
if (state.process) { if (state.process) {
console.log( const proc = state.process
`[monero] killing wallet-rpc process (pid ${state.process.pid})`, console.log(`[monero] sending SIGTERM to wallet-rpc (pid ${proc.pid})`)
) proc.kill()
state.process.kill() await Promise.race([proc.exited, sleep(3000)])
if (proc.exitCode === null) {
console.log(
`[monero] wallet-rpc did not exit gracefully, sending SIGKILL`,
)
proc.kill('SIGKILL')
}
state.process = null state.process = null
} }
state.wallet = null state.wallet = null
console.log(`[monero] wallet manager stopped`) console.log(`[monero] wallet manager stopped`)
} }
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
export async function createWallet( export async function createWallet(
state: MoneroWalletState, state: MoneroWalletState,
name: string, name: string,
@@ -206,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 }
} }
@@ -237,9 +356,20 @@ export async function openWallet(
password: string, password: string,
) { ) {
console.log(`[monero] opening wallet: ${name}`) console.log(`[monero] opening wallet: ${name}`)
if (!state.wallet) throw new Error('Wallet RPC not started') // Self-heal: the client connection may have been lost while the
await state.wallet.openWallet(name, password) // wallet-rpc process is still running (e.g. stale-process cleanup).
console.log(`[monero] wallet opened: ${name}`) if (!state.wallet) {
if (!state.process) throw new Error('Wallet RPC not started')
log(`[monero] reconnecting wallet client before open (${name})`)
state.wallet = await connectClient(state)
}
try {
await withTimeout(state.wallet.openWallet(name, password), 30000, `open_wallet ${name}`)
log(`[monero] wallet opened: ${name}`)
} catch (e) {
logError(`[monero] wallet open failed (${name})`, e)
throw e
}
} }
export async function closeWallet(state: MoneroWalletState) { export async function closeWallet(state: MoneroWalletState) {
@@ -253,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 }
} }
@@ -265,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
@@ -303,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) {
@@ -448,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
} }
@@ -492,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) {
@@ -570,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
} }
} }
@@ -582,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)
+87
View File
@@ -0,0 +1,87 @@
import { getENV } from './get-env'
const env = getENV()
export const VAULT_SERVICE = env.appName
const VAULT_NAME = 'vault'
const cache = new Map<string, string>()
const key = (service: string, name: string) => `${service}:${name}`
let loaded = false
let loadPromise: Promise<void> | null = null
let writeChain: Promise<void> = Promise.resolve()
async function ensureLoaded(): Promise<void> {
if (loaded) return
if (!loadPromise) {
loadPromise = (async () => {
const raw = await Bun.secrets.get({
service: VAULT_SERVICE,
name: VAULT_NAME,
})
if (raw) {
try {
const data = JSON.parse(raw) as Record<string, string>
for (const [k, v] of Object.entries(data)) cache.set(k, v)
} catch (e) {
console.log('[secrets] vault parse error:', e)
}
}
loaded = true
})()
}
await loadPromise
}
async function persist(): Promise<void> {
const raw = JSON.stringify(Object.fromEntries(cache))
writeChain = writeChain.then(() =>
Bun.secrets.set({
service: VAULT_SERVICE,
name: VAULT_NAME,
value: raw,
}),
)
return writeChain
}
export async function getSecret(
service: string,
name: string,
): Promise<string | null> {
const k = key(service, name)
await ensureLoaded()
return cache.get(k) ?? null
}
export async function setSecret(
service: string,
name: string,
value: string,
): Promise<void> {
await ensureLoaded()
cache.set(key(service, name), value)
await persist()
}
export async function deleteSecret(
service: string,
name: string,
): Promise<void> {
if (service === VAULT_SERVICE && name === VAULT_NAME) {
cache.clear()
loaded = false
loadPromise = null
await Bun.secrets.delete({ service, name })
return
}
await ensureLoaded()
cache.delete(key(service, name))
await persist()
}
export function clearSecretsCache(): void {
cache.clear()
loaded = false
loadPromise = null
}
+3 -6
View File
@@ -11,6 +11,7 @@ import type {
XrpTxEntry, XrpTxEntry,
XrpWalletInfo, XrpWalletInfo,
} from '../../../lib/rpc-schema' } from '../../../lib/rpc-schema'
import { getSecret, setSecret } from '../secrets-cache'
const SERVER = 'wss://s1.ripple.com' const SERVER = 'wss://s1.ripple.com'
const RIPPLE_EPOCH = 946684800 const RIPPLE_EPOCH = 946684800
@@ -54,7 +55,7 @@ export function walletForSigning(secret: string): Wallet {
} }
export async function getXrpWalletList(): Promise<XrpWalletInfo[]> { export async function getXrpWalletList(): Promise<XrpWalletInfo[]> {
const raw = await Bun.secrets.get({ service: SERVICE, name: WALLET_LIST_KEY }) const raw = await getSecret(SERVICE, WALLET_LIST_KEY)
if (!raw) return [] if (!raw) return []
try { try {
return JSON.parse(raw) return JSON.parse(raw)
@@ -66,11 +67,7 @@ export async function getXrpWalletList(): Promise<XrpWalletInfo[]> {
export async function saveXrpWalletList( export async function saveXrpWalletList(
wallets: XrpWalletInfo[], wallets: XrpWalletInfo[],
): Promise<void> { ): Promise<void> {
await Bun.secrets.set({ await setSecret(SERVICE, WALLET_LIST_KEY, JSON.stringify(wallets))
service: SERVICE,
name: WALLET_LIST_KEY,
value: JSON.stringify(wallets),
})
} }
export async function getBalance(address: string): Promise<XrpBalance> { export async function getBalance(address: string): Promise<XrpBalance> {
Binary file not shown.
+53 -27
View File
@@ -24,6 +24,7 @@ import {
isConnected, isConnected,
} from '../../lib/monero' } from '../../lib/monero'
import type { MoneroWalletState } from '../../lib/monero' import type { MoneroWalletState } from '../../lib/monero'
import { log, logError } from '../../lib/logger'
export function createMoneroHandlers(state: { export function createMoneroHandlers(state: {
manager: MoneroWalletState | null manager: MoneroWalletState | null
@@ -64,10 +65,12 @@ export function createMoneroHandlers(state: {
await walletStart(state.manager, daemonAddress) await walletStart(state.manager, daemonAddress)
} catch (e) { } catch (e) {
console.log('[rpc] moneroStart error:', e) console.log('[rpc] moneroStart error:', e)
logError('[rpc] moneroStart error', e)
return { running: false, walletOpen: false, connected: false } return { running: false, walletOpen: false, connected: false }
} }
const connected = await isConnected(state.manager) const connected = await isConnected(state.manager)
console.log('[rpc] moneroStart complete, connected:', connected) console.log('[rpc] moneroStart complete, connected:', connected)
log(`[rpc] moneroStart complete, connected: ${connected}`)
return { running: true, walletOpen: false, connected } return { running: true, walletOpen: false, connected }
}, },
moneroStop: async () => { moneroStop: async () => {
@@ -105,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(
@@ -129,31 +139,42 @@ export function createMoneroHandlers(state: {
console.log('[rpc] moneroOpenWallet:', name) console.log('[rpc] moneroOpenWallet:', name)
if (!state.manager) if (!state.manager)
throw new Error('Monero wallet RPC not started') throw new Error('Monero wallet RPC not started')
await walletOpen(state.manager, name, password) try {
await walletOpen(state.manager, name, password)
log(`[rpc] moneroOpenWallet ok: ${name}`)
} catch (e) {
logError(`[rpc] moneroOpenWallet failed (${name})`, e)
throw e
}
}, },
moneroGetBalance: async () => { moneroGetBalance: async () => {
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 ({
@@ -164,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) {
+6 -9
View File
@@ -10,6 +10,7 @@ import {
getFee, getFee,
} from '../../lib/xrp' } from '../../lib/xrp'
import type { XrpWalletInfo } from '../../../lib/rpc-schema' import type { XrpWalletInfo } from '../../../lib/rpc-schema'
import { getSecret, setSecret, deleteSecret } from '../../lib/secrets-cache'
const SERVICE = 'koins' const SERVICE = 'koins'
@@ -30,7 +31,7 @@ export function createXrpHandlers() {
const { address, seed } = generateWallet() const { address, seed } = generateWallet()
const id = crypto.randomUUID() const id = crypto.randomUUID()
const vaultKey = `xrp_seed_${id}` const vaultKey = `xrp_seed_${id}`
await Bun.secrets.set({ service: SERVICE, name: vaultKey, value: seed }) await setSecret(SERVICE, vaultKey, seed)
const wallets = await getXrpWalletList() const wallets = await getXrpWalletList()
const createdAt = new Date().toISOString() const createdAt = new Date().toISOString()
const info: XrpWalletInfo = { const info: XrpWalletInfo = {
@@ -58,11 +59,7 @@ export function createXrpHandlers() {
const { address } = walletFromSecret(secret) const { address } = walletFromSecret(secret)
const id = crypto.randomUUID() const id = crypto.randomUUID()
const vaultKey = `xrp_seed_${id}` const vaultKey = `xrp_seed_${id}`
await Bun.secrets.set({ await setSecret(SERVICE, vaultKey, secret.trim())
service: SERVICE,
name: vaultKey,
value: secret.trim(),
})
const wallets = await getXrpWalletList() const wallets = await getXrpWalletList()
const createdAt = new Date().toISOString() const createdAt = new Date().toISOString()
const info: XrpWalletInfo = { const info: XrpWalletInfo = {
@@ -83,15 +80,15 @@ export function createXrpHandlers() {
const wallet = wallets.find((w) => w.id === id) const wallet = wallets.find((w) => w.id === id)
if (!wallet) throw new Error('Wallet not found') if (!wallet) throw new Error('Wallet not found')
await Promise.all([ await Promise.all([
Bun.secrets.delete({ service: SERVICE, name: wallet.vaultKey }), deleteSecret(SERVICE, wallet.vaultKey),
Bun.secrets.delete({ service: SERVICE, name: `xrp_auth_${id}` }), deleteSecret(SERVICE, `xrp_auth_${id}`),
]) ])
await saveXrpWalletList(wallets.filter((w) => w.id !== id)) await saveXrpWalletList(wallets.filter((w) => w.id !== id))
console.log('[rpc] xrpDeleteWallet complete') console.log('[rpc] xrpDeleteWallet complete')
}, },
xrpGetSeed: async ({ vaultKey }: { vaultKey: string }) => { xrpGetSeed: async ({ vaultKey }: { vaultKey: string }) => {
console.log('[rpc] xrpGetSeed:', vaultKey) console.log('[rpc] xrpGetSeed:', vaultKey)
const seed = await Bun.secrets.get({ service: SERVICE, name: vaultKey }) const seed = await getSecret(SERVICE, vaultKey)
if (!seed) throw new Error('Seed not found in keychain') if (!seed) throw new Error('Seed not found in keychain')
return seed return seed
}, },
+1 -1
View File
@@ -17,7 +17,7 @@ export type {
} from './rpc-schema' } from './rpc-schema'
const rpc = Electroview.defineRPC<RPC>({ const rpc = Electroview.defineRPC<RPC>({
maxRequestTime: 10000, maxRequestTime: 120000,
handlers: { handlers: {
requests: {}, requests: {},
messages: {}, messages: {},
@@ -1,5 +1,7 @@
import { import {
electrobun, electrobun,
frontendLog,
frontendLogError,
type MoneroTxEntry, type MoneroTxEntry,
type MoneroAccountEntry, type MoneroAccountEntry,
type MoneroSendResult, type MoneroSendResult,
@@ -46,6 +48,42 @@ export const MoneroWallet = () => {
let loading = $state(false) let loading = $state(false)
let error = $state('') let error = $state('')
let selectedAccountIndex = $state(0) let selectedAccountIndex = $state(0)
let opening = $state(false)
let openingDepth = 0
let passwordRequired = $state(false)
let startInFlight: Promise<{ running: boolean; connected: boolean }> | 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
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 = () => {
openingDepth += 1
opening = true
}
const endOpening = () => {
openingDepth = Math.max(0, openingDepth - 1)
if (openingDepth === 0) opening = false
}
const checkBiometric = async () => { const checkBiometric = async () => {
if (!electrobun.rpc) return if (!electrobun.rpc) return
@@ -76,15 +114,43 @@ export const MoneroWallet = () => {
} }
const login = async () => { const login = async () => {
accountType = 'monero' if (walletOpen) return
if (installed && !running) { if (loginInFlight) return loginInFlight
await start() loginInFlight = (async () => {
await checkStatus() accountType = 'monero'
} error = ''
await listWallets() beginOpening()
if (wallets.length > 0) { try {
const pw = await moneroGetStoredPassword(wallets[0]) if (installed && !running) {
if (pw) await openExistingWallet(wallets[0], pw) await start()
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) {
error = 'Wallet server failed to start. Please try again.'
return
}
await listWallets()
if (wallets.length > 0) {
const pw = await moneroGetStoredPassword(wallets[0])
if (pw) await openExistingWallet(wallets[0], pw)
else passwordRequired = true
}
error = ''
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to open wallet'
} finally {
endOpening()
}
})()
try {
await loginInFlight
} finally {
loginInFlight = null
} }
} }
@@ -143,26 +209,39 @@ export const MoneroWallet = () => {
if (!electrobun.rpc) throw new Error('RPC not available') if (!electrobun.rpc) throw new Error('RPC not available')
const pw = password ?? (await moneroGetStoredPassword(name)) const pw = password ?? (await moneroGetStoredPassword(name))
if (!pw) throw new Error('Password required') if (!pw) throw new Error('Password required')
await electrobun.rpc.request.moneroOpenWallet({ beginOpening()
name, try {
password: pw, await electrobun.rpc.request.moneroOpenWallet({
}) name,
walletName = name password: pw,
walletOpen = true })
await refresh() walletName = name
walletOpen = true
await refresh()
} finally {
endOpening()
}
} }
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
@@ -184,9 +263,20 @@ export const MoneroWallet = () => {
const start = async () => { const start = async () => {
if (!electrobun.rpc) return if (!electrobun.rpc) return
const status = await electrobun.rpc.request.moneroStart({}) beginOpening()
running = status.running try {
connected = status.connected if (!startInFlight) {
startInFlight = electrobun.rpc.request.moneroStart({})
const status = await startInFlight
running = status.running
connected = status.connected
} else {
await startInFlight
}
} finally {
startInFlight = null
endOpening()
}
} }
const stop = async () => { const stop = async () => {
@@ -195,6 +285,7 @@ export const MoneroWallet = () => {
running = false running = false
walletOpen = false walletOpen = false
connected = false connected = false
passwordRequired = false
address = '' address = ''
balAtomic = '0' balAtomic = '0'
unlockedAtomic = '0' unlockedAtomic = '0'
@@ -209,16 +300,21 @@ export const MoneroWallet = () => {
storePw?: boolean, storePw?: boolean,
) => { ) => {
if (!electrobun.rpc) throw new Error('RPC not available') if (!electrobun.rpc) throw new Error('RPC not available')
const result = await electrobun.rpc.request.moneroCreateWallet({ beginOpening()
name, try {
password, const result = await electrobun.rpc.request.moneroCreateWallet({
}) name,
walletName = name password,
walletOpen = true })
address = result.address walletName = name
if (storePw) await moneroStorePassword(name, password) walletOpen = true
await refresh() address = result.address
return result if (storePw) await moneroStorePassword(name, password)
await refresh()
return result
} finally {
endOpening()
}
} }
const restoreWallet = async ( const restoreWallet = async (
@@ -229,25 +325,35 @@ export const MoneroWallet = () => {
storePw?: boolean, storePw?: boolean,
) => { ) => {
if (!electrobun.rpc) throw new Error('RPC not available') if (!electrobun.rpc) throw new Error('RPC not available')
const result = await electrobun.rpc.request.moneroRestoreWallet({ beginOpening()
name, try {
password, const result = await electrobun.rpc.request.moneroRestoreWallet({
mnemonic, name,
restoreHeight, password,
}) mnemonic,
walletName = name restoreHeight,
walletOpen = true })
address = result.address walletName = name
if (storePw) await moneroStorePassword(name, password) walletOpen = true
await refresh() address = result.address
if (storePw) await moneroStorePassword(name, password)
await refresh()
} finally {
endOpening()
}
} }
const openWallet = async (name: string, password: string) => { const openWallet = async (name: string, password: string) => {
if (!electrobun.rpc) return if (!electrobun.rpc) return
await electrobun.rpc.request.moneroOpenWallet({ name, password }) beginOpening()
walletName = name try {
walletOpen = true await electrobun.rpc.request.moneroOpenWallet({ name, password })
await refresh() walletName = name
walletOpen = true
await refresh()
} finally {
endOpening()
}
} }
const autoUnlock = async (name?: string) => { const autoUnlock = async (name?: string) => {
@@ -255,43 +361,121 @@ export const MoneroWallet = () => {
const target = name ?? wallets[0] const target = name ?? wallets[0]
if (!target) return false if (!target) return false
const pw = await moneroGetStoredPassword(target) const pw = await moneroGetStoredPassword(target)
if (!pw) return false if (!pw) {
await electrobun.rpc.request.moneroOpenWallet({ passwordRequired = true
name: target, return false
password: pw, }
}) beginOpening()
walletName = target try {
walletOpen = true await electrobun.rpc.request.moneroOpenWallet({
await refresh() name: target,
return true password: pw,
})
walletName = target
walletOpen = true
passwordRequired = false
error = ''
await refresh()
return true
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to open wallet'
return false
} finally {
endOpening()
}
}
const unlockWallet = async (password: string, remember = false) => {
const target = wallets[0]
if (!target) return
await openWallet(target, password)
passwordRequired = false
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,
}),
)
txs = result ?? []
await fetchAccounts()
} catch (e) {
error = e instanceof Error ? e.message : 'Monero refresh failed'
} finally {
loading = false
}
} }
const send = async ( const send = async (
@@ -404,6 +588,15 @@ export const MoneroWallet = () => {
get loading() { get loading() {
return loading return loading
}, },
get opening() {
return opening
},
get passwordRequired() {
return passwordRequired
},
get syncing() {
return syncing
},
get error() { get error() {
return error return error
}, },
@@ -451,6 +644,7 @@ export const MoneroWallet = () => {
restoreWallet, restoreWallet,
openWallet, openWallet,
autoUnlock, autoUnlock,
unlockWallet,
refresh, refresh,
send, send,
sendAll, sendAll,
+58 -21
View File
@@ -22,6 +22,7 @@
let xrpBalances = $state<Record<string, { balance: number; funded: boolean }>>({}) let xrpBalances = $state<Record<string, { balance: number; funded: boolean }>>({})
let loadingXrp = $state(true) let loadingXrp = $state(true)
let loadingXmr = $state(true) let loadingXmr = $state(true)
let fetchXrpInFlight = false
let feError = $state<string | null>(null) let feError = $state<string | null>(null)
@@ -70,10 +71,13 @@
}) })
async function fetchXrp() { async function fetchXrp() {
if (fetchXrpInFlight) return
fetchXrpInFlight = true
loadingXrp = true loadingXrp = true
const rpc = electrobun.rpc const rpc = electrobun.rpc
if (!rpc) { if (!rpc) {
loadingXrp = false loadingXrp = false
fetchXrpInFlight = false
return return
} }
try { try {
@@ -92,6 +96,7 @@
}), }),
) )
loadingXrp = false loadingXrp = false
fetchXrpInFlight = false
} }
async function fetchXmr() { async function fetchXmr() {
@@ -111,12 +116,17 @@
onMount(() => { onMount(() => {
frontendLog('dashboard mounted') frontendLog('dashboard mounted')
moneroWallet.init().catch((e) => frontendLogError('moneroWallet.init() failed', e)) moneroWallet.init().catch((e) => frontendLogError('moneroWallet.init() failed', e))
xrpWallet.init().then(async () => { xrpWallet.init().catch((e) => frontendLogError('xrpWallet.init() failed', e))
await fetchXrp()
})
fetchXmr() fetchXmr()
}) })
// Wait for the XRP wallet list to load, then fetch balances.
$effect(() => {
if (xrpWallet.ready && xrpWallet.wallets.length > 0) {
fetchXrp()
}
})
$effect(() => { $effect(() => {
if (moneroWallet.walletOpen) { if (moneroWallet.walletOpen) {
fetchXmr() fetchXmr()
@@ -181,7 +191,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} {#if loadingXrp || loadingXmr || moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen)}
<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">
@@ -194,7 +204,7 @@
<button <button
onclick={() => navigate('/xrp')} onclick={() => navigate('/xrp')}
class="text-left transition-transform active:scale-[0.98]"> class="text-left transition-transform active:scale-[0.98]">
<Card class="hover:border-foreground/20 transition-colors"> <Card class="h-44 hover:border-foreground/20 transition-colors">
<CardHeader class="pb-3"> <CardHeader class="pb-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img src="/icons/xrp.png" alt="" class="size-5 rounded-full" /> <img src="/icons/xrp.png" alt="" class="size-5 rounded-full" />
@@ -204,8 +214,8 @@
{xrpWallet.wallets.length} wallet{xrpWallet.wallets.length !== 1 ? 's' : ''} {xrpWallet.wallets.length} wallet{xrpWallet.wallets.length !== 1 ? 's' : ''}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent class="flex flex-1 flex-col justify-center">
{#if loadingXrp} {#if !xrpWallet.ready || loadingXrp || (xrpWallet.wallets.length > 0 && Object.keys(xrpBalances).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>
@@ -225,7 +235,7 @@
<button <button
onclick={() => navigate('/monero')} onclick={() => navigate('/monero')}
class="text-left transition-transform active:scale-[0.98]"> class="text-left transition-transform active:scale-[0.98]">
<Card class="hover:border-foreground/20 transition-colors"> <Card class="h-44 hover:border-foreground/20 transition-colors">
<CardHeader class="pb-3"> <CardHeader class="pb-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img src="/icons/monero.png" alt="" class="size-5 rounded-full" /> <img src="/icons/monero.png" alt="" class="size-5 rounded-full" />
@@ -239,21 +249,48 @@
: 'Not set up'} : 'Not set up'}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent class="flex flex-1 flex-col justify-center">
{#if loadingXmr && !moneroWallet.walletOpen} {#if moneroWallet.walletOpen}
<div class="h-14 flex items-center"><Loader /></div> <p class="font-mono text-xl tabular-nums">{fmtBal(xmrBalance, 4)}</p>
{:else if moneroWallet.walletOpen} {#if moneroWallet.syncing}
<p class="font-mono text-xl tabular-nums">{fmtBal(xmrBalance, 4)}</p> <p class="mt-0.5 text-xs text-muted-foreground">Syncing…</p>
{#if xmrPrice} {:else if xmrPrice}
<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>
{/if}
{#if xmrPrice}
<p class="mt-2 text-xs text-muted-foreground">
${fmtUsd(parseFloat(xmrPrice) )} / XMR
</p>
{/if}
{:else if moneroWallet.passwordRequired && moneroWallet.wallets.length > 0}
<p class="text-sm text-muted-foreground py-3">Wallet locked — unlock to view balance</p>
{:else if moneroWallet.opening}
<div class="h-14 flex items-center gap-2">
<Loader />
<p class="text-sm text-muted-foreground">
{moneroWallet.wallets.length > 0 ? 'Opening wallet...' : 'Starting wallet server...'}
</p> </p>
<p class="mt-2 text-xs text-muted-foreground"> </div>
${fmtUsd(parseFloat(xmrPrice) )} / XMR {:else if moneroWallet.error}
</p> <div class="py-3">
{/if} <p class="text-xs text-destructive break-words">{moneroWallet.error}</p>
<Button
variant="outline"
size="sm"
class="mt-2 gap-1.5"
onclick={() => moneroWallet.login()}>
Retry
</Button>
</div>
{:else if moneroWallet.wallets.length > 0} {:else if moneroWallet.wallets.length > 0}
<p class="text-sm text-muted-foreground py-3">Open wallet to view balance</p> <div class="h-14 flex items-center gap-2">
<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} {:else}
<Button <Button
onclick={() => navigate('/monero')} onclick={() => navigate('/monero')}
+71 -2
View File
@@ -19,8 +19,27 @@
let moneroMnemonic = $state('') let moneroMnemonic = $state('')
let moneroRestoreHeight = $state<number | undefined>(undefined) let moneroRestoreHeight = $state<number | undefined>(undefined)
let walletPassword = $state('')
let rememberPassword = $state(true)
let unlocking = $state(false)
let unlockError = $state('')
let initStarted = false let initStarted = false
async function unlockWithPassword() {
if (unlocking || !walletPassword.trim()) return
unlocking = true
unlockError = ''
try {
await w.unlockWallet(walletPassword.trim(), rememberPassword)
walletPassword = ''
} catch (e) {
unlockError = e instanceof Error ? e.message : 'Failed to unlock wallet'
} finally {
unlocking = false
}
}
$effect(() => { $effect(() => {
if (w.walletOpen && w.walletName) { if (w.walletOpen && w.walletName) {
navigate('/monero/wallet/:name', { navigate('/monero/wallet/:name', {
@@ -40,7 +59,7 @@
}) })
$effect(() => { $effect(() => {
if (w.running && !w.walletOpen && w.wallets.length > 0 && !w.loading) { if (w.running && !w.walletOpen && w.wallets.length > 0 && !w.loading && !w.opening) {
w.autoUnlock(w.wallets[0]) w.autoUnlock(w.wallets[0])
} }
}) })
@@ -66,6 +85,15 @@
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
{:else if w.opening}
<Card>
<CardContent class="flex items-center justify-center gap-2 py-8">
<Loader />
<span class="text-sm text-muted-foreground">
{!w.running ? 'Starting wallet server...' : 'Opening wallet...'}
</span>
</CardContent>
</Card>
{:else if !w.installed} {:else if !w.installed}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -99,7 +127,48 @@
</CardContent> </CardContent>
</Card> </Card>
{:else if !w.walletOpen} {:else if !w.walletOpen}
{#if w.wallets.length > 0} {#if w.opening}
<Card>
<CardContent class="flex items-center justify-center py-8">
<Loader />
<span class="ml-2 text-sm text-muted-foreground">
Opening wallet...
</span>
</CardContent>
</Card>
{:else if w.passwordRequired && w.wallets.length > 0}
<Card>
<CardHeader>
<CardTitle>Wallet locked</CardTitle>
<CardDescription>
Enter the password for {w.wallets[0]} to open it
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-3">
<Input
type="password"
placeholder="Wallet password"
bind:value={walletPassword} />
<label class="flex items-center gap-2 text-xs">
<input
type="checkbox"
bind:checked={rememberPassword}
class="h-3.5 w-3.5" />
Remember password for auto-unlock
</label>
<Button
onclick={unlockWithPassword}
disabled={unlocking || !walletPassword.trim()}>
{unlocking ? 'Opening...' : 'Unlock Wallet'}
</Button>
{#if unlockError}
<p class="text-xs text-destructive">{unlockError}</p>
{/if}
</div>
</CardContent>
</Card>
{:else if w.wallets.length > 0}
<Card> <Card>
<CardContent class="flex items-center justify-center py-8"> <CardContent class="flex items-center justify-center py-8">
<Loader /> <Loader />
@@ -24,6 +24,8 @@
import Send from '@lucide/svelte/icons/send' import Send from '@lucide/svelte/icons/send'
import QrCodeIcon from '@lucide/svelte/icons/qr-code' import QrCodeIcon from '@lucide/svelte/icons/qr-code'
import Plus from '@lucide/svelte/icons/plus' import Plus from '@lucide/svelte/icons/plus'
import Users from '@lucide/svelte/icons/users'
import Tags from '@lucide/svelte/icons/tags'
import Loader from '$lib/components/loader.svelte' import Loader from '$lib/components/loader.svelte'
import QrCode from '$lib/components/qr.svelte' import QrCode from '$lib/components/qr.svelte'
import { navigate, route } from 'sv-router/generated' import { navigate, route } from 'sv-router/generated'
@@ -97,9 +99,53 @@
let accountDialogOpen = $state(false) let accountDialogOpen = $state(false)
let accountLabel = $state('') let accountLabel = $state('')
let accountBusy = $state(false)
let accountError = $state('')
let subaddressDialogOpen = $state(false) let subaddressDialogOpen = $state(false)
let subaddressAccountIndex = $state('') let subaddressAccountIndex = $state('')
let subaddressLabel = $state('') let subaddressLabel = $state('')
let subaddressBusy = $state(false)
let subaddressError = $state('')
$effect(() => {
if (!subaddressAccountIndex && w.accounts.length > 0) {
subaddressAccountIndex = String(w.accounts[0].index)
}
})
async function handleCreateAccount() {
if (accountBusy) return
accountBusy = true
accountError = ''
try {
await w.createAccount(accountLabel || undefined)
accountDialogOpen = false
accountLabel = ''
} catch (e) {
accountError = e instanceof Error ? e.message : 'Failed to create account'
} finally {
accountBusy = false
}
}
async function handleCreateSubaddress() {
if (subaddressBusy || !subaddressAccountIndex) return
subaddressBusy = true
subaddressError = ''
try {
await w.createSubaddress(
parseInt(subaddressAccountIndex),
subaddressLabel || undefined,
)
subaddressDialogOpen = false
subaddressAccountIndex = ''
subaddressLabel = ''
} catch (e) {
subaddressError = e instanceof Error ? e.message : 'Failed to create subaddress'
} finally {
subaddressBusy = false
}
}
const walletName = $derived(route.params.name) const walletName = $derived(route.params.name)
@@ -134,19 +180,30 @@
}) })
onMount(() => { onMount(() => {
if (!w.walletOpen || w.walletName !== walletName) { if (w.walletOpen && w.walletName === walletName) {
w.refresh()
electrobun.rpc?.request
.fetchMoneroPrice({})
.then((p) => (moneroPrice = p))
}
})
$effect(() => {
if (!w.opening && (!w.walletOpen || w.walletName !== walletName)) {
navigate('/monero') navigate('/monero')
} }
w.refresh()
electrobun.rpc?.request
.fetchMoneroPrice({})
.then((p) => (moneroPrice = p))
}) })
</script> </script>
{#if !w.walletOpen || w.walletName !== walletName}
<div class="flex flex-col items-center justify-center gap-3 py-24">
<Loader />
<p class="text-sm text-muted-foreground">Opening wallet...</p>
</div>
{: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>
@@ -166,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">
@@ -340,14 +405,44 @@
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Content> <Dialog.Content>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Create Account</Dialog.Title> <div class="flex items-center gap-3">
<Dialog.Description>Optional label for the new account</Dialog.Description> <div class="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted">
<Users size={16} class="text-muted-foreground" />
</div>
<div class="space-y-0.5">
<Dialog.Title>Create Account</Dialog.Title>
<Dialog.Description>
New account with its own balance and addresses
</Dialog.Description>
</div>
</div>
</Dialog.Header> </Dialog.Header>
<div class="px-6 pb-4 space-y-3"> <div class="px-6 pb-4 space-y-3">
<Input placeholder="Account label (optional)" bind:value={accountLabel} /> <div class="space-y-1.5">
<label class="text-xs font-medium" for="account-label">Label</label>
<Input id="account-label" placeholder="Savings" bind:value={accountLabel} />
<p class="text-xs text-muted-foreground">
Optional — shown next to the account in your wallet
</p>
</div>
{#if accountError}
<p class="text-xs text-destructive">{accountError}</p>
{/if}
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button variant="outline" size="sm" onclick={() => { accountDialogOpen = false; accountLabel = '' }}>Cancel</Button> <Button
<Button size="sm" onclick={async () => { await w.createAccount(accountLabel || undefined); accountDialogOpen = false; accountLabel = '' }}>Create</Button> variant="outline"
size="sm"
disabled={accountBusy}
onclick={() => {
accountDialogOpen = false
accountLabel = ''
accountError = ''
}}>
Cancel
</Button>
<Button size="sm" disabled={accountBusy} onclick={handleCreateAccount}>
{accountBusy ? 'Creating...' : 'Create'}
</Button>
</div> </div>
</div> </div>
</Dialog.Content> </Dialog.Content>
@@ -360,29 +455,66 @@
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Content> <Dialog.Content>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Create Subaddress</Dialog.Title> <div class="flex items-center gap-3">
<Dialog.Description>Account index and optional label</Dialog.Description> <div class="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted">
<Tags size={16} class="text-muted-foreground" />
</div>
<div class="space-y-0.5">
<Dialog.Title>Create Subaddress</Dialog.Title>
<Dialog.Description>
Receiving address tied to an account
</Dialog.Description>
</div>
</div>
</Dialog.Header> </Dialog.Header>
<div class="px-6 pb-4 space-y-3"> <div class="px-6 pb-4 space-y-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<label class="text-xs font-medium">Account</label> <label class="text-xs font-medium" for="subaddress-account">Account</label>
<Select type="single" bind:value={subaddressAccountIndex}> <Select type="single" bind:value={subaddressAccountIndex}>
<SelectTrigger> <SelectTrigger>
{subaddressAccountIndex ? `Account ${subaddressAccountIndex}` : 'Select account'} Account {subaddressAccountIndex}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{#each w.accounts as acct} {#each w.accounts as acct}
<SelectItem value={String(acct.index)}> <SelectItem value={String(acct.index)}>
Account {acct.index}{acct.label ? ` - ${acct.label}` : ''} Account {acct.index}{acct.label ? ` - ${acct.label}` : ''} ({atomicToXmr(acct.unlockedBalance)} XMR)
</SelectItem> </SelectItem>
{/each} {/each}
</SelectContent> </SelectContent>
</Select> </Select>
<p class="text-xs text-muted-foreground">
Where the subaddress belongs
</p>
</div> </div>
<Input placeholder="Subaddress label (optional)" bind:value={subaddressLabel} /> <div class="space-y-1.5">
<label class="text-xs font-medium" for="subaddress-label">Label</label>
<Input id="subaddress-label" placeholder="Donations" bind:value={subaddressLabel} />
<p class="text-xs text-muted-foreground">
Optional — helps you track who sent you funds
</p>
</div>
{#if subaddressError}
<p class="text-xs text-destructive">{subaddressError}</p>
{/if}
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button variant="outline" size="sm" onclick={() => { subaddressDialogOpen = false; subaddressAccountIndex = ''; subaddressLabel = '' }}>Cancel</Button> <Button
<Button size="sm" disabled={!subaddressAccountIndex} onclick={async () => { await w.createSubaddress(parseInt(subaddressAccountIndex), subaddressLabel || undefined); subaddressDialogOpen = false; subaddressAccountIndex = ''; subaddressLabel = '' }}>Create</Button> variant="outline"
size="sm"
disabled={subaddressBusy}
onclick={() => {
subaddressDialogOpen = false
subaddressAccountIndex = ''
subaddressLabel = ''
subaddressError = ''
}}>
Cancel
</Button>
<Button
size="sm"
disabled={subaddressBusy || !subaddressAccountIndex}
onclick={handleCreateSubaddress}>
{subaddressBusy ? 'Creating...' : 'Create'}
</Button>
</div> </div>
</div> </div>
</Dialog.Content> </Dialog.Content>
@@ -455,4 +587,5 @@
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/if}