Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d80b58b704 | ||
|
|
babd85e614 | ||
|
|
d15152a013 | ||
|
|
3913889a22 | ||
|
|
da0c56658b | ||
|
|
743700b82c | ||
|
|
8953596194 | ||
|
|
5df3ae7058 | ||
|
|
7b4ba84fee | ||
|
|
20f29faa80 | ||
|
|
c0f6417925 | ||
|
|
c61fea938a | ||
|
|
bdfee2a553 | ||
|
|
1c5e5b617f | ||
|
|
11e0bcdaf6 | ||
|
|
4270d380fb | ||
|
|
af6251669a | ||
|
|
6eec1f998d | ||
|
|
d9ca8a0047 | ||
|
|
a06c463653 | ||
|
|
3e1b8fc554 | ||
|
|
edb4f6b1f3 | ||
|
|
c2a405a9e1 | ||
|
|
6809cf09c6 | ||
|
|
9df8ce1867 | ||
|
|
1cccdc70e0 | ||
|
|
f517f7fe44 | ||
|
|
c9e492ad95 | ||
|
|
50a7771d97 |
@@ -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`, uploading a release asset to Gitea (`https://gitea.tzgyn.dev/TZGyn/koins`), updating the Homebrew cask, or pushing the tap repo (`https://gitea.tzgyn.dev/TZGyn/homebrew-tap`) — 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 Gitea release, update the Homebrew cask, and push the Gitea 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").
|
||||||
|
|||||||
@@ -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.12',
|
||||||
},
|
},
|
||||||
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 |
@@ -20,6 +20,7 @@
|
|||||||
"d3-shape": "^3.2.0",
|
"d3-shape": "^3.2.0",
|
||||||
"electrobun": "^1.18.1",
|
"electrobun": "^1.18.1",
|
||||||
"env-paths": "^4.0.0",
|
"env-paths": "^4.0.0",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"monero-ts": "^0.11.8",
|
"monero-ts": "^0.11.8",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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() {},
|
|
||||||
data() {},
|
|
||||||
error() {},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
console.log(`[monero] RPC ready after ${Date.now() - start}ms`)
|
console.log(`[monero] RPC ready after ${Date.now() - start}ms`)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
lastErr = JSON.stringify(res)
|
||||||
} 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,18 +141,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 +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) {
|
||||||
|
const proc = state.process
|
||||||
|
console.log(`[monero] sending SIGTERM to wallet-rpc (pid ${proc.pid})`)
|
||||||
|
proc.kill()
|
||||||
|
await Promise.race([proc.exited, sleep(3000)])
|
||||||
|
if (proc.exitCode === null) {
|
||||||
console.log(
|
console.log(
|
||||||
`[monero] killing wallet-rpc process (pid ${state.process.pid})`,
|
`[monero] wallet-rpc did not exit gracefully, sending SIGKILL`,
|
||||||
)
|
)
|
||||||
state.process.kill()
|
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)
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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,12 +139,19 @@ 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')
|
||||||
|
try {
|
||||||
await walletOpen(state.manager, name, password)
|
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')
|
||||||
|
try {
|
||||||
const { balance, unlocked } = await walletGetBalance(
|
const { balance, unlocked } = await walletGetBalance(
|
||||||
state.manager,
|
state.manager,
|
||||||
)
|
)
|
||||||
@@ -155,6 +172,10 @@ export function createMoneroHandlers(state: {
|
|||||||
height,
|
height,
|
||||||
daemonHeight,
|
daemonHeight,
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError('[rpc] moneroGetBalance failed', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
},
|
},
|
||||||
moneroGetTransactions: async ({
|
moneroGetTransactions: async ({
|
||||||
accountIndex,
|
accountIndex,
|
||||||
@@ -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')
|
||||||
|
try {
|
||||||
const txs = await walletGetTransactions(
|
const txs = await walletGetTransactions(
|
||||||
state.manager,
|
state.manager,
|
||||||
accountIndex,
|
accountIndex,
|
||||||
)
|
)
|
||||||
console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`)
|
console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`)
|
||||||
return txs
|
return txs
|
||||||
|
} catch (e) {
|
||||||
|
logError('[rpc] moneroGetTransactions failed', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
},
|
},
|
||||||
moneroWalletStatus: async () => {
|
moneroWalletStatus: async () => {
|
||||||
if (!state.manager) {
|
if (!state.manager) {
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js'
|
||||||
|
import Loader from '$lib/components/loader.svelte'
|
||||||
|
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
|
||||||
|
import { decodeQrFromFile } from '$lib/qr.js'
|
||||||
|
|
||||||
|
let {
|
||||||
|
onDecode,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
onDecode: (text: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
} = $props()
|
||||||
|
|
||||||
|
let fileInput: HTMLInputElement
|
||||||
|
let busy = $state(false)
|
||||||
|
let error = $state('')
|
||||||
|
|
||||||
|
const handleFile = async (e: Event) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
busy = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const text = await decodeQrFromFile(file)
|
||||||
|
if (!text) throw new Error('No QR code found in the image')
|
||||||
|
onDecode(text)
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Failed to read QR code'
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
if (fileInput) fileInput.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onchange={handleFile}
|
||||||
|
disabled={disabled || busy} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 gap-1.5"
|
||||||
|
disabled={disabled || busy}
|
||||||
|
onclick={() => fileInput?.click()}>
|
||||||
|
{#if busy}
|
||||||
|
<Loader class="size-3.5" />
|
||||||
|
{:else}
|
||||||
|
<QrCodeIcon size={14} />
|
||||||
|
{/if}
|
||||||
|
Scan QR
|
||||||
|
</Button>
|
||||||
|
{#if error}
|
||||||
|
<span class="text-xs text-red-500">{error}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -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: {},
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import jsQR from 'jsqr'
|
||||||
|
|
||||||
|
export const decodeQrFromFile = (file: File): Promise<string> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = img.naturalWidth
|
||||||
|
canvas.height = img.naturalHeight
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) throw new Error('Canvas is not supported')
|
||||||
|
ctx.drawImage(img, 0, 0)
|
||||||
|
const { data, width, height } = ctx.getImageData(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
canvas.width,
|
||||||
|
canvas.height,
|
||||||
|
)
|
||||||
|
resolve(jsQR(data, width, height)?.data ?? '')
|
||||||
|
} catch (e) {
|
||||||
|
reject(e)
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
reject(new Error('Could not load the image'))
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
})
|
||||||
|
|
||||||
|
export type QrParseResult = {
|
||||||
|
address: string
|
||||||
|
amount?: string
|
||||||
|
destinationTag?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles payment URIs (monero:ADDRESS?tx_amount=..., xrp:rADDR?amount=..&dt=..)
|
||||||
|
// as well as bare addresses. Returns the last path-like segment before any
|
||||||
|
// query string so prefixes and /pay paths are skipped.
|
||||||
|
export const parseQrPayload = (
|
||||||
|
text: string,
|
||||||
|
kind: 'xrp' | 'monero',
|
||||||
|
): QrParseResult => {
|
||||||
|
let body = text.trim().replace(/\s+/g, '')
|
||||||
|
body = body.replace(/^(?:[a-z0-9+.-]+):\/?\//i, '')
|
||||||
|
const [beforeQuery, queryRaw] = body.split('?')
|
||||||
|
const segments = beforeQuery.split('/')
|
||||||
|
const address = segments[segments.length - 1]
|
||||||
|
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (queryRaw) {
|
||||||
|
for (const pair of queryRaw.split('&')) {
|
||||||
|
const eq = pair.indexOf('=')
|
||||||
|
const key = (eq >= 0 ? pair.slice(0, eq) : pair).toLowerCase()
|
||||||
|
const value = eq >= 0 ? decodeURIComponent(pair.slice(eq + 1)) : ''
|
||||||
|
if (key) params[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'xrp') {
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
amount: params['amount'] || undefined,
|
||||||
|
destinationTag: params['dt'] || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
amount: params['tx_amount'] || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
electrobun,
|
electrobun,
|
||||||
|
frontendLog,
|
||||||
|
frontendLogError,
|
||||||
type MoneroTxEntry,
|
type MoneroTxEntry,
|
||||||
type MoneroAccountEntry,
|
type MoneroAccountEntry,
|
||||||
type MoneroSendResult,
|
type MoneroSendResult,
|
||||||
@@ -25,7 +27,7 @@ export const atomicToXmr = (atomic: string): string => {
|
|||||||
return frac ? `${whole}.${frac}` : `${whole}`
|
return frac ? `${whole}.${frac}` : `${whole}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MoneroWallet = () => {
|
export const createMoneroWallet = () => {
|
||||||
let accountType = $state<'monero' | null>(null)
|
let accountType = $state<'monero' | null>(null)
|
||||||
let ready = $state(false)
|
let ready = $state(false)
|
||||||
let biometricAvailable = $state(false)
|
let biometricAvailable = $state(false)
|
||||||
@@ -46,6 +48,45 @@ 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
|
||||||
|
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let price = $state<string | null>(null)
|
||||||
|
let priceInFlight = false
|
||||||
|
|
||||||
|
const rpcTimeout = <T>(promise: Promise<T>, ms: number, label: string): Promise<T> =>
|
||||||
|
new Promise<T>((resolve, reject) => {
|
||||||
|
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
|
||||||
@@ -65,7 +106,10 @@ export const MoneroWallet = () => {
|
|||||||
return authed === true
|
return authed === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let initPromise: Promise<void> | null = null
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
|
if (initPromise) return initPromise
|
||||||
|
initPromise = (async () => {
|
||||||
await checkStatus()
|
await checkStatus()
|
||||||
if (installed && !running && !downloading) {
|
if (installed && !running && !downloading) {
|
||||||
await start()
|
await start()
|
||||||
@@ -73,18 +117,48 @@ export const MoneroWallet = () => {
|
|||||||
}
|
}
|
||||||
await checkBiometric()
|
await checkBiometric()
|
||||||
ready = true
|
ready = true
|
||||||
|
})()
|
||||||
|
return initPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
|
if (walletOpen) return
|
||||||
|
if (loginInFlight) return loginInFlight
|
||||||
|
loginInFlight = (async () => {
|
||||||
accountType = 'monero'
|
accountType = 'monero'
|
||||||
|
error = ''
|
||||||
|
beginOpening()
|
||||||
|
try {
|
||||||
if (installed && !running) {
|
if (installed && !running) {
|
||||||
await start()
|
await start()
|
||||||
await checkStatus()
|
await checkStatus()
|
||||||
|
} else if (startInFlight) {
|
||||||
|
// a start is already in flight (e.g. triggered by init);
|
||||||
|
// wait for it so we don't call open before the server is up
|
||||||
|
await startInFlight.catch(() => {})
|
||||||
|
await checkStatus()
|
||||||
|
}
|
||||||
|
if (!running) {
|
||||||
|
error = 'Wallet server failed to start. Please try again.'
|
||||||
|
return
|
||||||
}
|
}
|
||||||
await listWallets()
|
await listWallets()
|
||||||
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 +217,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,19 +226,30 @@ export const MoneroWallet = () => {
|
|||||||
walletName = name
|
walletName = name
|
||||||
walletOpen = true
|
walletOpen = true
|
||||||
await refresh()
|
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(
|
||||||
|
rpcTimeout(
|
||||||
electrobun.rpc.request.moneroBinaryStatus({}),
|
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(
|
||||||
|
rpcTimeout(
|
||||||
electrobun.rpc.request.moneroWalletStatus({}),
|
electrobun.rpc.request.moneroWalletStatus({}),
|
||||||
|
30000,
|
||||||
|
'moneroWalletStatus',
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (status) {
|
if (status) {
|
||||||
running = status.running
|
running = status.running
|
||||||
@@ -184,9 +271,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 +293,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 +308,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 +320,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 +333,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 +346,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,24 +369,92 @@ 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 scheduleSyncRetry = () => {
|
||||||
|
if (syncRetryTimer) clearTimeout(syncRetryTimer)
|
||||||
|
syncRetryTimer = setTimeout(async () => {
|
||||||
|
syncRetryTimer = null
|
||||||
|
if (walletOpen && !loading) await refresh()
|
||||||
|
}, 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopAutoRefresh = () => {
|
||||||
|
if (autoRefreshTimer) {
|
||||||
|
clearInterval(autoRefreshTimer)
|
||||||
|
autoRefreshTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh runs in the store so the dashboard's connection/sync state
|
||||||
|
// stays live regardless of which page is mounted.
|
||||||
|
const startAutoRefresh = () => {
|
||||||
|
if (autoRefreshTimer || !walletOpen) return
|
||||||
|
autoRefreshTimer = setInterval(() => {
|
||||||
|
if (walletOpen && !loading) refresh()
|
||||||
|
}, 30000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// $effect needs a root outside a component; the root lives for the
|
||||||
|
// lifetime of the singleton store.
|
||||||
|
$effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
if (walletOpen && !loading) startAutoRefresh()
|
||||||
|
else if (!walletOpen) stopAutoRefresh()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
if (!electrobun.rpc) return
|
const rpc = electrobun.rpc
|
||||||
|
if (!rpc) return
|
||||||
|
if (refreshInFlight) return refreshInFlight
|
||||||
|
refreshInFlight = (async () => {
|
||||||
loading = true
|
loading = true
|
||||||
|
let failed = false
|
||||||
|
const wasSyncing = syncing
|
||||||
|
let lastError: unknown = null
|
||||||
try {
|
try {
|
||||||
const [bal] = await tryCatch(
|
const [bal, balErr] = await tryCatch(
|
||||||
electrobun.rpc.request.moneroGetBalance({}),
|
rpcTimeout(
|
||||||
|
rpc.request.moneroGetBalance({}),
|
||||||
|
20000,
|
||||||
|
'moneroGetBalance',
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
if (balErr) {
|
||||||
|
failed = true
|
||||||
|
lastError = balErr
|
||||||
|
}
|
||||||
if (bal) {
|
if (bal) {
|
||||||
balAtomic = bal.balance
|
balAtomic = bal.balance
|
||||||
unlockedAtomic = bal.unlocked
|
unlockedAtomic = bal.unlocked
|
||||||
@@ -280,17 +462,64 @@ export const MoneroWallet = () => {
|
|||||||
height = bal.height
|
height = bal.height
|
||||||
daemonHeight = bal.daemonHeight
|
daemonHeight = bal.daemonHeight
|
||||||
}
|
}
|
||||||
const [result] = await tryCatch(
|
const [result, txErr] = await tryCatch(
|
||||||
electrobun.rpc.request.moneroGetTransactions({
|
rpcTimeout(
|
||||||
|
rpc.request.moneroGetTransactions({
|
||||||
accountIndex: selectedAccountIndex,
|
accountIndex: selectedAccountIndex,
|
||||||
}),
|
}),
|
||||||
|
20000,
|
||||||
|
'moneroGetTransactions',
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
if (txErr) {
|
||||||
|
failed = true
|
||||||
|
lastError = txErr
|
||||||
|
}
|
||||||
txs = result ?? []
|
txs = result ?? []
|
||||||
await fetchAccounts()
|
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) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Monero refresh failed'
|
syncing = true
|
||||||
|
scheduleSyncRetry()
|
||||||
|
frontendLogError(
|
||||||
|
`monero refresh failed (height ${height}/${daemonHeight})`,
|
||||||
|
e,
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
|
refreshInFlight = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return refreshInFlight
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPrice = async () => {
|
||||||
|
const rpc = electrobun.rpc
|
||||||
|
if (!rpc || priceInFlight) return
|
||||||
|
priceInFlight = true
|
||||||
|
try {
|
||||||
|
const [p] = await tryCatch(rpc.request.fetchMoneroPrice({}))
|
||||||
|
if (p) price = p.usd
|
||||||
|
} finally {
|
||||||
|
priceInFlight = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +633,18 @@ export const MoneroWallet = () => {
|
|||||||
get loading() {
|
get loading() {
|
||||||
return loading
|
return loading
|
||||||
},
|
},
|
||||||
|
get opening() {
|
||||||
|
return opening
|
||||||
|
},
|
||||||
|
get passwordRequired() {
|
||||||
|
return passwordRequired
|
||||||
|
},
|
||||||
|
get syncing() {
|
||||||
|
return syncing
|
||||||
|
},
|
||||||
|
get price() {
|
||||||
|
return price
|
||||||
|
},
|
||||||
get error() {
|
get error() {
|
||||||
return error
|
return error
|
||||||
},
|
},
|
||||||
@@ -451,7 +692,9 @@ export const MoneroWallet = () => {
|
|||||||
restoreWallet,
|
restoreWallet,
|
||||||
openWallet,
|
openWallet,
|
||||||
autoUnlock,
|
autoUnlock,
|
||||||
|
unlockWallet,
|
||||||
refresh,
|
refresh,
|
||||||
|
fetchPrice,
|
||||||
send,
|
send,
|
||||||
sendAll,
|
sendAll,
|
||||||
createAccount,
|
createAccount,
|
||||||
@@ -459,4 +702,15 @@ export const MoneroWallet = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Singleton: calling MoneroWallet() always returns the same instance, so
|
||||||
|
// no component can ever create a second, disconnected wallet state.
|
||||||
|
type MoneroWalletStore = ReturnType<typeof createMoneroWallet>
|
||||||
|
|
||||||
|
let moneroWalletInstance: MoneroWalletStore | null = null
|
||||||
|
|
||||||
|
export const MoneroWallet = (): MoneroWalletStore => {
|
||||||
|
if (!moneroWalletInstance) moneroWalletInstance = createMoneroWallet()
|
||||||
|
return moneroWalletInstance
|
||||||
|
}
|
||||||
|
|
||||||
export const moneroWallet = MoneroWallet()
|
export const moneroWallet = MoneroWallet()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const xrpNetwork = {
|
|||||||
explorerAddressUrl: 'https://xrpscan.com/account/',
|
explorerAddressUrl: 'https://xrpscan.com/account/',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const XrpWallet = () => {
|
export const createXrpWallet = () => {
|
||||||
let accountType = $state<'xrp' | null>(null)
|
let accountType = $state<'xrp' | null>(null)
|
||||||
let ready = $state(false)
|
let ready = $state(false)
|
||||||
let biometricAvailable = $state(false)
|
let biometricAvailable = $state(false)
|
||||||
@@ -29,6 +29,9 @@ export const XrpWallet = () => {
|
|||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let wallets = $state<XrpWalletInfo[]>([])
|
let wallets = $state<XrpWalletInfo[]>([])
|
||||||
|
let balances = $state<Record<string, { balance: number; funded: boolean }>>({})
|
||||||
|
let balancesLoading = $state(false)
|
||||||
|
let fetchBalancesInFlight = false
|
||||||
let currentWalletId = $state<string | null>(null)
|
let currentWalletId = $state<string | null>(null)
|
||||||
let currentPasswordHash = $state<string | null>(null)
|
let currentPasswordHash = $state<string | null>(null)
|
||||||
|
|
||||||
@@ -73,7 +76,10 @@ export const XrpWallet = () => {
|
|||||||
biometricAvailable = ok === true
|
biometricAvailable = ok === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let initPromise: Promise<void> | null = null
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
|
if (initPromise) return initPromise
|
||||||
|
initPromise = (async () => {
|
||||||
if (!electrobun.rpc) return
|
if (!electrobun.rpc) return
|
||||||
const [walletList] = await tryCatch(
|
const [walletList] = await tryCatch(
|
||||||
electrobun.rpc.request.xrpListWallets({}),
|
electrobun.rpc.request.xrpListWallets({}),
|
||||||
@@ -86,6 +92,8 @@ export const XrpWallet = () => {
|
|||||||
}
|
}
|
||||||
await checkBiometric()
|
await checkBiometric()
|
||||||
ready = true
|
ready = true
|
||||||
|
})()
|
||||||
|
return initPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
@@ -319,6 +327,34 @@ export const XrpWallet = () => {
|
|||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchBalances = async () => {
|
||||||
|
const rpc = electrobun.rpc
|
||||||
|
if (!rpc || fetchBalancesInFlight) return
|
||||||
|
if (wallets.length === 0) return
|
||||||
|
fetchBalancesInFlight = true
|
||||||
|
balancesLoading = true
|
||||||
|
try {
|
||||||
|
const [p] = await tryCatch(rpc.request.fetchXrpPrice({}))
|
||||||
|
if (p) price = p.usd
|
||||||
|
await Promise.allSettled(
|
||||||
|
wallets.map(async (w) => {
|
||||||
|
const [res] = await tryCatch(
|
||||||
|
rpc.request.xrpGetBalance({ address: w.address }),
|
||||||
|
)
|
||||||
|
if (res) {
|
||||||
|
balances[w.address] = {
|
||||||
|
balance: parseFloat(res.balance),
|
||||||
|
funded: res.funded,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
balancesLoading = false
|
||||||
|
fetchBalancesInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const send = async (
|
const send = async (
|
||||||
to: string,
|
to: string,
|
||||||
amount: string,
|
amount: string,
|
||||||
@@ -399,6 +435,12 @@ export const XrpWallet = () => {
|
|||||||
get wallets() {
|
get wallets() {
|
||||||
return wallets
|
return wallets
|
||||||
},
|
},
|
||||||
|
get balances() {
|
||||||
|
return balances
|
||||||
|
},
|
||||||
|
get balancesLoading() {
|
||||||
|
return balancesLoading
|
||||||
|
},
|
||||||
get currentWalletId() {
|
get currentWalletId() {
|
||||||
return currentWalletId
|
return currentWalletId
|
||||||
},
|
},
|
||||||
@@ -424,6 +466,7 @@ export const XrpWallet = () => {
|
|||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
refresh,
|
refresh,
|
||||||
|
fetchBalances,
|
||||||
createWallet,
|
createWallet,
|
||||||
importWallet,
|
importWallet,
|
||||||
lock,
|
lock,
|
||||||
@@ -442,4 +485,15 @@ export const XrpWallet = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Singleton: calling XrpWallet() always returns the same instance, so no
|
||||||
|
// component can ever create a second, disconnected wallet state.
|
||||||
|
type XrpWalletStore = ReturnType<typeof createXrpWallet>
|
||||||
|
|
||||||
|
let xrpWalletInstance: XrpWalletStore | null = null
|
||||||
|
|
||||||
|
export const XrpWallet = (): XrpWalletStore => {
|
||||||
|
if (!xrpWalletInstance) xrpWalletInstance = createXrpWallet()
|
||||||
|
return xrpWalletInstance
|
||||||
|
}
|
||||||
|
|
||||||
export const xrpWallet = XrpWallet()
|
export const xrpWallet = XrpWallet()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { moneroWallet } from '$lib/states/monero-wallet.svelte.js'
|
import { moneroWallet } from '$lib/states/monero-wallet.svelte.js'
|
||||||
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
|
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
|
||||||
import { electrobun } from '$lib/electrobun.js'
|
|
||||||
import { navigate } from 'sv-router/generated'
|
import { navigate } from 'sv-router/generated'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import {
|
import {
|
||||||
@@ -16,29 +15,23 @@
|
|||||||
import WalletIcon from '@lucide/svelte/icons/wallet'
|
import WalletIcon from '@lucide/svelte/icons/wallet'
|
||||||
import Loader from '$lib/components/loader.svelte'
|
import Loader from '$lib/components/loader.svelte'
|
||||||
import { frontendLog, frontendLogError } from '$lib/electrobun.js'
|
import { frontendLog, frontendLogError } from '$lib/electrobun.js'
|
||||||
|
import { arc } from 'd3-shape'
|
||||||
let xrpPrice = $state<string | null>(null)
|
import { tweened } from 'svelte/motion'
|
||||||
let xmrPrice = $state<string | null>(null)
|
import { cubicOut } from 'svelte/easing'
|
||||||
let xrpBalances = $state<Record<string, { balance: number; funded: boolean }>>({})
|
|
||||||
let loadingXrp = $state(true)
|
|
||||||
let loadingXmr = $state(true)
|
|
||||||
|
|
||||||
let feError = $state<string | null>(null)
|
let feError = $state<string | null>(null)
|
||||||
|
|
||||||
const usd = (amount: string, price: string | null) =>
|
|
||||||
price ? parseFloat(amount) * parseFloat(price) : 0
|
|
||||||
|
|
||||||
const xrpTotalUsd = $derived.by(() => {
|
const xrpTotalUsd = $derived.by(() => {
|
||||||
const price = xrpPrice
|
const p = xrpWallet.price
|
||||||
return xrpWallet.wallets.reduce((sum, w) => {
|
return xrpWallet.wallets.reduce((sum, w) => {
|
||||||
const b = xrpBalances[w.address]?.balance ?? 0
|
const b = xrpWallet.balances[w.address]?.balance ?? 0
|
||||||
return sum + (price ? b * parseFloat(price) : 0)
|
return sum + (p ? b * parseFloat(p) : 0)
|
||||||
}, 0)
|
}, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
const xrpTotalBalance = $derived.by(() =>
|
const xrpTotalBalance = $derived.by(() =>
|
||||||
xrpWallet.wallets.reduce((sum, w) => {
|
xrpWallet.wallets.reduce((sum, w) => {
|
||||||
return sum + (xrpBalances[w.address]?.balance ?? 0)
|
return sum + (xrpWallet.balances[w.address]?.balance ?? 0)
|
||||||
}, 0),
|
}, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,17 +39,54 @@
|
|||||||
moneroWallet.walletOpen ? parseFloat(moneroWallet.balance || '0') : 0,
|
moneroWallet.walletOpen ? parseFloat(moneroWallet.balance || '0') : 0,
|
||||||
)
|
)
|
||||||
const xmrTotalUsd = $derived(
|
const xmrTotalUsd = $derived(
|
||||||
moneroWallet.walletOpen && xmrPrice
|
moneroWallet.walletOpen && moneroWallet.price
|
||||||
? xmrBalance * parseFloat(xmrPrice)
|
? xmrBalance * parseFloat(moneroWallet.price)
|
||||||
: 0,
|
: 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
const portfolioUsd = $derived(xrpTotalUsd + xmrTotalUsd)
|
const portfolioUsd = $derived(xrpTotalUsd + xmrTotalUsd)
|
||||||
|
|
||||||
|
const xrpShare = $derived(portfolioUsd > 0 ? xrpTotalUsd / portfolioUsd : 0)
|
||||||
|
const xmrShare = $derived(
|
||||||
|
portfolioUsd > 0 ? xmrTotalUsd / portfolioUsd : 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const tweenedUsd = tweened(0, { duration: 600, easing: cubicOut })
|
||||||
|
const tweenedXrpShare = tweened(0, { duration: 600, easing: cubicOut })
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
tweenedUsd.set(portfolioUsd)
|
||||||
|
tweenedXrpShare.set(xrpShare)
|
||||||
|
})
|
||||||
|
|
||||||
|
const donut = $derived.by(() => {
|
||||||
|
if (portfolioUsd <= 0) return { xrp: '', xmr: '' }
|
||||||
|
const a = arc<{ startAngle: number; endAngle: number }>()
|
||||||
|
.innerRadius(62)
|
||||||
|
.outerRadius(80)
|
||||||
|
.padAngle(0.02)
|
||||||
|
const start = -Math.PI / 2
|
||||||
|
const endXrp = start + $tweenedXrpShare * 2 * Math.PI
|
||||||
|
return {
|
||||||
|
xrp: a({ startAngle: start, endAngle: endXrp }) ?? '',
|
||||||
|
xmr: a({ startAngle: endXrp, endAngle: start + 2 * Math.PI }) ?? '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const hasXrp = $derived(xrpWallet.wallets.length > 0)
|
const hasXrp = $derived(xrpWallet.wallets.length > 0)
|
||||||
const hasXmr = $derived(moneroWallet.wallets.length > 0)
|
const hasXmr = $derived(moneroWallet.wallets.length > 0)
|
||||||
const noWallets = $derived(!hasXrp && !hasXmr)
|
const noWallets = $derived(!hasXrp && !hasXmr)
|
||||||
|
|
||||||
|
const moneroDot = $derived(
|
||||||
|
!moneroWallet.running
|
||||||
|
? 'bg-muted'
|
||||||
|
: !moneroWallet.connected
|
||||||
|
? 'bg-red-500'
|
||||||
|
: moneroWallet.syncing || moneroWallet.height < moneroWallet.daemonHeight
|
||||||
|
? 'bg-yellow-500 animate-pulse'
|
||||||
|
: 'bg-emerald-500',
|
||||||
|
)
|
||||||
|
|
||||||
const fmtUsd = (v: number) =>
|
const fmtUsd = (v: number) =>
|
||||||
v.toLocaleString(undefined, {
|
v.toLocaleString(undefined, {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
@@ -69,57 +99,23 @@
|
|||||||
maximumFractionDigits: dp,
|
maximumFractionDigits: dp,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function fetchXrp() {
|
const fmtPct = (v: number) => `${Math.round(v * 100)}%`
|
||||||
loadingXrp = true
|
|
||||||
const rpc = electrobun.rpc
|
|
||||||
if (!rpc) {
|
|
||||||
loadingXrp = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const p = await rpc.request.fetchXrpPrice({})
|
|
||||||
if (p) xrpPrice = p.usd
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
await Promise.allSettled(
|
|
||||||
xrpWallet.wallets.map(async (w) => {
|
|
||||||
const res = await rpc.request.xrpGetBalance({
|
|
||||||
address: w.address,
|
|
||||||
})
|
|
||||||
xrpBalances[w.address] = {
|
|
||||||
balance: parseFloat(res.balance),
|
|
||||||
funded: res.funded,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
loadingXrp = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchXmr() {
|
|
||||||
loadingXmr = true
|
|
||||||
const rpc = electrobun.rpc
|
|
||||||
if (!rpc) {
|
|
||||||
loadingXmr = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const p = await rpc.request.fetchMoneroPrice({})
|
|
||||||
xmrPrice = p?.usd ?? null
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
loadingXmr = false
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
frontendLog('dashboard mounted')
|
frontendLog('dashboard mounted')
|
||||||
moneroWallet.init().catch((e) => frontendLogError('moneroWallet.init() failed', e))
|
|
||||||
xrpWallet.init().then(async () => {
|
|
||||||
await fetchXrp()
|
|
||||||
})
|
})
|
||||||
fetchXmr()
|
|
||||||
|
// Balances live in the stores so they survive navigation; the effects
|
||||||
|
// only trigger background refetches.
|
||||||
|
$effect(() => {
|
||||||
|
if (xrpWallet.ready && xrpWallet.wallets.length > 0) {
|
||||||
|
xrpWallet.fetchBalances()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (moneroWallet.walletOpen) {
|
if (moneroWallet.walletOpen) {
|
||||||
fetchXmr()
|
moneroWallet.fetchPrice()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -179,9 +175,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{: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 moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen) || (xrpWallet.wallets.length > 0 && !xrpWallet.price) || (moneroWallet.walletOpen && !moneroWallet.price)}
|
||||||
<div class="mt-2 h-9 w-44 rounded-md bg-muted animate-pulse"></div>
|
<div class="mt-2 h-9 w-44 rounded-md bg-muted animate-pulse"></div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mt-1 text-3xl font-semibold tabular-nums">
|
<p class="mt-1 text-3xl font-semibold tabular-nums">
|
||||||
@@ -190,31 +186,66 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-base">Portfolio Allocation</CardTitle>
|
||||||
|
<CardDescription class="text-xs">
|
||||||
|
Share of your balance by asset
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="flex flex-col items-center gap-5 py-6">
|
||||||
|
<div class="relative">
|
||||||
|
<svg viewBox="-100 -100 200 200" class="size-48">
|
||||||
|
<path d={donut.xrp} fill="#0EA5E9" />
|
||||||
|
<path d={donut.xmr} fill="#F97316" />
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<p
|
||||||
|
class="text-lg font-semibold tabular-nums">$
|
||||||
|
{fmtUsd($tweenedUsd)}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Portfolio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-5 text-xs">
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<span class="size-2.5 rounded-full bg-sky-500"></span>
|
||||||
|
XRP · {fmtPct($tweenedXrpShare)}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<span class="size-2.5 rounded-full bg-orange-500"></span>
|
||||||
|
XMR · {fmtPct(1 - $tweenedXrpShare)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<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" />
|
||||||
<CardTitle class="text-base">XRP</CardTitle>
|
<CardTitle class="text-base">XRP</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription class="text-xs">
|
<CardDescription class="text-xs">XRP Ledger</CardDescription>
|
||||||
{xrpWallet.wallets.length} wallet{xrpWallet.wallets.length !== 1 ? 's' : ''}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent class="flex flex-1 flex-col justify-center">
|
||||||
{#if loadingXrp}
|
{#if !xrpWallet.ready || (xrpWallet.wallets.length > 0 && Object.keys(xrpWallet.balances).length === 0)}
|
||||||
<div class="h-14 flex items-center"><Loader /></div>
|
<div class="h-14 flex items-center"><Loader /></div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="font-mono text-xl tabular-nums">{fmtBal(xrpTotalBalance)}</p>
|
<p class="font-mono text-xl tabular-nums">{fmtBal(xrpTotalBalance)}</p>
|
||||||
{#if xrpPrice}
|
{#if xrpWallet.price}
|
||||||
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
|
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
|
||||||
${fmtUsd(xrpTotalUsd)}
|
${fmtUsd(xrpTotalUsd)}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-xs text-muted-foreground">
|
<p class="mt-2 text-xs text-muted-foreground">
|
||||||
${fmtUsd(parseFloat(xrpPrice) )} / XRP
|
${fmtUsd(parseFloat(xrpWallet.price) )} / XRP
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -225,35 +256,61 @@
|
|||||||
<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" />
|
||||||
<CardTitle class="text-base">Monero</CardTitle>
|
<CardTitle class="text-base">Monero</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription class="text-xs">
|
<CardDescription class="flex items-center gap-1.5 text-xs">
|
||||||
{moneroWallet.walletOpen
|
<span class="size-2 shrink-0 rounded-full {moneroDot}"></span>
|
||||||
? moneroWallet.walletName || 'Wallet open'
|
{#if !moneroWallet.running}
|
||||||
: moneroWallet.wallets.length > 0
|
Server offline
|
||||||
? `${moneroWallet.wallets.length} wallet${moneroWallet.wallets.length !== 1 ? 's' : ''}`
|
{:else if !moneroWallet.connected}
|
||||||
: 'Not set up'}
|
Connecting to daemon…
|
||||||
|
{:else}
|
||||||
|
{fmtBal(moneroWallet.height, 0)} / {fmtBal(moneroWallet.daemonHeight, 0)}
|
||||||
|
{/if}
|
||||||
</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 !moneroWallet.syncing && moneroWallet.price}
|
||||||
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
|
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
|
||||||
${fmtUsd(xmrTotalUsd)}
|
${fmtUsd(xmrTotalUsd)}
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if moneroWallet.price}
|
||||||
<p class="mt-2 text-xs text-muted-foreground">
|
<p class="mt-2 text-xs text-muted-foreground">
|
||||||
${fmtUsd(parseFloat(xmrPrice) )} / XMR
|
${fmtUsd(parseFloat(moneroWallet.price) )} / XMR
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if moneroWallet.passwordRequired && moneroWallet.wallets.length > 0}
|
||||||
|
<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}
|
{:else}
|
||||||
<Button
|
<Button
|
||||||
onclick={() => navigate('/monero')}
|
onclick={() => navigate('/monero')}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
if (!xrpWallet.ready) await xrpWallet.init()
|
if (!xrpWallet.ready) await xrpWallet.init()
|
||||||
if (!moneroWallet.ready) await moneroWallet.init()
|
if (!moneroWallet.ready) await moneroWallet.init()
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
|
xrpWallet.login(),
|
||||||
xrpWallet.autoUnlock(),
|
xrpWallet.autoUnlock(),
|
||||||
moneroWallet.login(),
|
moneroWallet.login(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -19,7 +19,24 @@
|
|||||||
let moneroMnemonic = $state('')
|
let moneroMnemonic = $state('')
|
||||||
let moneroRestoreHeight = $state<number | undefined>(undefined)
|
let moneroRestoreHeight = $state<number | undefined>(undefined)
|
||||||
|
|
||||||
let initStarted = false
|
let walletPassword = $state('')
|
||||||
|
let rememberPassword = $state(true)
|
||||||
|
let unlocking = $state(false)
|
||||||
|
let unlockError = $state('')
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -30,17 +47,7 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!initStarted) {
|
if (w.running && !w.walletOpen && w.wallets.length > 0 && !w.loading && !w.opening) {
|
||||||
initStarted = true
|
|
||||||
w.init().then(async () => {
|
|
||||||
if (w.accountType === 'monero') return
|
|
||||||
await w.login()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (w.running && !w.walletOpen && w.wallets.length > 0 && !w.loading) {
|
|
||||||
w.autoUnlock(w.wallets[0])
|
w.autoUnlock(w.wallets[0])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -66,6 +73,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 +115,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 />
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from '$lib/components/ui/select/index.js'
|
} from '$lib/components/ui/select/index.js'
|
||||||
import { navigate } from 'sv-router/generated'
|
import { navigate } from 'sv-router/generated'
|
||||||
|
import QrUpload from '$lib/components/qr-upload.svelte'
|
||||||
|
import { parseQrPayload } from '$lib/qr.js'
|
||||||
|
|
||||||
const w = moneroWallet
|
const w = moneroWallet
|
||||||
|
|
||||||
@@ -134,7 +136,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<label class="text-xs font-medium">Recipient Address</label>
|
<label class="text-xs font-medium">Recipient Address</label>
|
||||||
|
<QrUpload
|
||||||
|
onDecode={(text) => {
|
||||||
|
const parsed = parseQrPayload(text, 'monero')
|
||||||
|
recipient = parsed.address
|
||||||
|
if (parsed.amount && !sendAll) amountXmr = parsed.amount
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Monero address starting with 4..."
|
placeholder="Monero address starting with 4..."
|
||||||
bind:value={recipient} />
|
bind:value={recipient} />
|
||||||
|
|||||||
@@ -15,10 +15,6 @@
|
|||||||
const w = moneroWallet
|
const w = moneroWallet
|
||||||
|
|
||||||
let moneroSettingsPw = $state('')
|
let moneroSettingsPw = $state('')
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
w.init()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-xl">
|
<div class="mx-auto max-w-xl">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -108,7 +154,6 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (w.walletOpen && !autoRefreshTimer) {
|
if (w.walletOpen && !autoRefreshTimer) {
|
||||||
autoRefreshTimer = setInterval(() => {
|
autoRefreshTimer = setInterval(() => {
|
||||||
w.refresh()
|
|
||||||
electrobun.rpc?.request
|
electrobun.rpc?.request
|
||||||
.fetchMoneroPrice({})
|
.fetchMoneroPrice({})
|
||||||
.then((p) => (moneroPrice = p))
|
.then((p) => (moneroPrice = p))
|
||||||
@@ -134,19 +179,30 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
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 && !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 +222,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 +404,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 +454,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 +587,4 @@
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -22,18 +22,6 @@
|
|||||||
let inputUnlockPassword = $state('')
|
let inputUnlockPassword = $state('')
|
||||||
let newSeed = $state<string | null>(null)
|
let newSeed = $state<string | null>(null)
|
||||||
|
|
||||||
let initStarted = false
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!initStarted) {
|
|
||||||
initStarted = true
|
|
||||||
w.init().then(async () => {
|
|
||||||
if (w.accountType === 'xrp') return
|
|
||||||
await w.login()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (w.accountType === 'xrp' && w.currentWalletId && w.seed && !newSeed) {
|
if (w.accountType === 'xrp' && w.currentWalletId && w.seed && !newSeed) {
|
||||||
navigate('/xrp/wallet/:id', {
|
navigate('/xrp/wallet/:id', {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
} from '$lib/components/ui/card/index.js'
|
} from '$lib/components/ui/card/index.js'
|
||||||
import { navigate } from 'sv-router/generated'
|
import { navigate } from 'sv-router/generated'
|
||||||
|
import QrUpload from '$lib/components/qr-upload.svelte'
|
||||||
|
import { parseQrPayload } from '$lib/qr.js'
|
||||||
|
|
||||||
const w = xrpWallet
|
const w = xrpWallet
|
||||||
|
|
||||||
@@ -78,7 +80,16 @@
|
|||||||
}}
|
}}
|
||||||
class="space-y-4">
|
class="space-y-4">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<label class="text-xs font-medium">Recipient Address</label>
|
<label class="text-xs font-medium">Recipient Address</label>
|
||||||
|
<QrUpload
|
||||||
|
onDecode={(text) => {
|
||||||
|
const parsed = parseQrPayload(text, 'xrp')
|
||||||
|
recipient = parsed.address
|
||||||
|
if (parsed.destinationTag) destinationTag = parsed.destinationTag
|
||||||
|
if (parsed.amount) amountXrp = parsed.amount
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
placeholder="XRP address starting with r..."
|
placeholder="XRP address starting with r..."
|
||||||
bind:value={recipient} />
|
bind:value={recipient} />
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Local
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
|
*.log*
|
||||||
|
|
||||||
|
# Dist
|
||||||
|
node_modules
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
You are an expert in JavaScript, Rspeedy, and Vue Lynx application development. You write maintainable, performant, and accessible code.
|
||||||
|
|
||||||
|
## Read in Advance
|
||||||
|
|
||||||
|
- Vue Lynx: [llms.txt](https://vue.lynxjs.org/llms.txt), **REQUIRED**.
|
||||||
|
While dealing with a Vue Lynx task, an agent **MUST** read this doc because it is an entry point of all available docs about Vue Lynx.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `npm run dev` - Start the dev server
|
||||||
|
|
||||||
|
- `npm run build` - Build the app for production
|
||||||
|
|
||||||
|
- `npm run preview` - Preview the production build locally
|
||||||
|
|
||||||
|
- `npm exec rspeedy inspect` - Inspect the Rspeedy config and Rspack config of the project.
|
||||||
|
|
||||||
|
## Related Docs
|
||||||
|
|
||||||
|
- Rsbuild: <https://rsbuild.rs/llms.txt>
|
||||||
|
|
||||||
|
- Rspack: <https://rspack.rs/llms.txt>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## Vue Lynx project
|
||||||
|
|
||||||
|
This is a Vue Lynx project bootstrapped with `create-vue-lynx`.
|
||||||
|
|
||||||
|
## IDE Setup
|
||||||
|
|
||||||
|
This project is pre-configured for [Vue Language Features (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.volar). VS Code will prompt you to install it automatically.
|
||||||
|
|
||||||
|
Volar provides prop completions, type checking, and Lynx-specific diagnostics for `.vue` files.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, install the dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Scan the QRCode in the terminal with your LynxExplorer App to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `src/App.vue`. The page auto-updates as you edit the file.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { defineConfig } from '@lynx-js/rspeedy'
|
||||||
|
|
||||||
|
import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'
|
||||||
|
import { pluginVueLynx } from 'vue-lynx/plugin'
|
||||||
|
import { pluginTailwindCSS } from 'rsbuild-plugin-tailwindcss'
|
||||||
|
import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
environments: {
|
||||||
|
lynx: {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
// crypto-browserify -> create-hash -> require('crypto') forms a
|
||||||
|
// circular dependency; webpack resolves createHash to undefined in
|
||||||
|
// the lynx build. Point create-hash at its browser implementation
|
||||||
|
// (no require('crypto') cycle).
|
||||||
|
'create-hash$': '/Users/tzgyn/Git/koins/node_modules/.bun/create-hash@1.2.0/node_modules/create-hash/browser.js',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
web: {},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
pluginNodePolyfill(),
|
||||||
|
pluginQRCode({
|
||||||
|
schema(url) {
|
||||||
|
return `${url}?fullscreen=true`
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
pluginVueLynx({
|
||||||
|
optionsApi: false,
|
||||||
|
enableCSSInlineVariables: true,
|
||||||
|
enableCSSInheritance: true,
|
||||||
|
}),
|
||||||
|
pluginTailwindCSS({
|
||||||
|
config: 'tailwind.config.ts',
|
||||||
|
exclude: [/[\\/]node_modules[\\/]/],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "mobile",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "rspeedy build",
|
||||||
|
"dev": "rspeedy dev",
|
||||||
|
"preview": "rspeedy preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-lynx": "^0.5.1",
|
||||||
|
"xrpl": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@lynx-js/qrcode-rsbuild-plugin": "^0.4.6",
|
||||||
|
"@lynx-js/rspeedy": "^0.13.5",
|
||||||
|
"@lynx-js/tailwind-preset": "^0.5.1",
|
||||||
|
"@rsbuild/plugin-node-polyfill": "^1.4.6",
|
||||||
|
"@rsbuild/plugin-vue": "^1.2.6",
|
||||||
|
"rsbuild-plugin-tailwindcss": "^0.2.4",
|
||||||
|
"tailwindcss": "3",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
background-color: #0a0a0b;
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
page {
|
||||||
|
background-color: #0a0a0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
background-color: #18181b;
|
||||||
|
color: #fafafa;
|
||||||
|
caret-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll-view {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
text {
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue-lynx'
|
||||||
|
|
||||||
|
import './App.css'
|
||||||
|
import {
|
||||||
|
generateWallet,
|
||||||
|
walletFromSecret,
|
||||||
|
isValidAddress,
|
||||||
|
getWallet,
|
||||||
|
saveWallet,
|
||||||
|
deleteWallet,
|
||||||
|
getSeed,
|
||||||
|
saveSeed,
|
||||||
|
getBalance,
|
||||||
|
getTransactions,
|
||||||
|
sendPayment,
|
||||||
|
type XrpWalletInfo,
|
||||||
|
type XrpBalance,
|
||||||
|
type XrpTxEntry,
|
||||||
|
} from './lib/xrp'
|
||||||
|
|
||||||
|
type Screen =
|
||||||
|
| 'welcome'
|
||||||
|
| 'create'
|
||||||
|
| 'import'
|
||||||
|
| 'dashboard'
|
||||||
|
| 'send'
|
||||||
|
| 'receive'
|
||||||
|
|
||||||
|
const screen = ref<Screen>('welcome')
|
||||||
|
const wallet = ref<XrpWalletInfo | null>(null)
|
||||||
|
const seed = ref<string | null>(null)
|
||||||
|
|
||||||
|
const balance = ref<XrpBalance | null>(null)
|
||||||
|
const transactions = ref<XrpTxEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
|
||||||
|
const newName = ref('')
|
||||||
|
const newMnemonic = ref('')
|
||||||
|
const importName = ref('')
|
||||||
|
const importSecret = ref('')
|
||||||
|
const createdSeed = ref('')
|
||||||
|
|
||||||
|
const sendTo = ref('')
|
||||||
|
const sendAmount = ref('')
|
||||||
|
const sendTag = ref('')
|
||||||
|
const sending = ref(false)
|
||||||
|
|
||||||
|
const showAddress = computed(() => wallet.value?.address ?? '')
|
||||||
|
const truncatedAddress = computed(() => {
|
||||||
|
const a = showAddress.value
|
||||||
|
if (a.length <= 16) return a
|
||||||
|
return `${a.slice(0, 8)}...${a.slice(-8)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
wallet.value = getWallet()
|
||||||
|
if (wallet.value) {
|
||||||
|
seed.value = getSeed()
|
||||||
|
screen.value = 'dashboard'
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const w = wallet.value
|
||||||
|
if (!w) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const [bal, txs] = await Promise.all([
|
||||||
|
getBalance(w.address),
|
||||||
|
getTransactions(w.address),
|
||||||
|
])
|
||||||
|
balance.value = bal
|
||||||
|
transactions.value = txs
|
||||||
|
} catch (e) {
|
||||||
|
console.info('[koins] refresh error:', e instanceof Error ? e.message : String(e))
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load wallet'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
error.value = ''
|
||||||
|
if (!newName.value.trim()) {
|
||||||
|
error.value = 'Enter a wallet name'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { address, seed: newSeed } = generateWallet()
|
||||||
|
const w: XrpWalletInfo = {
|
||||||
|
id: `xrp_${Date.now()}`,
|
||||||
|
name: newName.value.trim(),
|
||||||
|
address,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
wallet.value = w
|
||||||
|
seed.value = newSeed
|
||||||
|
saveWallet(w)
|
||||||
|
saveSeed(newSeed)
|
||||||
|
createdSeed.value = newSeed
|
||||||
|
newName.value = ''
|
||||||
|
screen.value = 'create'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to create wallet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishCreate() {
|
||||||
|
createdSeed.value = ''
|
||||||
|
screen.value = 'dashboard'
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImport() {
|
||||||
|
error.value = ''
|
||||||
|
if (!importName.value.trim()) {
|
||||||
|
error.value = 'Enter a wallet name'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!importSecret.value.trim()) {
|
||||||
|
error.value = 'Enter your seed or secret'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { address } = walletFromSecret(importSecret.value)
|
||||||
|
const w: XrpWalletInfo = {
|
||||||
|
id: `xrp_${Date.now()}`,
|
||||||
|
name: importName.value.trim(),
|
||||||
|
address,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
wallet.value = w
|
||||||
|
seed.value = importSecret.value.trim()
|
||||||
|
saveWallet(w)
|
||||||
|
saveSeed(importSecret.value.trim())
|
||||||
|
importName.value = ''
|
||||||
|
importSecret.value = ''
|
||||||
|
screen.value = 'dashboard'
|
||||||
|
refresh()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to import wallet'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSend() {
|
||||||
|
error.value = ''
|
||||||
|
if (!isValidAddress(sendTo.value.trim())) {
|
||||||
|
error.value = 'Invalid XRP address'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const amount = parseFloat(sendAmount.value)
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
error.value = 'Enter a valid amount'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!seed.value) {
|
||||||
|
error.value = 'Wallet is locked'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const bal = parseFloat(balance.value?.balance ?? '0')
|
||||||
|
if (amount > bal) {
|
||||||
|
error.value = 'Insufficient balance'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sending.value = true
|
||||||
|
sendPayment(
|
||||||
|
seed.value,
|
||||||
|
sendTo.value.trim(),
|
||||||
|
sendAmount.value,
|
||||||
|
sendTag.value ? parseInt(sendTag.value, 10) : undefined,
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
success.value = 'Payment sent'
|
||||||
|
sendTo.value = ''
|
||||||
|
sendAmount.value = ''
|
||||||
|
sendTag.value = ''
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to send'
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
sending.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (!wallet.value) return
|
||||||
|
deleteWallet()
|
||||||
|
wallet.value = null
|
||||||
|
seed.value = null
|
||||||
|
balance.value = null
|
||||||
|
transactions.value = []
|
||||||
|
screen.value = 'welcome'
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDashboard() {
|
||||||
|
screen.value = 'dashboard'
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
if (!ts) return 'pending'
|
||||||
|
const d = new Date(ts * 1000)
|
||||||
|
const month = d.toLocaleDateString(undefined, { month: 'short' })
|
||||||
|
return `${month} ${d.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateHash(hash: string): string {
|
||||||
|
if (hash.length <= 14) return hash
|
||||||
|
return `${hash.slice(0, 7)}...${hash.slice(-7)}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="app flex-1 bg-background px-5 pt-12 pb-6">
|
||||||
|
|
||||||
|
<!-- Welcome -->
|
||||||
|
<view v-if="screen === 'welcome'" class="flex flex-col items-center">
|
||||||
|
<text class="mt-12 text-5xl font-bold text-foreground">Koins</text>
|
||||||
|
<text class="mt-1 mb-6 text-lg text-muted">XRP Wallet</text>
|
||||||
|
<text class="mb-4 text-sm text-center text-muted">
|
||||||
|
Create or import your XRP wallet
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<view class="mt-5 flex w-full flex-row gap-3">
|
||||||
|
<view class="flex-1 bg-primary h-[52px] rounded-xl items-center justify-center" @tap="screen = 'create'">
|
||||||
|
<text class="text-base font-semibold text-primary-foreground">Create Wallet</text>
|
||||||
|
</view>
|
||||||
|
<view class="flex-1 bg-zinc-800 h-[52px] rounded-xl items-center justify-center" @tap="screen = 'import'">
|
||||||
|
<text class="text-base font-semibold text-foreground">Import</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Create -->
|
||||||
|
<view v-else-if="screen === 'create'" class="flex flex-col items-center">
|
||||||
|
<text class="self-start mb-5 text-[26px] font-semibold text-foreground">Create Wallet</text>
|
||||||
|
<text v-if="!createdSeed" class="mb-4 text-sm text-center text-muted">Choose a name for your new wallet</text>
|
||||||
|
<view v-if="!createdSeed" class="flex w-full flex-col gap-3">
|
||||||
|
<input
|
||||||
|
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
|
||||||
|
:value="newName"
|
||||||
|
@input="newName = $event.detail.value"
|
||||||
|
placeholder="Wallet name"
|
||||||
|
placeholder-class="text-muted"
|
||||||
|
/>
|
||||||
|
<view class="h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="handleCreate">
|
||||||
|
<text class="text-base font-semibold text-primary-foreground">Generate Wallet</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="flex w-full flex-col gap-2.5 rounded-2xl border border-yellow-800 bg-yellow-950 p-5">
|
||||||
|
<text class="text-lg font-semibold text-yellow-200">Your Seed Phrase</text>
|
||||||
|
<text class="text-[13px] text-yellow-300">Write this down. It is the only way to recover your wallet. Never share it.</text>
|
||||||
|
<text class="rounded-[10px] border border-yellow-800 bg-card p-3.5 text-[15px] leading-6 text-foreground">
|
||||||
|
{{ createdSeed }}
|
||||||
|
</text>
|
||||||
|
<view class="h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="finishCreate">
|
||||||
|
<text class="text-base font-semibold text-primary-foreground">I've Saved It</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text v-if="error" class="mt-3 text-center text-[13px] text-destructive">{{ error }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Import -->
|
||||||
|
<view v-else-if="screen === 'import'" class="flex flex-col items-center">
|
||||||
|
<text class="self-start mb-5 text-[26px] font-semibold text-foreground">Import Wallet</text>
|
||||||
|
<view class="flex w-full flex-col gap-3">
|
||||||
|
<input
|
||||||
|
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
|
||||||
|
:value="importName"
|
||||||
|
@input="importName = $event.detail.value"
|
||||||
|
placeholder="Wallet name"
|
||||||
|
placeholder-class="text-muted"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
|
||||||
|
:value="importSecret"
|
||||||
|
@input="importSecret = $event.detail.value"
|
||||||
|
placeholder="Seed phrase or secret"
|
||||||
|
placeholder-class="text-muted"
|
||||||
|
/>
|
||||||
|
<view class="h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="handleImport">
|
||||||
|
<text class="text-base font-semibold text-primary-foreground">Import Wallet</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text v-if="error" class="mt-3 text-center text-[13px] text-destructive">{{ error }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Dashboard -->
|
||||||
|
<view v-else-if="screen === 'dashboard'" class="flex flex-col items-center">
|
||||||
|
<view class="flex w-full flex-row items-start justify-between">
|
||||||
|
<view class="flex flex-col">
|
||||||
|
<text class="text-[17px] font-semibold text-foreground">{{ wallet?.name ?? 'Wallet' }}</text>
|
||||||
|
<text class="mt-0.5 text-[13px] text-muted">{{ truncatedAddress }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="flex flex-row gap-3">
|
||||||
|
<text class="text-sm text-primary" @tap="handleDelete">Delete</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mt-5 flex w-full flex-col items-center rounded-2xl border border-border bg-card p-7">
|
||||||
|
<text class="text-sm text-muted">Total Balance</text>
|
||||||
|
<text class="mt-1.5 text-4xl font-bold text-foreground">{{ loading && !balance ? '...' : (balance?.balance ?? '0') }} XRP</text>
|
||||||
|
<text v-if="balance && !balance.funded" class="mt-1.5 text-[13px] text-yellow-400">Account not funded yet</text>
|
||||||
|
<text v-if="error" class="mt-3 text-center text-[13px] text-destructive">{{ error }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mt-5 flex w-full flex-row gap-3">
|
||||||
|
<view class="flex-1 bg-primary h-[52px] rounded-xl items-center justify-center" @tap="screen = 'send'">
|
||||||
|
<text class="text-base font-semibold text-primary-foreground">Send</text>
|
||||||
|
</view>
|
||||||
|
<view class="flex-1 bg-zinc-800 h-[52px] rounded-xl items-center justify-center" @tap="screen = 'receive'">
|
||||||
|
<text class="text-base font-semibold text-foreground">Receive</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="self-start mt-7 mb-2 text-lg font-semibold text-foreground">Transactions</text>
|
||||||
|
<scroll-view class="tx-list flex w-full flex-col" :scroll-y="true">
|
||||||
|
<view v-if="transactions.length === 0 && !loading" class="py-6">
|
||||||
|
<text class="text-sm text-center text-muted">No transactions yet</text>
|
||||||
|
</view>
|
||||||
|
<view v-for="tx in transactions" :key="tx.hash" class="mb-2 flex w-full flex-row items-center rounded-xl bg-card p-3.5">
|
||||||
|
<view
|
||||||
|
class="mr-3 h-9 w-9 rounded-full items-center justify-center"
|
||||||
|
:class="tx.direction === 'in' ? 'bg-emerald-950' : 'bg-red-950'"
|
||||||
|
>
|
||||||
|
<text class="text-lg font-bold" :class="tx.direction === 'in' ? 'text-emerald-400' : 'text-red-400'">
|
||||||
|
{{ tx.direction === 'in' ? '↓' : '↑' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="flex flex-1 flex-col">
|
||||||
|
<text class="text-[15px] font-medium text-foreground">{{ tx.direction === 'in' ? 'Received' : 'Sent' }}</text>
|
||||||
|
<text class="mt-0.5 text-xs text-muted">{{ formatDate(tx.timestamp) }} · {{ truncateHash(tx.hash) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="text-[15px] font-semibold" :class="tx.direction === 'in' ? 'text-emerald-400' : 'text-red-400'">
|
||||||
|
{{ tx.direction === 'in' ? '+' : '-' }}{{ tx.amount }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Send -->
|
||||||
|
<view v-else-if="screen === 'send'" class="flex flex-col items-center">
|
||||||
|
<text class="self-start mb-5 text-[26px] font-semibold text-foreground">Send XRP</text>
|
||||||
|
<view class="flex w-full flex-col gap-3">
|
||||||
|
<input
|
||||||
|
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
|
||||||
|
:value="sendTo"
|
||||||
|
@input="sendTo = $event.detail.value"
|
||||||
|
placeholder="Destination address (r...)"
|
||||||
|
placeholder-class="text-muted"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
|
||||||
|
:value="sendAmount"
|
||||||
|
@input="sendAmount = $event.detail.value"
|
||||||
|
placeholder="Amount (XRP)"
|
||||||
|
placeholder-class="text-muted"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
|
||||||
|
:value="sendTag"
|
||||||
|
@input="sendTag = $event.detail.value"
|
||||||
|
placeholder="Destination tag (optional)"
|
||||||
|
placeholder-class="text-muted"
|
||||||
|
/>
|
||||||
|
<view class="h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="handleSend">
|
||||||
|
<text class="text-base font-semibold text-primary-foreground">{{ sending ? 'Sending...' : 'Send' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="h-[52px] bg-zinc-800 w-full rounded-xl items-center justify-center" @tap="goDashboard">
|
||||||
|
<text class="text-base font-semibold text-foreground">Cancel</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text v-if="error" class="mt-3 text-center text-[13px] text-destructive">{{ error }}</text>
|
||||||
|
<text v-if="success" class="mt-3 text-center text-[13px] text-success">{{ success }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Receive -->
|
||||||
|
<view v-else-if="screen === 'receive'" class="flex flex-col items-center">
|
||||||
|
<text class="self-start mb-5 text-[26px] font-semibold text-foreground">Receive XRP</text>
|
||||||
|
<view class="flex w-full flex-col items-center rounded-2xl border border-border bg-card p-7">
|
||||||
|
<text class="text-sm leading-6 text-center text-foreground">{{ showAddress }}</text>
|
||||||
|
<text class="mt-4 text-sm text-center text-muted">Share this address to receive XRP</text>
|
||||||
|
</view>
|
||||||
|
<view class="mt-5 h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="goDashboard">
|
||||||
|
<text class="text-base font-semibold text-primary-foreground">Done</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import './lib/text-encoder'
|
||||||
|
|
||||||
|
declare const lynxCoreInject: { tt?: Record<string, any> } | undefined
|
||||||
|
|
||||||
|
// The LynxExplorer SDK calls tt.callDestroyLifetimeFun() when destroying a
|
||||||
|
// card. vue-lynx's runtime does not define it, which throws during page
|
||||||
|
// teardown and can break subsequent loads. Provide the expected no-op hook.
|
||||||
|
if (typeof lynxCoreInject !== 'undefined' && lynxCoreInject?.tt) {
|
||||||
|
const tt = lynxCoreInject.tt
|
||||||
|
if (typeof tt.callDestroyLifetimeFun !== 'function') {
|
||||||
|
tt.callDestroyLifetimeFun = () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import { createApp } from 'vue-lynx'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.mount()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
declare const lynx: {
|
||||||
|
getStorageSync(key: string): string
|
||||||
|
setStorageSync(key: string, value: string): void
|
||||||
|
removeStorageSync(key: string): void
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
function lynxStorageAvailable(): boolean {
|
||||||
|
return typeof lynx !== 'undefined' && typeof lynx.getStorageSync === 'function'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItem(key: string): string | null {
|
||||||
|
try {
|
||||||
|
const ls = globalThis.localStorage?.getItem(key)
|
||||||
|
if (ls !== null && ls !== undefined) return ls
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
if (lynxStorageAvailable()) return lynx!.getStorageSync(key)
|
||||||
|
} catch {}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setItem(key: string, value: string): void {
|
||||||
|
try {
|
||||||
|
globalThis.localStorage?.setItem(key, value)
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
if (lynxStorageAvailable()) lynx!.setStorageSync(key, value)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeItem(key: string): void {
|
||||||
|
try {
|
||||||
|
globalThis.localStorage?.removeItem(key)
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
if (lynxStorageAvailable()) lynx!.removeStorageSync(key)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// TextEncoder/TextDecoder polyfill for runtimes that lack them (Lynx PrimJS).
|
||||||
|
// Needed by @noble/hashes (xrpl crypto stack) and other WASM-adjacent libs.
|
||||||
|
// Must be imported BEFORE any module that transitively imports xrpl, because
|
||||||
|
// @noble/curves calls utf8ToBytes at module-evaluation time.
|
||||||
|
if (typeof globalThis.TextEncoder === 'undefined') {
|
||||||
|
const utf8Encode = (str: string): Uint8Array => {
|
||||||
|
const bytes: number[] = []
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
let code = str.charCodeAt(i)
|
||||||
|
if (code < 0x80) {
|
||||||
|
bytes.push(code)
|
||||||
|
} else if (code < 0x800) {
|
||||||
|
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f))
|
||||||
|
} else if (code >= 0xd800 && code <= 0xdbff && i + 1 < str.length) {
|
||||||
|
const low = str.charCodeAt(i + 1)
|
||||||
|
if (low >= 0xdc00 && low <= 0xdfff) {
|
||||||
|
i++
|
||||||
|
code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00)
|
||||||
|
bytes.push(
|
||||||
|
0xf0 | (code >> 18),
|
||||||
|
0x80 | ((code >> 12) & 0x3f),
|
||||||
|
0x80 | ((code >> 6) & 0x3f),
|
||||||
|
0x80 | (code & 0x3f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
bytes.push(0xef, 0xbf, 0xbd)
|
||||||
|
}
|
||||||
|
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||||
|
bytes.push(0xef, 0xbf, 0xbd)
|
||||||
|
} else {
|
||||||
|
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Uint8Array.from(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.TextEncoder = class TextEncoder {
|
||||||
|
encoding = 'utf-8'
|
||||||
|
encode(input: string = ''): Uint8Array {
|
||||||
|
return utf8Encode(input)
|
||||||
|
}
|
||||||
|
} as unknown as typeof TextEncoder
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof globalThis.TextDecoder === 'undefined') {
|
||||||
|
globalThis.TextDecoder = class TextDecoder {
|
||||||
|
encoding = 'utf-8'
|
||||||
|
decode(bytes?: Uint8Array): string {
|
||||||
|
if (!bytes) return ''
|
||||||
|
let result = ''
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
const b = bytes[i]
|
||||||
|
if (b < 0x80) {
|
||||||
|
result += String.fromCharCode(b)
|
||||||
|
} else if (b < 0xe0 && i + 1 < bytes.length) {
|
||||||
|
result += String.fromCharCode(((b & 0x1f) << 6) | (bytes[++i] & 0x3f))
|
||||||
|
} else if (b < 0xf0 && i + 2 < bytes.length) {
|
||||||
|
result += String.fromCharCode(
|
||||||
|
((b & 0x0f) << 12) | ((bytes[++i] & 0x3f) << 6) | (bytes[++i] & 0x3f),
|
||||||
|
)
|
||||||
|
} else if (i + 3 < bytes.length) {
|
||||||
|
const cp =
|
||||||
|
((b & 0x07) << 18) |
|
||||||
|
((bytes[++i] & 0x3f) << 12) |
|
||||||
|
((bytes[++i] & 0x3f) << 6) |
|
||||||
|
(bytes[++i] & 0x3f)
|
||||||
|
result += String.fromCodePoint(cp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
} as unknown as typeof TextDecoder
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { Wallet, xrpToDrops, dropsToXrp, isValidClassicAddress } from 'xrpl'
|
||||||
|
import { getItem, setItem, removeItem } from './storage'
|
||||||
|
|
||||||
|
const RPC_URL = 'https://s1.ripple.com:51234/'
|
||||||
|
const RIPPLE_EPOCH = 946684800
|
||||||
|
const WALLET_KEY = 'koins_xrp_wallet'
|
||||||
|
const SEED_KEY = 'koins_xrp_seed'
|
||||||
|
|
||||||
|
export interface XrpWalletInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XrpBalance {
|
||||||
|
balance: string
|
||||||
|
funded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XrpTxEntry {
|
||||||
|
hash: string
|
||||||
|
amount: string
|
||||||
|
timestamp: number
|
||||||
|
direction: 'in' | 'out'
|
||||||
|
fee?: string
|
||||||
|
confirmed: boolean
|
||||||
|
destinationTag?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rpc(command: string, params: Record<string, unknown>): Promise<any> {
|
||||||
|
const res = await fetch(RPC_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
method: command,
|
||||||
|
params: [params],
|
||||||
|
id: 1,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`RPC ${command} failed: HTTP ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
const err = data?.result?.error ?? data?.error
|
||||||
|
if (err) {
|
||||||
|
const msg = data?.result?.error_message ?? data?.error_message ?? err
|
||||||
|
const e = new Error(String(msg))
|
||||||
|
;(e as any).code = err
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
return data.result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidAddress(address: string): boolean {
|
||||||
|
return isValidClassicAddress(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateWallet(): { address: string; seed: string } {
|
||||||
|
const wallet = Wallet.generate()
|
||||||
|
return { address: wallet.classicAddress, seed: wallet.seed! }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function walletFromSecret(secret: string): { address: string } {
|
||||||
|
const s = secret.trim()
|
||||||
|
try {
|
||||||
|
return { address: Wallet.fromSeed(s).classicAddress }
|
||||||
|
} catch {
|
||||||
|
return { address: Wallet.fromMnemonic(s).classicAddress }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function walletForSigning(secret: string): Wallet {
|
||||||
|
const s = secret.trim()
|
||||||
|
try {
|
||||||
|
return Wallet.fromSeed(s)
|
||||||
|
} catch {
|
||||||
|
return Wallet.fromMnemonic(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWallet(): XrpWalletInfo | null {
|
||||||
|
const raw = getItem(WALLET_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as XrpWalletInfo
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWallet(wallet: XrpWalletInfo): void {
|
||||||
|
setItem(WALLET_KEY, JSON.stringify(wallet))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWallet(): void {
|
||||||
|
removeItem(WALLET_KEY)
|
||||||
|
removeItem(SEED_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSeed(): string | null {
|
||||||
|
return getItem(SEED_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSeed(seed: string): void {
|
||||||
|
setItem(SEED_KEY, seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBalance(address: string): Promise<XrpBalance> {
|
||||||
|
try {
|
||||||
|
const result = await rpc('account_info', {
|
||||||
|
account: address,
|
||||||
|
ledger_index: 'current',
|
||||||
|
})
|
||||||
|
const balance = (result.account_data?.Balance ?? '0') as string
|
||||||
|
return { balance: dropsAmount(balance), funded: true }
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === 'actNotFound') {
|
||||||
|
return { balance: '0', funded: false }
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropsAmount(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') return '0'
|
||||||
|
return String(dropsToXrp(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTransactions(address: string): Promise<XrpTxEntry[]> {
|
||||||
|
const result = await rpc('account_tx', {
|
||||||
|
account: address,
|
||||||
|
ledger_index_min: -1,
|
||||||
|
ledger_index_max: -1,
|
||||||
|
limit: 25,
|
||||||
|
})
|
||||||
|
const entries: XrpTxEntry[] = []
|
||||||
|
for (const entry of result.transactions as any[]) {
|
||||||
|
const tx = entry.tx_json ?? entry.tx
|
||||||
|
if (!tx || tx.TransactionType !== 'Payment') continue
|
||||||
|
const meta = entry.meta ?? entry.metaData ?? {}
|
||||||
|
if (meta.TransactionResult && meta.TransactionResult !== 'tesSUCCESS') continue
|
||||||
|
const delivered = meta.delivered_amount ?? meta.DeliveredAmount ?? tx.Amount
|
||||||
|
entries.push({
|
||||||
|
hash: entry.hash ?? tx.hash,
|
||||||
|
amount: dropsAmount(delivered),
|
||||||
|
timestamp: tx.date ? tx.date + RIPPLE_EPOCH : 0,
|
||||||
|
direction: tx.Destination === address ? 'in' : 'out',
|
||||||
|
fee: dropsAmount(tx.Fee),
|
||||||
|
confirmed: entry.validated === true,
|
||||||
|
destinationTag: tx.DestinationTag,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPayment(
|
||||||
|
secret: string,
|
||||||
|
to: string,
|
||||||
|
amount: string,
|
||||||
|
destinationTag?: number,
|
||||||
|
): Promise<{ hash: string; fee: string }> {
|
||||||
|
if (!isValidClassicAddress(to)) throw new Error('Invalid XRP address')
|
||||||
|
const wallet = walletForSigning(secret)
|
||||||
|
const account = wallet.classicAddress
|
||||||
|
|
||||||
|
const [info, feeResult] = await Promise.all([
|
||||||
|
rpc('account_info', { account, ledger_index: 'current' }),
|
||||||
|
rpc('fee', {}),
|
||||||
|
])
|
||||||
|
const accountData = info.account_data ?? {}
|
||||||
|
const sequence = accountData.Sequence as number
|
||||||
|
const balanceDrops = BigInt(accountData.Balance ?? '0')
|
||||||
|
const feeDrops = BigInt(feeResult.drops?.open_ledger_fee ?? '10')
|
||||||
|
|
||||||
|
const amountDrops = BigInt(xrpToDrops(amount))
|
||||||
|
if (amountDrops + feeDrops > balanceDrops) {
|
||||||
|
throw new Error('Insufficient balance (includes network fee)')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx: Record<string, unknown> = {
|
||||||
|
TransactionType: 'Payment',
|
||||||
|
Account: account,
|
||||||
|
Destination: to,
|
||||||
|
Amount: xrpToDrops(amount),
|
||||||
|
Fee: feeDrops.toString(),
|
||||||
|
Sequence: sequence,
|
||||||
|
Flags: 0,
|
||||||
|
}
|
||||||
|
if (destinationTag !== undefined) tx.DestinationTag = destinationTag
|
||||||
|
|
||||||
|
const signed = wallet.sign(tx)
|
||||||
|
const result = await rpc('submit', { tx_blob: signed.tx_blob })
|
||||||
|
const engineResult = result.engine_result
|
||||||
|
if (engineResult !== 'tesSUCCESS') {
|
||||||
|
const msg = result.engine_result_message ?? 'Transaction failed'
|
||||||
|
throw new Error(`${engineResult}: ${msg}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hash: signed.hash,
|
||||||
|
fee: dropsAmount(feeDrops.toString()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/// <reference types="@lynx-js/rspeedy/client" />
|
||||||
|
|
||||||
|
declare module '@lynx-js/types' {
|
||||||
|
interface GlobalProps {
|
||||||
|
/**
|
||||||
|
* Define your global properties in this interface.
|
||||||
|
* These types will be accessible through `lynx.__globalProps`.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
declare module '*.vue' {
|
||||||
|
import type { Component } from 'vue-lynx'
|
||||||
|
|
||||||
|
const component: Component
|
||||||
|
export default component
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.png' {
|
||||||
|
const src: string
|
||||||
|
export default src
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["vue-lynx/types"],
|
||||||
|
|
||||||
|
"noEmit": true,
|
||||||
|
},
|
||||||
|
"vueCompilerOptions": {
|
||||||
|
"plugins": ["vue-lynx/types/volar-plugin"]
|
||||||
|
},
|
||||||
|
"include": ["./**/*.ts", "./**/*.vue"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
import preset from '@lynx-js/tailwind-preset'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: ['./src/**/*.{vue,js,ts}'],
|
||||||
|
presets: [preset],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: '#0a0a0b',
|
||||||
|
foreground: '#fafafa',
|
||||||
|
muted: {
|
||||||
|
DEFAULT: '#a1a1aa',
|
||||||
|
foreground: '#71717a',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: '#18181b',
|
||||||
|
foreground: '#fafafa',
|
||||||
|
},
|
||||||
|
border: '#27272a',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: '#3b82f6',
|
||||||
|
foreground: '#ffffff',
|
||||||
|
},
|
||||||
|
destructive: '#f87171',
|
||||||
|
success: '#34d399',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./src" },
|
||||||
|
],
|
||||||
|
"files": [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
|
||||||
|
// Rspeedy uses Node.js's module resolution
|
||||||
|
"module": "node16",
|
||||||
|
"moduleResolution": "node16",
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
|
||||||
|
// Node.js 18+
|
||||||
|
"lib": ["es2023"],
|
||||||
|
"target": "es2022",
|
||||||
|
|
||||||
|
"noEmit": true,
|
||||||
|
},
|
||||||
|
"include": ["./lynx.config.ts"],
|
||||||
|
}
|
||||||