Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a06c463653 | ||
|
|
3e1b8fc554 | ||
|
|
edb4f6b1f3 | ||
|
|
c2a405a9e1 | ||
|
|
6809cf09c6 | ||
|
|
9df8ce1867 | ||
|
|
1cccdc70e0 | ||
|
|
f517f7fe44 | ||
|
|
c9e492ad95 | ||
|
|
50a7771d97 |
@@ -1,6 +1,9 @@
|
|||||||
# 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" 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.
|
||||||
|
- 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 — the affirmative applies to the action you described, and you must still pause before each subsequent publish step (e.g. upload, cask update) to ask for permission again.
|
||||||
- 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."
|
- 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."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.3',
|
||||||
},
|
},
|
||||||
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/
|
|
||||||
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 494 B After Width: | Height: | Size: 783 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 789 KiB |
@@ -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)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 +23,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',
|
||||||
@@ -97,18 +99,45 @@ 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)
|
||||||
|
if (pids.length) {
|
||||||
|
console.log(
|
||||||
|
`[monero] stale process(es) on port ${state.rpcPort}: ${pids.join(', ')}`,
|
||||||
|
)
|
||||||
for (const pid of pids) {
|
for (const pid of pids) {
|
||||||
try {
|
try {
|
||||||
process.kill(Number(pid), 'SIGKILL')
|
process.kill(Number(pid), 'SIGTERM')
|
||||||
console.log(`[monero] killed stale process ${pid} on port ${state.rpcPort}`)
|
|
||||||
} catch {}
|
} 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 {}
|
||||||
|
|
||||||
console.log(`[monero] starting wallet-rpc process...`)
|
console.log(`[monero] starting wallet-rpc process...`)
|
||||||
@@ -140,10 +169,30 @@ export async function start(
|
|||||||
`[monero] wallet-rpc process exited with code ${exitCode}`,
|
`[monero] wallet-rpc process exited with code ${exitCode}`,
|
||||||
error?.message ?? '',
|
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) {
|
||||||
@@ -183,17 +232,41 @@ export async function start(
|
|||||||
|
|
||||||
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`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[monero] wallet save before stop failed:`, e)
|
||||||
|
}
|
||||||
if (state.process) {
|
if (state.process) {
|
||||||
console.log(
|
console.log(
|
||||||
`[monero] killing wallet-rpc process (pid ${state.process.pid})`,
|
`[monero] sending SIGTERM to wallet-rpc (pid ${state.process.pid})`,
|
||||||
)
|
)
|
||||||
state.process.kill()
|
state.process.kill()
|
||||||
|
await Promise.race([state.process.exited, sleep(3000)])
|
||||||
|
if (state.process.exitCode === null) {
|
||||||
|
console.log(
|
||||||
|
`[monero] wallet-rpc did not exit gracefully, sending SIGKILL`,
|
||||||
|
)
|
||||||
|
state.process.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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
|||||||
@@ -46,6 +46,21 @@ 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
|
||||||
|
|
||||||
|
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,7 +91,12 @@ export const MoneroWallet = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
|
if (walletOpen) return
|
||||||
|
if (loginInFlight) return loginInFlight
|
||||||
|
loginInFlight = (async () => {
|
||||||
accountType = 'monero'
|
accountType = 'monero'
|
||||||
|
beginOpening()
|
||||||
|
try {
|
||||||
if (installed && !running) {
|
if (installed && !running) {
|
||||||
await start()
|
await start()
|
||||||
await checkStatus()
|
await checkStatus()
|
||||||
@@ -85,6 +105,19 @@ export const MoneroWallet = () => {
|
|||||||
if (wallets.length > 0) {
|
if (wallets.length > 0) {
|
||||||
const pw = await moneroGetStoredPassword(wallets[0])
|
const pw = await moneroGetStoredPassword(wallets[0])
|
||||||
if (pw) await openExistingWallet(wallets[0], pw)
|
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,6 +176,8 @@ 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')
|
||||||
|
beginOpening()
|
||||||
|
try {
|
||||||
await electrobun.rpc.request.moneroOpenWallet({
|
await electrobun.rpc.request.moneroOpenWallet({
|
||||||
name,
|
name,
|
||||||
password: pw,
|
password: pw,
|
||||||
@@ -150,6 +185,9 @@ export const MoneroWallet = () => {
|
|||||||
walletName = name
|
walletName = name
|
||||||
walletOpen = true
|
walletOpen = true
|
||||||
await refresh()
|
await refresh()
|
||||||
|
} finally {
|
||||||
|
endOpening()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkStatus = async () => {
|
const checkStatus = async () => {
|
||||||
@@ -184,9 +222,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()
|
||||||
|
try {
|
||||||
|
if (!startInFlight) {
|
||||||
|
startInFlight = electrobun.rpc.request.moneroStart({})
|
||||||
|
const status = await startInFlight
|
||||||
running = status.running
|
running = status.running
|
||||||
connected = status.connected
|
connected = status.connected
|
||||||
|
} else {
|
||||||
|
await startInFlight
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
startInFlight = null
|
||||||
|
endOpening()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = async () => {
|
const stop = async () => {
|
||||||
@@ -195,6 +244,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,6 +259,8 @@ 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')
|
||||||
|
beginOpening()
|
||||||
|
try {
|
||||||
const result = await electrobun.rpc.request.moneroCreateWallet({
|
const result = await electrobun.rpc.request.moneroCreateWallet({
|
||||||
name,
|
name,
|
||||||
password,
|
password,
|
||||||
@@ -219,6 +271,9 @@ export const MoneroWallet = () => {
|
|||||||
if (storePw) await moneroStorePassword(name, password)
|
if (storePw) await moneroStorePassword(name, password)
|
||||||
await refresh()
|
await refresh()
|
||||||
return result
|
return result
|
||||||
|
} finally {
|
||||||
|
endOpening()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreWallet = async (
|
const restoreWallet = async (
|
||||||
@@ -229,6 +284,8 @@ 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')
|
||||||
|
beginOpening()
|
||||||
|
try {
|
||||||
const result = await electrobun.rpc.request.moneroRestoreWallet({
|
const result = await electrobun.rpc.request.moneroRestoreWallet({
|
||||||
name,
|
name,
|
||||||
password,
|
password,
|
||||||
@@ -240,14 +297,22 @@ export const MoneroWallet = () => {
|
|||||||
address = result.address
|
address = result.address
|
||||||
if (storePw) await moneroStorePassword(name, password)
|
if (storePw) await moneroStorePassword(name, password)
|
||||||
await refresh()
|
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
|
||||||
|
beginOpening()
|
||||||
|
try {
|
||||||
await electrobun.rpc.request.moneroOpenWallet({ name, password })
|
await electrobun.rpc.request.moneroOpenWallet({ name, password })
|
||||||
walletName = name
|
walletName = name
|
||||||
walletOpen = true
|
walletOpen = true
|
||||||
await refresh()
|
await refresh()
|
||||||
|
} finally {
|
||||||
|
endOpening()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoUnlock = async (name?: string) => {
|
const autoUnlock = async (name?: string) => {
|
||||||
@@ -255,15 +320,36 @@ 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) {
|
||||||
|
passwordRequired = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
beginOpening()
|
||||||
|
try {
|
||||||
await electrobun.rpc.request.moneroOpenWallet({
|
await electrobun.rpc.request.moneroOpenWallet({
|
||||||
name: target,
|
name: target,
|
||||||
password: pw,
|
password: pw,
|
||||||
})
|
})
|
||||||
walletName = target
|
walletName = target
|
||||||
walletOpen = true
|
walletOpen = true
|
||||||
|
passwordRequired = false
|
||||||
|
error = ''
|
||||||
await refresh()
|
await refresh()
|
||||||
return true
|
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 refresh = async () => {
|
const refresh = async () => {
|
||||||
@@ -404,6 +490,12 @@ export const MoneroWallet = () => {
|
|||||||
get loading() {
|
get loading() {
|
||||||
return loading
|
return loading
|
||||||
},
|
},
|
||||||
|
get opening() {
|
||||||
|
return opening
|
||||||
|
},
|
||||||
|
get passwordRequired() {
|
||||||
|
return passwordRequired
|
||||||
|
},
|
||||||
get error() {
|
get error() {
|
||||||
return error
|
return error
|
||||||
},
|
},
|
||||||
@@ -451,6 +543,7 @@ export const MoneroWallet = () => {
|
|||||||
restoreWallet,
|
restoreWallet,
|
||||||
openWallet,
|
openWallet,
|
||||||
autoUnlock,
|
autoUnlock,
|
||||||
|
unlockWallet,
|
||||||
refresh,
|
refresh,
|
||||||
send,
|
send,
|
||||||
sendAll,
|
sendAll,
|
||||||
|
|||||||
@@ -181,7 +181,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 +194,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,7 +204,7 @@
|
|||||||
{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 loadingXrp}
|
||||||
<div class="h-14 flex items-center"><Loader /></div>
|
<div class="h-14 flex items-center"><Loader /></div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -225,7 +225,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,10 +239,8 @@
|
|||||||
: '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>
|
|
||||||
{:else 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 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">
|
||||||
@@ -252,8 +250,33 @@
|
|||||||
${fmtUsd(parseFloat(xmrPrice) )} / XMR
|
${fmtUsd(parseFloat(xmrPrice) )} / XMR
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/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>
|
||||||
|
</div>
|
||||||
|
{:else if moneroWallet.error}
|
||||||
|
<div class="py-3">
|
||||||
|
<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')}
|
||||||
|
|||||||
@@ -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,16 +180,27 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!w.walletOpen || w.walletName !== walletName) {
|
if (w.walletOpen && w.walletName === walletName) {
|
||||||
navigate('/monero')
|
|
||||||
}
|
|
||||||
w.refresh()
|
w.refresh()
|
||||||
electrobun.rpc?.request
|
electrobun.rpc?.request
|
||||||
.fetchMoneroPrice({})
|
.fetchMoneroPrice({})
|
||||||
.then((p) => (moneroPrice = p))
|
.then((p) => (moneroPrice = p))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!w.opening && (!w.walletOpen || w.walletName !== walletName)) {
|
||||||
|
navigate('/monero')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</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}
|
||||||
@@ -340,14 +397,44 @@
|
|||||||
</Dialog.Trigger>
|
</Dialog.Trigger>
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<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.Title>Create Account</Dialog.Title>
|
||||||
<Dialog.Description>Optional label for the new account</Dialog.Description>
|
<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 +447,66 @@
|
|||||||
</Dialog.Trigger>
|
</Dialog.Trigger>
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<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.Title>Create Subaddress</Dialog.Title>
|
||||||
<Dialog.Description>Account index and optional label</Dialog.Description>
|
<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>
|
||||||
@@ -456,3 +580,4 @@
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||