Compare commits
15
Commits
v0.0.7
..
d80b58b704
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d80b58b704 | ||
|
|
babd85e614 | ||
|
|
d15152a013 | ||
|
|
3913889a22 | ||
|
|
da0c56658b | ||
|
|
743700b82c | ||
|
|
8953596194 | ||
|
|
5df3ae7058 | ||
|
|
7b4ba84fee | ||
|
|
20f29faa80 | ||
|
|
c0f6417925 | ||
|
|
c61fea938a | ||
|
|
bdfee2a553 | ||
|
|
1c5e5b617f | ||
|
|
11e0bcdaf6 |
@@ -5,6 +5,6 @@
|
||||
- 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.
|
||||
- When asked to publish, always rebuild the DMG first (`bun run build:stable` from `apps/desktop`), wait for notarization to complete, verify with `xcrun stapler validate`, then publish the fresh DMG. Never publish a stale build.
|
||||
- Never publish without explicit permission. Before any publish action — running `bun run build:stable`, `gh release upload`, updating the Homebrew cask, or pushing the tap repo — describe the intended action (what will be built, what SHA, which release) and ask for permission. Once you have asked and received a clear affirmative (any clear yes, "go", "publish", etc.), you may proceed through the whole pipeline without pausing between steps: build, notarize, verify, upload to the GitHub release, update the Homebrew cask, and push the tap repo. One permission covers the entire publish flow. You must still get a separate permission before starting each new publish cycle.
|
||||
- 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").
|
||||
|
||||
@@ -4,7 +4,7 @@ export default {
|
||||
app: {
|
||||
name: 'koins',
|
||||
identifier: 'koins.tzgyn.com',
|
||||
version: '0.0.6',
|
||||
version: '0.0.12',
|
||||
},
|
||||
build: {
|
||||
// Vite builds to dist/, we copy from there
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"d3-shape": "^3.2.0",
|
||||
"electrobun": "^1.18.1",
|
||||
"env-paths": "^4.0.0",
|
||||
"jsqr": "^1.4.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"monero-ts": "^0.11.8",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
@@ -41,13 +41,40 @@ function rpcConnection(
|
||||
})
|
||||
}
|
||||
|
||||
function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
ms: number,
|
||||
label: string,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`${label} timed out after ${ms}ms`)),
|
||||
ms,
|
||||
)
|
||||
promise.then(
|
||||
(v) => {
|
||||
clearTimeout(timer)
|
||||
resolve(v)
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(timer)
|
||||
reject(e)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function rawRpc(
|
||||
state: MoneroWalletState,
|
||||
method: string,
|
||||
params: Record<string, any> = {},
|
||||
): Promise<any> {
|
||||
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)
|
||||
throw new Error(res.error.message || JSON.stringify(res.error))
|
||||
return res?.result
|
||||
@@ -62,7 +89,11 @@ async function waitForRpc(
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const conn = rpcConnection(state)
|
||||
const res = await conn.sendJsonRequest('get_version', {})
|
||||
const res = await withTimeout(
|
||||
conn.sendJsonRequest('get_version', {}),
|
||||
5000,
|
||||
'wallet-rpc probe',
|
||||
)
|
||||
if (res?.result?.version) {
|
||||
console.log(`[monero] RPC ready after ${Date.now() - start}ms`)
|
||||
return
|
||||
@@ -290,7 +321,11 @@ export async function createWallet(
|
||||
language: 'English',
|
||||
})
|
||||
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}`)
|
||||
return { mnemonic: result.mnemonic, address }
|
||||
}
|
||||
@@ -329,7 +364,7 @@ export async function openWallet(
|
||||
state.wallet = await connectClient(state)
|
||||
}
|
||||
try {
|
||||
await state.wallet.openWallet(name, password)
|
||||
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)
|
||||
@@ -348,8 +383,16 @@ export async function getBalance(
|
||||
state: MoneroWalletState,
|
||||
): Promise<{ balance: bigint; unlocked: bigint }> {
|
||||
if (!state.wallet) throw new Error('Wallet RPC not started')
|
||||
const balance = (await state.wallet.getBalance()) as bigint
|
||||
const unlocked = (await state.wallet.getUnlockedBalance()) as bigint
|
||||
const balance = (await withTimeout(
|
||||
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})`)
|
||||
return { balance, unlocked }
|
||||
}
|
||||
@@ -360,9 +403,10 @@ export async function getAddress(
|
||||
subIdx = 0,
|
||||
): Promise<string> {
|
||||
if (!state.wallet) throw new Error('Wallet RPC not started')
|
||||
const address = (await state.wallet.getAddress(
|
||||
accountIdx,
|
||||
subIdx,
|
||||
const address = (await withTimeout(
|
||||
state.wallet.getAddress(accountIdx, subIdx),
|
||||
30000,
|
||||
'wallet get_address',
|
||||
)) as string
|
||||
console.log(`[monero] address: ${address}`)
|
||||
return address
|
||||
@@ -398,7 +442,11 @@ export async function getAccounts(
|
||||
state: MoneroWalletState,
|
||||
): Promise<any[]> {
|
||||
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`)
|
||||
if (accounts?.length) {
|
||||
for (const acct of accounts) {
|
||||
@@ -543,7 +591,11 @@ export async function getHeight(
|
||||
state: MoneroWalletState,
|
||||
): Promise<number> {
|
||||
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}`)
|
||||
return height
|
||||
}
|
||||
@@ -587,7 +639,11 @@ export async function getDaemonHeight(
|
||||
const daemon = await connectToDaemonRpc(
|
||||
`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}`)
|
||||
return height
|
||||
} catch (e) {
|
||||
@@ -665,9 +721,13 @@ export async function isWalletOpen(
|
||||
): Promise<boolean> {
|
||||
if (!state.wallet) return false
|
||||
try {
|
||||
await state.wallet.getHeight()
|
||||
await withTimeout(state.wallet.getHeight(), 8000, 'wallet get_height')
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -677,10 +737,12 @@ export async function isConnected(
|
||||
): Promise<boolean> {
|
||||
if (!state.wallet) return false
|
||||
try {
|
||||
const daemon = await connectToDaemonRpc(
|
||||
`http://${state.daemonAddress}`,
|
||||
const daemon = await withTimeout(
|
||||
connectToDaemonRpc(`http://${state.daemonAddress}`),
|
||||
8000,
|
||||
'daemon connect',
|
||||
)
|
||||
await daemon.getHeight()
|
||||
await withTimeout(daemon.getHeight(), 8000, 'daemon get_height')
|
||||
return true
|
||||
} catch (e) {
|
||||
console.log(`[monero] connection check failed:`, e)
|
||||
|
||||
@@ -108,12 +108,19 @@ export function createMoneroHandlers(state: {
|
||||
console.log('[rpc] moneroRestoreWallet:', name)
|
||||
if (!state.manager)
|
||||
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(
|
||||
state.manager,
|
||||
name,
|
||||
password,
|
||||
mnemonic,
|
||||
restoreHeight,
|
||||
height,
|
||||
)
|
||||
const address = await walletGetAddress(state.manager)
|
||||
console.log(
|
||||
@@ -144,6 +151,7 @@ export function createMoneroHandlers(state: {
|
||||
console.log('[rpc] moneroGetBalance')
|
||||
if (!state.manager)
|
||||
throw new Error('Monero wallet RPC not started')
|
||||
try {
|
||||
const { balance, unlocked } = await walletGetBalance(
|
||||
state.manager,
|
||||
)
|
||||
@@ -164,6 +172,10 @@ export function createMoneroHandlers(state: {
|
||||
height,
|
||||
daemonHeight,
|
||||
}
|
||||
} catch (e) {
|
||||
logError('[rpc] moneroGetBalance failed', e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
moneroGetTransactions: async ({
|
||||
accountIndex,
|
||||
@@ -173,12 +185,17 @@ export function createMoneroHandlers(state: {
|
||||
console.log('[rpc] moneroGetTransactions', { accountIndex })
|
||||
if (!state.manager)
|
||||
throw new Error('Monero wallet RPC not started')
|
||||
try {
|
||||
const txs = await walletGetTransactions(
|
||||
state.manager,
|
||||
accountIndex,
|
||||
)
|
||||
console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`)
|
||||
return txs
|
||||
} catch (e) {
|
||||
logError('[rpc] moneroGetTransactions failed', e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
moneroWalletStatus: async () => {
|
||||
if (!state.manager) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
electrobun,
|
||||
frontendLog,
|
||||
frontendLogError,
|
||||
type MoneroTxEntry,
|
||||
type MoneroAccountEntry,
|
||||
type MoneroSendResult,
|
||||
@@ -25,7 +27,7 @@ export const atomicToXmr = (atomic: string): string => {
|
||||
return frac ? `${whole}.${frac}` : `${whole}`
|
||||
}
|
||||
|
||||
export const MoneroWallet = () => {
|
||||
export const createMoneroWallet = () => {
|
||||
let accountType = $state<'monero' | null>(null)
|
||||
let ready = $state(false)
|
||||
let biometricAvailable = $state(false)
|
||||
@@ -51,6 +53,30 @@ export const MoneroWallet = () => {
|
||||
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
|
||||
@@ -80,7 +106,10 @@ export const MoneroWallet = () => {
|
||||
return authed === true
|
||||
}
|
||||
|
||||
let initPromise: Promise<void> | null = null
|
||||
const init = async () => {
|
||||
if (initPromise) return initPromise
|
||||
initPromise = (async () => {
|
||||
await checkStatus()
|
||||
if (installed && !running && !downloading) {
|
||||
await start()
|
||||
@@ -88,6 +117,8 @@ export const MoneroWallet = () => {
|
||||
}
|
||||
await checkBiometric()
|
||||
ready = true
|
||||
})()
|
||||
return initPromise
|
||||
}
|
||||
|
||||
const login = async () => {
|
||||
@@ -203,14 +234,22 @@ export const MoneroWallet = () => {
|
||||
const checkStatus = async () => {
|
||||
if (!electrobun.rpc) return
|
||||
const [binStatus] = await tryCatch(
|
||||
rpcTimeout(
|
||||
electrobun.rpc.request.moneroBinaryStatus({}),
|
||||
15000,
|
||||
'moneroBinaryStatus',
|
||||
),
|
||||
)
|
||||
if (binStatus) {
|
||||
installed = binStatus.installed
|
||||
downloading = binStatus.downloading
|
||||
}
|
||||
const [status] = await tryCatch(
|
||||
rpcTimeout(
|
||||
electrobun.rpc.request.moneroWalletStatus({}),
|
||||
30000,
|
||||
'moneroWalletStatus',
|
||||
),
|
||||
)
|
||||
if (status) {
|
||||
running = status.running
|
||||
@@ -362,13 +401,60 @@ export const MoneroWallet = () => {
|
||||
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 () => {
|
||||
if (!electrobun.rpc) return
|
||||
const rpc = electrobun.rpc
|
||||
if (!rpc) return
|
||||
if (refreshInFlight) return refreshInFlight
|
||||
refreshInFlight = (async () => {
|
||||
loading = true
|
||||
let failed = false
|
||||
const wasSyncing = syncing
|
||||
let lastError: unknown = null
|
||||
try {
|
||||
const [bal] = await tryCatch(
|
||||
electrobun.rpc.request.moneroGetBalance({}),
|
||||
const [bal, balErr] = await tryCatch(
|
||||
rpcTimeout(
|
||||
rpc.request.moneroGetBalance({}),
|
||||
20000,
|
||||
'moneroGetBalance',
|
||||
),
|
||||
)
|
||||
if (balErr) {
|
||||
failed = true
|
||||
lastError = balErr
|
||||
}
|
||||
if (bal) {
|
||||
balAtomic = bal.balance
|
||||
unlockedAtomic = bal.unlocked
|
||||
@@ -376,17 +462,64 @@ export const MoneroWallet = () => {
|
||||
height = bal.height
|
||||
daemonHeight = bal.daemonHeight
|
||||
}
|
||||
const [result] = await tryCatch(
|
||||
electrobun.rpc.request.moneroGetTransactions({
|
||||
const [result, txErr] = await tryCatch(
|
||||
rpcTimeout(
|
||||
rpc.request.moneroGetTransactions({
|
||||
accountIndex: selectedAccountIndex,
|
||||
}),
|
||||
20000,
|
||||
'moneroGetTransactions',
|
||||
),
|
||||
)
|
||||
if (txErr) {
|
||||
failed = true
|
||||
lastError = txErr
|
||||
}
|
||||
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) {
|
||||
error = e instanceof Error ? e.message : 'Monero refresh failed'
|
||||
syncing = true
|
||||
scheduleSyncRetry()
|
||||
frontendLogError(
|
||||
`monero refresh failed (height ${height}/${daemonHeight})`,
|
||||
e,
|
||||
)
|
||||
} finally {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,6 +639,12 @@ export const MoneroWallet = () => {
|
||||
get passwordRequired() {
|
||||
return passwordRequired
|
||||
},
|
||||
get syncing() {
|
||||
return syncing
|
||||
},
|
||||
get price() {
|
||||
return price
|
||||
},
|
||||
get error() {
|
||||
return error
|
||||
},
|
||||
@@ -555,6 +694,7 @@ export const MoneroWallet = () => {
|
||||
autoUnlock,
|
||||
unlockWallet,
|
||||
refresh,
|
||||
fetchPrice,
|
||||
send,
|
||||
sendAll,
|
||||
createAccount,
|
||||
@@ -562,4 +702,15 @@ export const MoneroWallet = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton: calling MoneroWallet() always returns the same instance, so
|
||||
// no component can ever create a second, disconnected wallet state.
|
||||
type MoneroWalletStore = ReturnType<typeof createMoneroWallet>
|
||||
|
||||
let moneroWalletInstance: MoneroWalletStore | null = null
|
||||
|
||||
export const MoneroWallet = (): MoneroWalletStore => {
|
||||
if (!moneroWalletInstance) moneroWalletInstance = createMoneroWallet()
|
||||
return moneroWalletInstance
|
||||
}
|
||||
|
||||
export const moneroWallet = MoneroWallet()
|
||||
|
||||
@@ -13,7 +13,7 @@ export const xrpNetwork = {
|
||||
explorerAddressUrl: 'https://xrpscan.com/account/',
|
||||
} as const
|
||||
|
||||
export const XrpWallet = () => {
|
||||
export const createXrpWallet = () => {
|
||||
let accountType = $state<'xrp' | null>(null)
|
||||
let ready = $state(false)
|
||||
let biometricAvailable = $state(false)
|
||||
@@ -29,6 +29,9 @@ export const XrpWallet = () => {
|
||||
let loading = $state(false)
|
||||
let error = $state('')
|
||||
let wallets = $state<XrpWalletInfo[]>([])
|
||||
let balances = $state<Record<string, { balance: number; funded: boolean }>>({})
|
||||
let balancesLoading = $state(false)
|
||||
let fetchBalancesInFlight = false
|
||||
let currentWalletId = $state<string | null>(null)
|
||||
let currentPasswordHash = $state<string | null>(null)
|
||||
|
||||
@@ -73,7 +76,10 @@ export const XrpWallet = () => {
|
||||
biometricAvailable = ok === true
|
||||
}
|
||||
|
||||
let initPromise: Promise<void> | null = null
|
||||
const init = async () => {
|
||||
if (initPromise) return initPromise
|
||||
initPromise = (async () => {
|
||||
if (!electrobun.rpc) return
|
||||
const [walletList] = await tryCatch(
|
||||
electrobun.rpc.request.xrpListWallets({}),
|
||||
@@ -86,6 +92,8 @@ export const XrpWallet = () => {
|
||||
}
|
||||
await checkBiometric()
|
||||
ready = true
|
||||
})()
|
||||
return initPromise
|
||||
}
|
||||
|
||||
const login = async () => {
|
||||
@@ -319,6 +327,34 @@ export const XrpWallet = () => {
|
||||
})()
|
||||
}
|
||||
|
||||
const fetchBalances = async () => {
|
||||
const rpc = electrobun.rpc
|
||||
if (!rpc || fetchBalancesInFlight) return
|
||||
if (wallets.length === 0) return
|
||||
fetchBalancesInFlight = true
|
||||
balancesLoading = true
|
||||
try {
|
||||
const [p] = await tryCatch(rpc.request.fetchXrpPrice({}))
|
||||
if (p) price = p.usd
|
||||
await Promise.allSettled(
|
||||
wallets.map(async (w) => {
|
||||
const [res] = await tryCatch(
|
||||
rpc.request.xrpGetBalance({ address: w.address }),
|
||||
)
|
||||
if (res) {
|
||||
balances[w.address] = {
|
||||
balance: parseFloat(res.balance),
|
||||
funded: res.funded,
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
balancesLoading = false
|
||||
fetchBalancesInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
const send = async (
|
||||
to: string,
|
||||
amount: string,
|
||||
@@ -399,6 +435,12 @@ export const XrpWallet = () => {
|
||||
get wallets() {
|
||||
return wallets
|
||||
},
|
||||
get balances() {
|
||||
return balances
|
||||
},
|
||||
get balancesLoading() {
|
||||
return balancesLoading
|
||||
},
|
||||
get currentWalletId() {
|
||||
return currentWalletId
|
||||
},
|
||||
@@ -424,6 +466,7 @@ export const XrpWallet = () => {
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
fetchBalances,
|
||||
createWallet,
|
||||
importWallet,
|
||||
lock,
|
||||
@@ -442,4 +485,15 @@ export const XrpWallet = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton: calling XrpWallet() always returns the same instance, so no
|
||||
// component can ever create a second, disconnected wallet state.
|
||||
type XrpWalletStore = ReturnType<typeof createXrpWallet>
|
||||
|
||||
let xrpWalletInstance: XrpWalletStore | null = null
|
||||
|
||||
export const XrpWallet = (): XrpWalletStore => {
|
||||
if (!xrpWalletInstance) xrpWalletInstance = createXrpWallet()
|
||||
return xrpWalletInstance
|
||||
}
|
||||
|
||||
export const xrpWallet = XrpWallet()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { moneroWallet } from '$lib/states/monero-wallet.svelte.js'
|
||||
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
|
||||
import { electrobun } from '$lib/electrobun.js'
|
||||
import { navigate } from 'sv-router/generated'
|
||||
import { onMount } from 'svelte'
|
||||
import {
|
||||
@@ -16,30 +15,23 @@
|
||||
import WalletIcon from '@lucide/svelte/icons/wallet'
|
||||
import Loader from '$lib/components/loader.svelte'
|
||||
import { frontendLog, frontendLogError } from '$lib/electrobun.js'
|
||||
|
||||
let xrpPrice = $state<string | null>(null)
|
||||
let xmrPrice = $state<string | null>(null)
|
||||
let xrpBalances = $state<Record<string, { balance: number; funded: boolean }>>({})
|
||||
let loadingXrp = $state(true)
|
||||
let loadingXmr = $state(true)
|
||||
let fetchXrpInFlight = false
|
||||
import { arc } from 'd3-shape'
|
||||
import { tweened } from 'svelte/motion'
|
||||
import { cubicOut } from 'svelte/easing'
|
||||
|
||||
let feError = $state<string | null>(null)
|
||||
|
||||
const usd = (amount: string, price: string | null) =>
|
||||
price ? parseFloat(amount) * parseFloat(price) : 0
|
||||
|
||||
const xrpTotalUsd = $derived.by(() => {
|
||||
const price = xrpPrice
|
||||
const p = xrpWallet.price
|
||||
return xrpWallet.wallets.reduce((sum, w) => {
|
||||
const b = xrpBalances[w.address]?.balance ?? 0
|
||||
return sum + (price ? b * parseFloat(price) : 0)
|
||||
const b = xrpWallet.balances[w.address]?.balance ?? 0
|
||||
return sum + (p ? b * parseFloat(p) : 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const xrpTotalBalance = $derived.by(() =>
|
||||
xrpWallet.wallets.reduce((sum, w) => {
|
||||
return sum + (xrpBalances[w.address]?.balance ?? 0)
|
||||
return sum + (xrpWallet.balances[w.address]?.balance ?? 0)
|
||||
}, 0),
|
||||
)
|
||||
|
||||
@@ -47,17 +39,54 @@
|
||||
moneroWallet.walletOpen ? parseFloat(moneroWallet.balance || '0') : 0,
|
||||
)
|
||||
const xmrTotalUsd = $derived(
|
||||
moneroWallet.walletOpen && xmrPrice
|
||||
? xmrBalance * parseFloat(xmrPrice)
|
||||
moneroWallet.walletOpen && moneroWallet.price
|
||||
? xmrBalance * parseFloat(moneroWallet.price)
|
||||
: 0,
|
||||
)
|
||||
|
||||
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 hasXmr = $derived(moneroWallet.wallets.length > 0)
|
||||
const noWallets = $derived(!hasXrp && !hasXmr)
|
||||
|
||||
const moneroDot = $derived(
|
||||
!moneroWallet.running
|
||||
? 'bg-muted'
|
||||
: !moneroWallet.connected
|
||||
? 'bg-red-500'
|
||||
: moneroWallet.syncing || moneroWallet.height < moneroWallet.daemonHeight
|
||||
? 'bg-yellow-500 animate-pulse'
|
||||
: 'bg-emerald-500',
|
||||
)
|
||||
|
||||
const fmtUsd = (v: number) =>
|
||||
v.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
@@ -70,66 +99,23 @@
|
||||
maximumFractionDigits: dp,
|
||||
})
|
||||
|
||||
async function fetchXrp() {
|
||||
if (fetchXrpInFlight) return
|
||||
fetchXrpInFlight = true
|
||||
loadingXrp = true
|
||||
const rpc = electrobun.rpc
|
||||
if (!rpc) {
|
||||
loadingXrp = false
|
||||
fetchXrpInFlight = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const p = await rpc.request.fetchXrpPrice({})
|
||||
if (p) xrpPrice = p.usd
|
||||
} catch { /* ignore */ }
|
||||
await Promise.allSettled(
|
||||
xrpWallet.wallets.map(async (w) => {
|
||||
const res = await rpc.request.xrpGetBalance({
|
||||
address: w.address,
|
||||
})
|
||||
xrpBalances[w.address] = {
|
||||
balance: parseFloat(res.balance),
|
||||
funded: res.funded,
|
||||
}
|
||||
}),
|
||||
)
|
||||
loadingXrp = false
|
||||
fetchXrpInFlight = false
|
||||
}
|
||||
|
||||
async function fetchXmr() {
|
||||
loadingXmr = true
|
||||
const rpc = electrobun.rpc
|
||||
if (!rpc) {
|
||||
loadingXmr = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const p = await rpc.request.fetchMoneroPrice({})
|
||||
xmrPrice = p?.usd ?? null
|
||||
} catch { /* ignore */ }
|
||||
loadingXmr = false
|
||||
}
|
||||
const fmtPct = (v: number) => `${Math.round(v * 100)}%`
|
||||
|
||||
onMount(() => {
|
||||
frontendLog('dashboard mounted')
|
||||
moneroWallet.init().catch((e) => frontendLogError('moneroWallet.init() failed', e))
|
||||
xrpWallet.init().catch((e) => frontendLogError('xrpWallet.init() failed', e))
|
||||
fetchXmr()
|
||||
})
|
||||
|
||||
// Wait for the XRP wallet list to load, then fetch balances.
|
||||
// Balances live in the stores so they survive navigation; the effects
|
||||
// only trigger background refetches.
|
||||
$effect(() => {
|
||||
if (xrpWallet.ready && xrpWallet.wallets.length > 0) {
|
||||
fetchXrp()
|
||||
xrpWallet.fetchBalances()
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (moneroWallet.walletOpen) {
|
||||
fetchXmr()
|
||||
moneroWallet.fetchPrice()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -189,9 +175,9 @@
|
||||
</div>
|
||||
</div>
|
||||
{: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>
|
||||
{#if loadingXrp || loadingXmr || moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen)}
|
||||
{#if moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen) || (xrpWallet.wallets.length > 0 && !xrpWallet.price) || (moneroWallet.walletOpen && !moneroWallet.price)}
|
||||
<div class="mt-2 h-9 w-44 rounded-md bg-muted animate-pulse"></div>
|
||||
{:else}
|
||||
<p class="mt-1 text-3xl font-semibold tabular-nums">
|
||||
@@ -200,6 +186,43 @@
|
||||
{/if}
|
||||
</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">
|
||||
<button
|
||||
onclick={() => navigate('/xrp')}
|
||||
@@ -210,21 +233,19 @@
|
||||
<img src="/icons/xrp.png" alt="" class="size-5 rounded-full" />
|
||||
<CardTitle class="text-base">XRP</CardTitle>
|
||||
</div>
|
||||
<CardDescription class="text-xs">
|
||||
{xrpWallet.wallets.length} wallet{xrpWallet.wallets.length !== 1 ? 's' : ''}
|
||||
</CardDescription>
|
||||
<CardDescription class="text-xs">XRP Ledger</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-1 flex-col justify-center">
|
||||
{#if !xrpWallet.ready || loadingXrp || (xrpWallet.wallets.length > 0 && Object.keys(xrpBalances).length === 0)}
|
||||
{#if !xrpWallet.ready || (xrpWallet.wallets.length > 0 && Object.keys(xrpWallet.balances).length === 0)}
|
||||
<div class="h-14 flex items-center"><Loader /></div>
|
||||
{:else}
|
||||
<p class="font-mono text-xl tabular-nums">{fmtBal(xrpTotalBalance)}</p>
|
||||
{#if xrpPrice}
|
||||
{#if xrpWallet.price}
|
||||
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
|
||||
${fmtUsd(xrpTotalUsd)}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
${fmtUsd(parseFloat(xrpPrice) )} / XRP
|
||||
${fmtUsd(parseFloat(xrpWallet.price) )} / XRP
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -241,23 +262,28 @@
|
||||
<img src="/icons/monero.png" alt="" class="size-5 rounded-full" />
|
||||
<CardTitle class="text-base">Monero</CardTitle>
|
||||
</div>
|
||||
<CardDescription class="text-xs">
|
||||
{moneroWallet.walletOpen
|
||||
? moneroWallet.walletName || 'Wallet open'
|
||||
: moneroWallet.wallets.length > 0
|
||||
? `${moneroWallet.wallets.length} wallet${moneroWallet.wallets.length !== 1 ? 's' : ''}`
|
||||
: 'Not set up'}
|
||||
<CardDescription class="flex items-center gap-1.5 text-xs">
|
||||
<span class="size-2 shrink-0 rounded-full {moneroDot}"></span>
|
||||
{#if !moneroWallet.running}
|
||||
Server offline
|
||||
{:else if !moneroWallet.connected}
|
||||
Connecting to daemon…
|
||||
{:else}
|
||||
{fmtBal(moneroWallet.height, 0)} / {fmtBal(moneroWallet.daemonHeight, 0)}
|
||||
{/if}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-1 flex-col justify-center">
|
||||
{#if moneroWallet.walletOpen}
|
||||
<p class="font-mono text-xl tabular-nums">{fmtBal(xmrBalance, 4)}</p>
|
||||
{#if xmrPrice}
|
||||
{#if !moneroWallet.syncing && moneroWallet.price}
|
||||
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
|
||||
${fmtUsd(xmrTotalUsd)}
|
||||
</p>
|
||||
{/if}
|
||||
{#if moneroWallet.price}
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
${fmtUsd(parseFloat(xmrPrice) )} / XMR
|
||||
${fmtUsd(parseFloat(moneroWallet.price) )} / XMR
|
||||
</p>
|
||||
{/if}
|
||||
{:else if moneroWallet.passwordRequired && moneroWallet.wallets.length > 0}
|
||||
@@ -285,8 +311,6 @@
|
||||
<Loader />
|
||||
<p class="text-sm text-muted-foreground">Opening wallet...</p>
|
||||
</div>
|
||||
{:else if loadingXmr}
|
||||
<div class="h-14 flex items-center"><Loader /></div>
|
||||
{:else}
|
||||
<Button
|
||||
onclick={() => navigate('/monero')}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
if (!xrpWallet.ready) await xrpWallet.init()
|
||||
if (!moneroWallet.ready) await moneroWallet.init()
|
||||
await Promise.allSettled([
|
||||
xrpWallet.login(),
|
||||
xrpWallet.autoUnlock(),
|
||||
moneroWallet.login(),
|
||||
])
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
let unlocking = $state(false)
|
||||
let unlockError = $state('')
|
||||
|
||||
let initStarted = false
|
||||
|
||||
async function unlockWithPassword() {
|
||||
if (unlocking || !walletPassword.trim()) return
|
||||
unlocking = true
|
||||
@@ -48,16 +46,6 @@
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!initStarted) {
|
||||
initStarted = true
|
||||
w.init().then(async () => {
|
||||
if (w.accountType === 'monero') return
|
||||
await w.login()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (w.running && !w.walletOpen && w.wallets.length > 0 && !w.loading && !w.opening) {
|
||||
w.autoUnlock(w.wallets[0])
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
SelectTrigger,
|
||||
} from '$lib/components/ui/select/index.js'
|
||||
import { navigate } from 'sv-router/generated'
|
||||
import QrUpload from '$lib/components/qr-upload.svelte'
|
||||
import { parseQrPayload } from '$lib/qr.js'
|
||||
|
||||
const w = moneroWallet
|
||||
|
||||
@@ -134,7 +136,15 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<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
|
||||
placeholder="Monero address starting with 4..."
|
||||
bind:value={recipient} />
|
||||
|
||||
@@ -15,10 +15,6 @@
|
||||
const w = moneroWallet
|
||||
|
||||
let moneroSettingsPw = $state('')
|
||||
|
||||
$effect(() => {
|
||||
w.init()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-xl">
|
||||
|
||||
@@ -154,7 +154,6 @@
|
||||
$effect(() => {
|
||||
if (w.walletOpen && !autoRefreshTimer) {
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
w.refresh()
|
||||
electrobun.rpc?.request
|
||||
.fetchMoneroPrice({})
|
||||
.then((p) => (moneroPrice = p))
|
||||
@@ -203,7 +202,7 @@
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<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>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">Balance</p>
|
||||
@@ -223,6 +222,14 @@
|
||||
Unlocked: {w.unlocked} XMR
|
||||
</p>
|
||||
{/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="size-2 rounded-full {w.connected ? 'bg-green-500' : 'bg-red-500'}"></div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
|
||||
@@ -22,18 +22,6 @@
|
||||
let inputUnlockPassword = $state('')
|
||||
let newSeed = $state<string | null>(null)
|
||||
|
||||
let initStarted = false
|
||||
|
||||
$effect(() => {
|
||||
if (!initStarted) {
|
||||
initStarted = true
|
||||
w.init().then(async () => {
|
||||
if (w.accountType === 'xrp') return
|
||||
await w.login()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (w.accountType === 'xrp' && w.currentWalletId && w.seed && !newSeed) {
|
||||
navigate('/xrp/wallet/:id', {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
CardDescription,
|
||||
} from '$lib/components/ui/card/index.js'
|
||||
import { navigate } from 'sv-router/generated'
|
||||
import QrUpload from '$lib/components/qr-upload.svelte'
|
||||
import { parseQrPayload } from '$lib/qr.js'
|
||||
|
||||
const w = xrpWallet
|
||||
|
||||
@@ -78,7 +80,16 @@
|
||||
}}
|
||||
class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<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
|
||||
placeholder="XRP address starting with r..."
|
||||
bind:value={recipient} />
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Local
|
||||
.DS_Store
|
||||
*.local
|
||||
*.log*
|
||||
|
||||
# Dist
|
||||
node_modules
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
Vendored
+3
@@ -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()),
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -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 {}
|
||||
Vendored
+11
@@ -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"],
|
||||
}
|
||||
Reference in New Issue
Block a user