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.
|
- Never manually edit `.router/router.ts` — sv-router auto-generates it from the filesystem. Just create the route file in the correct filesystem location.
|
||||||
- Only commit when explicitly told to do so. When asked to commit, always write a single simple line commit message.
|
- Only commit when explicitly told to do so. When asked to commit, always write a single simple line commit message.
|
||||||
- When asked to publish, always rebuild the DMG first (`bun run build:stable` from `apps/desktop`), wait for notarization to complete, verify with `xcrun stapler validate`, then publish the fresh DMG. Never publish a stale build.
|
- 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.
|
- 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").
|
- 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: {
|
app: {
|
||||||
name: 'koins',
|
name: 'koins',
|
||||||
identifier: 'koins.tzgyn.com',
|
identifier: 'koins.tzgyn.com',
|
||||||
version: '0.0.6',
|
version: '0.0.12',
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
// Vite builds to dist/, we copy from there
|
// Vite builds to dist/, we copy from there
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -41,13 +41,40 @@ function rpcConnection(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
ms: number,
|
||||||
|
label: string,
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => reject(new Error(`${label} timed out after ${ms}ms`)),
|
||||||
|
ms,
|
||||||
|
)
|
||||||
|
promise.then(
|
||||||
|
(v) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve(v)
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(e)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function rawRpc(
|
async function rawRpc(
|
||||||
state: MoneroWalletState,
|
state: MoneroWalletState,
|
||||||
method: string,
|
method: string,
|
||||||
params: Record<string, any> = {},
|
params: Record<string, any> = {},
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const conn = rpcConnection(state)
|
const conn = rpcConnection(state)
|
||||||
const res = await conn.sendJsonRequest(method, params)
|
const res = await withTimeout(
|
||||||
|
conn.sendJsonRequest(method, params),
|
||||||
|
30000,
|
||||||
|
`wallet-rpc ${method}`,
|
||||||
|
)
|
||||||
if (res?.error)
|
if (res?.error)
|
||||||
throw new Error(res.error.message || JSON.stringify(res.error))
|
throw new Error(res.error.message || JSON.stringify(res.error))
|
||||||
return res?.result
|
return res?.result
|
||||||
@@ -62,7 +89,11 @@ async function waitForRpc(
|
|||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
try {
|
try {
|
||||||
const conn = rpcConnection(state)
|
const conn = rpcConnection(state)
|
||||||
const res = await conn.sendJsonRequest('get_version', {})
|
const res = await withTimeout(
|
||||||
|
conn.sendJsonRequest('get_version', {}),
|
||||||
|
5000,
|
||||||
|
'wallet-rpc probe',
|
||||||
|
)
|
||||||
if (res?.result?.version) {
|
if (res?.result?.version) {
|
||||||
console.log(`[monero] RPC ready after ${Date.now() - start}ms`)
|
console.log(`[monero] RPC ready after ${Date.now() - start}ms`)
|
||||||
return
|
return
|
||||||
@@ -290,7 +321,11 @@ export async function createWallet(
|
|||||||
language: 'English',
|
language: 'English',
|
||||||
})
|
})
|
||||||
if (!state.wallet) throw new Error('Wallet RPC not started')
|
if (!state.wallet) throw new Error('Wallet RPC not started')
|
||||||
const address = await state.wallet.getAddress(0, 0)
|
const address = (await withTimeout(
|
||||||
|
state.wallet.getAddress(0, 0),
|
||||||
|
30000,
|
||||||
|
'wallet get_address',
|
||||||
|
)) as string
|
||||||
console.log(`[monero] wallet created: ${name} -> ${address}`)
|
console.log(`[monero] wallet created: ${name} -> ${address}`)
|
||||||
return { mnemonic: result.mnemonic, address }
|
return { mnemonic: result.mnemonic, address }
|
||||||
}
|
}
|
||||||
@@ -329,7 +364,7 @@ export async function openWallet(
|
|||||||
state.wallet = await connectClient(state)
|
state.wallet = await connectClient(state)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await state.wallet.openWallet(name, password)
|
await withTimeout(state.wallet.openWallet(name, password), 30000, `open_wallet ${name}`)
|
||||||
log(`[monero] wallet opened: ${name}`)
|
log(`[monero] wallet opened: ${name}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(`[monero] wallet open failed (${name})`, e)
|
logError(`[monero] wallet open failed (${name})`, e)
|
||||||
@@ -348,8 +383,16 @@ export async function getBalance(
|
|||||||
state: MoneroWalletState,
|
state: MoneroWalletState,
|
||||||
): Promise<{ balance: bigint; unlocked: bigint }> {
|
): Promise<{ balance: bigint; unlocked: bigint }> {
|
||||||
if (!state.wallet) throw new Error('Wallet RPC not started')
|
if (!state.wallet) throw new Error('Wallet RPC not started')
|
||||||
const balance = (await state.wallet.getBalance()) as bigint
|
const balance = (await withTimeout(
|
||||||
const unlocked = (await state.wallet.getUnlockedBalance()) as bigint
|
state.wallet.getBalance(),
|
||||||
|
30000,
|
||||||
|
'wallet get_balance',
|
||||||
|
)) as bigint
|
||||||
|
const unlocked = (await withTimeout(
|
||||||
|
state.wallet.getUnlockedBalance(),
|
||||||
|
30000,
|
||||||
|
'wallet get_unlocked_balance',
|
||||||
|
)) as bigint
|
||||||
console.log(`[monero] balance: ${balance} (unlocked: ${unlocked})`)
|
console.log(`[monero] balance: ${balance} (unlocked: ${unlocked})`)
|
||||||
return { balance, unlocked }
|
return { balance, unlocked }
|
||||||
}
|
}
|
||||||
@@ -360,9 +403,10 @@ export async function getAddress(
|
|||||||
subIdx = 0,
|
subIdx = 0,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!state.wallet) throw new Error('Wallet RPC not started')
|
if (!state.wallet) throw new Error('Wallet RPC not started')
|
||||||
const address = (await state.wallet.getAddress(
|
const address = (await withTimeout(
|
||||||
accountIdx,
|
state.wallet.getAddress(accountIdx, subIdx),
|
||||||
subIdx,
|
30000,
|
||||||
|
'wallet get_address',
|
||||||
)) as string
|
)) as string
|
||||||
console.log(`[monero] address: ${address}`)
|
console.log(`[monero] address: ${address}`)
|
||||||
return address
|
return address
|
||||||
@@ -398,7 +442,11 @@ export async function getAccounts(
|
|||||||
state: MoneroWalletState,
|
state: MoneroWalletState,
|
||||||
): Promise<any[]> {
|
): Promise<any[]> {
|
||||||
if (!state.wallet) throw new Error('Wallet RPC not started')
|
if (!state.wallet) throw new Error('Wallet RPC not started')
|
||||||
const accounts = (await state.wallet.getAccounts(true)) as any[]
|
const accounts = (await withTimeout(
|
||||||
|
state.wallet.getAccounts(true),
|
||||||
|
30000,
|
||||||
|
'wallet get_accounts',
|
||||||
|
)) as any[]
|
||||||
console.log(`[monero] accounts: ${accounts?.length ?? 0} returned`)
|
console.log(`[monero] accounts: ${accounts?.length ?? 0} returned`)
|
||||||
if (accounts?.length) {
|
if (accounts?.length) {
|
||||||
for (const acct of accounts) {
|
for (const acct of accounts) {
|
||||||
@@ -543,7 +591,11 @@ export async function getHeight(
|
|||||||
state: MoneroWalletState,
|
state: MoneroWalletState,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
if (!state.wallet) throw new Error('Wallet RPC not started')
|
if (!state.wallet) throw new Error('Wallet RPC not started')
|
||||||
const height = (await state.wallet.getHeight()) as number
|
const height = (await withTimeout(
|
||||||
|
state.wallet.getHeight(),
|
||||||
|
30000,
|
||||||
|
'wallet get_height',
|
||||||
|
)) as number
|
||||||
console.log(`[monero] wallet height: ${height}`)
|
console.log(`[monero] wallet height: ${height}`)
|
||||||
return height
|
return height
|
||||||
}
|
}
|
||||||
@@ -587,7 +639,11 @@ export async function getDaemonHeight(
|
|||||||
const daemon = await connectToDaemonRpc(
|
const daemon = await connectToDaemonRpc(
|
||||||
`http://${state.daemonAddress}`,
|
`http://${state.daemonAddress}`,
|
||||||
)
|
)
|
||||||
const height = (await daemon.getHeight()) as number
|
const height = (await withTimeout(
|
||||||
|
daemon.getHeight(),
|
||||||
|
15000,
|
||||||
|
'daemon get_height',
|
||||||
|
)) as number
|
||||||
console.log(`[monero] daemon height: ${height}`)
|
console.log(`[monero] daemon height: ${height}`)
|
||||||
return height
|
return height
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -665,9 +721,13 @@ export async function isWalletOpen(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!state.wallet) return false
|
if (!state.wallet) return false
|
||||||
try {
|
try {
|
||||||
await state.wallet.getHeight()
|
await withTimeout(state.wallet.getHeight(), 8000, 'wallet get_height')
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
// A timeout usually means the wallet-rpc is busy syncing, not that
|
||||||
|
// the wallet is closed. Assume still-open so the UI doesn't flip to
|
||||||
|
// "closed" and try to re-open an already-open wallet.
|
||||||
|
if (e instanceof Error && e.message.includes('timed out')) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -677,10 +737,12 @@ export async function isConnected(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!state.wallet) return false
|
if (!state.wallet) return false
|
||||||
try {
|
try {
|
||||||
const daemon = await connectToDaemonRpc(
|
const daemon = await withTimeout(
|
||||||
`http://${state.daemonAddress}`,
|
connectToDaemonRpc(`http://${state.daemonAddress}`),
|
||||||
|
8000,
|
||||||
|
'daemon connect',
|
||||||
)
|
)
|
||||||
await daemon.getHeight()
|
await withTimeout(daemon.getHeight(), 8000, 'daemon get_height')
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[monero] connection check failed:`, e)
|
console.log(`[monero] connection check failed:`, e)
|
||||||
|
|||||||
@@ -108,12 +108,19 @@ export function createMoneroHandlers(state: {
|
|||||||
console.log('[rpc] moneroRestoreWallet:', name)
|
console.log('[rpc] moneroRestoreWallet:', name)
|
||||||
if (!state.manager)
|
if (!state.manager)
|
||||||
throw new Error('Monero wallet RPC not started')
|
throw new Error('Monero wallet RPC not started')
|
||||||
|
// Default to ~2 days of history instead of a full genesis scan
|
||||||
|
// (which can take hours against a remote daemon).
|
||||||
|
let height = restoreHeight
|
||||||
|
if (!height || height === 0) {
|
||||||
|
const tip = await walletGetDaemonHeight(state.manager)
|
||||||
|
if (tip > 1440) height = tip - 1440
|
||||||
|
}
|
||||||
await walletRestore(
|
await walletRestore(
|
||||||
state.manager,
|
state.manager,
|
||||||
name,
|
name,
|
||||||
password,
|
password,
|
||||||
mnemonic,
|
mnemonic,
|
||||||
restoreHeight,
|
height,
|
||||||
)
|
)
|
||||||
const address = await walletGetAddress(state.manager)
|
const address = await walletGetAddress(state.manager)
|
||||||
console.log(
|
console.log(
|
||||||
@@ -144,6 +151,7 @@ export function createMoneroHandlers(state: {
|
|||||||
console.log('[rpc] moneroGetBalance')
|
console.log('[rpc] moneroGetBalance')
|
||||||
if (!state.manager)
|
if (!state.manager)
|
||||||
throw new Error('Monero wallet RPC not started')
|
throw new Error('Monero wallet RPC not started')
|
||||||
|
try {
|
||||||
const { balance, unlocked } = await walletGetBalance(
|
const { balance, unlocked } = await walletGetBalance(
|
||||||
state.manager,
|
state.manager,
|
||||||
)
|
)
|
||||||
@@ -164,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,
|
||||||
@@ -173,12 +185,17 @@ export function createMoneroHandlers(state: {
|
|||||||
console.log('[rpc] moneroGetTransactions', { accountIndex })
|
console.log('[rpc] moneroGetTransactions', { accountIndex })
|
||||||
if (!state.manager)
|
if (!state.manager)
|
||||||
throw new Error('Monero wallet RPC not started')
|
throw new Error('Monero wallet RPC not started')
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
electrobun,
|
electrobun,
|
||||||
|
frontendLog,
|
||||||
|
frontendLogError,
|
||||||
type MoneroTxEntry,
|
type MoneroTxEntry,
|
||||||
type MoneroAccountEntry,
|
type MoneroAccountEntry,
|
||||||
type MoneroSendResult,
|
type MoneroSendResult,
|
||||||
@@ -25,7 +27,7 @@ export const atomicToXmr = (atomic: string): string => {
|
|||||||
return frac ? `${whole}.${frac}` : `${whole}`
|
return frac ? `${whole}.${frac}` : `${whole}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MoneroWallet = () => {
|
export const createMoneroWallet = () => {
|
||||||
let accountType = $state<'monero' | null>(null)
|
let accountType = $state<'monero' | null>(null)
|
||||||
let ready = $state(false)
|
let ready = $state(false)
|
||||||
let biometricAvailable = $state(false)
|
let biometricAvailable = $state(false)
|
||||||
@@ -51,6 +53,30 @@ export const MoneroWallet = () => {
|
|||||||
let passwordRequired = $state(false)
|
let passwordRequired = $state(false)
|
||||||
let startInFlight: Promise<{ running: boolean; connected: boolean }> | null = null
|
let startInFlight: Promise<{ running: boolean; connected: boolean }> | null = null
|
||||||
let loginInFlight: Promise<void> | null = null
|
let loginInFlight: Promise<void> | null = null
|
||||||
|
let refreshInFlight: Promise<void> | null = null
|
||||||
|
let syncing = $state(false)
|
||||||
|
let syncRetryTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let 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 = () => {
|
const beginOpening = () => {
|
||||||
openingDepth += 1
|
openingDepth += 1
|
||||||
@@ -80,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()
|
||||||
@@ -88,6 +117,8 @@ export const MoneroWallet = () => {
|
|||||||
}
|
}
|
||||||
await checkBiometric()
|
await checkBiometric()
|
||||||
ready = true
|
ready = true
|
||||||
|
})()
|
||||||
|
return initPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
@@ -203,14 +234,22 @@ export const MoneroWallet = () => {
|
|||||||
const checkStatus = async () => {
|
const checkStatus = async () => {
|
||||||
if (!electrobun.rpc) return
|
if (!electrobun.rpc) return
|
||||||
const [binStatus] = await tryCatch(
|
const [binStatus] = await tryCatch(
|
||||||
|
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
|
||||||
@@ -362,13 +401,60 @@ export const MoneroWallet = () => {
|
|||||||
if (remember) await moneroStorePassword(target, password)
|
if (remember) await moneroStorePassword(target, password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scheduleSyncRetry = () => {
|
||||||
|
if (syncRetryTimer) clearTimeout(syncRetryTimer)
|
||||||
|
syncRetryTimer = setTimeout(async () => {
|
||||||
|
syncRetryTimer = null
|
||||||
|
if (walletOpen && !loading) await refresh()
|
||||||
|
}, 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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
|
||||||
@@ -376,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +639,12 @@ export const MoneroWallet = () => {
|
|||||||
get passwordRequired() {
|
get passwordRequired() {
|
||||||
return passwordRequired
|
return passwordRequired
|
||||||
},
|
},
|
||||||
|
get syncing() {
|
||||||
|
return syncing
|
||||||
|
},
|
||||||
|
get price() {
|
||||||
|
return price
|
||||||
|
},
|
||||||
get error() {
|
get error() {
|
||||||
return error
|
return error
|
||||||
},
|
},
|
||||||
@@ -555,6 +694,7 @@ export const MoneroWallet = () => {
|
|||||||
autoUnlock,
|
autoUnlock,
|
||||||
unlockWallet,
|
unlockWallet,
|
||||||
refresh,
|
refresh,
|
||||||
|
fetchPrice,
|
||||||
send,
|
send,
|
||||||
sendAll,
|
sendAll,
|
||||||
createAccount,
|
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()
|
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,30 +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 fetchXrpInFlight = false
|
|
||||||
|
|
||||||
let feError = $state<string | null>(null)
|
let feError = $state<string | null>(null)
|
||||||
|
|
||||||
const usd = (amount: string, price: string | null) =>
|
|
||||||
price ? parseFloat(amount) * parseFloat(price) : 0
|
|
||||||
|
|
||||||
const xrpTotalUsd = $derived.by(() => {
|
const xrpTotalUsd = $derived.by(() => {
|
||||||
const price = xrpPrice
|
const p = xrpWallet.price
|
||||||
return xrpWallet.wallets.reduce((sum, w) => {
|
return xrpWallet.wallets.reduce((sum, w) => {
|
||||||
const b = xrpBalances[w.address]?.balance ?? 0
|
const b = xrpWallet.balances[w.address]?.balance ?? 0
|
||||||
return sum + (price ? b * parseFloat(price) : 0)
|
return sum + (p ? b * parseFloat(p) : 0)
|
||||||
}, 0)
|
}, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
const xrpTotalBalance = $derived.by(() =>
|
const xrpTotalBalance = $derived.by(() =>
|
||||||
xrpWallet.wallets.reduce((sum, w) => {
|
xrpWallet.wallets.reduce((sum, w) => {
|
||||||
return sum + (xrpBalances[w.address]?.balance ?? 0)
|
return sum + (xrpWallet.balances[w.address]?.balance ?? 0)
|
||||||
}, 0),
|
}, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,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,
|
||||||
@@ -70,66 +99,23 @@
|
|||||||
maximumFractionDigits: dp,
|
maximumFractionDigits: dp,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function fetchXrp() {
|
const fmtPct = (v: number) => `${Math.round(v * 100)}%`
|
||||||
if (fetchXrpInFlight) return
|
|
||||||
fetchXrpInFlight = true
|
|
||||||
loadingXrp = true
|
|
||||||
const rpc = electrobun.rpc
|
|
||||||
if (!rpc) {
|
|
||||||
loadingXrp = false
|
|
||||||
fetchXrpInFlight = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const p = await rpc.request.fetchXrpPrice({})
|
|
||||||
if (p) xrpPrice = p.usd
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
await Promise.allSettled(
|
|
||||||
xrpWallet.wallets.map(async (w) => {
|
|
||||||
const res = await rpc.request.xrpGetBalance({
|
|
||||||
address: w.address,
|
|
||||||
})
|
|
||||||
xrpBalances[w.address] = {
|
|
||||||
balance: parseFloat(res.balance),
|
|
||||||
funded: res.funded,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
loadingXrp = false
|
|
||||||
fetchXrpInFlight = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchXmr() {
|
|
||||||
loadingXmr = true
|
|
||||||
const rpc = electrobun.rpc
|
|
||||||
if (!rpc) {
|
|
||||||
loadingXmr = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const p = await rpc.request.fetchMoneroPrice({})
|
|
||||||
xmrPrice = p?.usd ?? null
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
loadingXmr = false
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
frontendLog('dashboard mounted')
|
frontendLog('dashboard mounted')
|
||||||
moneroWallet.init().catch((e) => frontendLogError('moneroWallet.init() failed', e))
|
|
||||||
xrpWallet.init().catch((e) => frontendLogError('xrpWallet.init() failed', e))
|
|
||||||
fetchXmr()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Wait for the XRP wallet list to load, then fetch balances.
|
// Balances live in the stores so they survive navigation; the effects
|
||||||
|
// only trigger background refetches.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (xrpWallet.ready && xrpWallet.wallets.length > 0) {
|
if (xrpWallet.ready && xrpWallet.wallets.length > 0) {
|
||||||
fetchXrp()
|
xrpWallet.fetchBalances()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (moneroWallet.walletOpen) {
|
if (moneroWallet.walletOpen) {
|
||||||
fetchXmr()
|
moneroWallet.fetchPrice()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -189,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 || moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen)}
|
{#if moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen) || (xrpWallet.wallets.length > 0 && !xrpWallet.price) || (moneroWallet.walletOpen && !moneroWallet.price)}
|
||||||
<div class="mt-2 h-9 w-44 rounded-md bg-muted animate-pulse"></div>
|
<div class="mt-2 h-9 w-44 rounded-md bg-muted animate-pulse"></div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mt-1 text-3xl font-semibold tabular-nums">
|
<p class="mt-1 text-3xl font-semibold tabular-nums">
|
||||||
@@ -200,6 +186,43 @@
|
|||||||
{/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')}
|
||||||
@@ -210,21 +233,19 @@
|
|||||||
<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 class="flex flex-1 flex-col justify-center">
|
<CardContent class="flex flex-1 flex-col justify-center">
|
||||||
{#if !xrpWallet.ready || loadingXrp || (xrpWallet.wallets.length > 0 && Object.keys(xrpBalances).length === 0)}
|
{#if !xrpWallet.ready || (xrpWallet.wallets.length > 0 && Object.keys(xrpWallet.balances).length === 0)}
|
||||||
<div class="h-14 flex items-center"><Loader /></div>
|
<div class="h-14 flex items-center"><Loader /></div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="font-mono text-xl tabular-nums">{fmtBal(xrpTotalBalance)}</p>
|
<p class="font-mono text-xl tabular-nums">{fmtBal(xrpTotalBalance)}</p>
|
||||||
{#if xrpPrice}
|
{#if xrpWallet.price}
|
||||||
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
|
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
|
||||||
${fmtUsd(xrpTotalUsd)}
|
${fmtUsd(xrpTotalUsd)}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-xs text-muted-foreground">
|
<p class="mt-2 text-xs text-muted-foreground">
|
||||||
${fmtUsd(parseFloat(xrpPrice) )} / XRP
|
${fmtUsd(parseFloat(xrpWallet.price) )} / XRP
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -241,23 +262,28 @@
|
|||||||
<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 class="flex flex-1 flex-col justify-center">
|
<CardContent class="flex flex-1 flex-col justify-center">
|
||||||
{#if moneroWallet.walletOpen}
|
{#if moneroWallet.walletOpen}
|
||||||
<p class="font-mono text-xl tabular-nums">{fmtBal(xmrBalance, 4)}</p>
|
<p class="font-mono text-xl tabular-nums">{fmtBal(xmrBalance, 4)}</p>
|
||||||
{#if xmrPrice}
|
{#if !moneroWallet.syncing && moneroWallet.price}
|
||||||
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
|
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
|
||||||
${fmtUsd(xmrTotalUsd)}
|
${fmtUsd(xmrTotalUsd)}
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if moneroWallet.price}
|
||||||
<p class="mt-2 text-xs text-muted-foreground">
|
<p class="mt-2 text-xs text-muted-foreground">
|
||||||
${fmtUsd(parseFloat(xmrPrice) )} / XMR
|
${fmtUsd(parseFloat(moneroWallet.price) )} / XMR
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if moneroWallet.passwordRequired && moneroWallet.wallets.length > 0}
|
{:else if moneroWallet.passwordRequired && moneroWallet.wallets.length > 0}
|
||||||
@@ -285,8 +311,6 @@
|
|||||||
<Loader />
|
<Loader />
|
||||||
<p class="text-sm text-muted-foreground">Opening wallet...</p>
|
<p class="text-sm text-muted-foreground">Opening wallet...</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if loadingXmr}
|
|
||||||
<div class="h-14 flex items-center"><Loader /></div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<Button
|
<Button
|
||||||
onclick={() => navigate('/monero')}
|
onclick={() => navigate('/monero')}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
if (!xrpWallet.ready) await xrpWallet.init()
|
if (!xrpWallet.ready) await xrpWallet.init()
|
||||||
if (!moneroWallet.ready) await moneroWallet.init()
|
if (!moneroWallet.ready) await moneroWallet.init()
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
|
xrpWallet.login(),
|
||||||
xrpWallet.autoUnlock(),
|
xrpWallet.autoUnlock(),
|
||||||
moneroWallet.login(),
|
moneroWallet.login(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -24,8 +24,6 @@
|
|||||||
let unlocking = $state(false)
|
let unlocking = $state(false)
|
||||||
let unlockError = $state('')
|
let unlockError = $state('')
|
||||||
|
|
||||||
let initStarted = false
|
|
||||||
|
|
||||||
async function unlockWithPassword() {
|
async function unlockWithPassword() {
|
||||||
if (unlocking || !walletPassword.trim()) return
|
if (unlocking || !walletPassword.trim()) return
|
||||||
unlocking = true
|
unlocking = true
|
||||||
@@ -48,16 +46,6 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!initStarted) {
|
|
||||||
initStarted = true
|
|
||||||
w.init().then(async () => {
|
|
||||||
if (w.accountType === 'monero') return
|
|
||||||
await w.login()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (w.running && !w.walletOpen && w.wallets.length > 0 && !w.loading && !w.opening) {
|
if (w.running && !w.walletOpen && w.wallets.length > 0 && !w.loading && !w.opening) {
|
||||||
w.autoUnlock(w.wallets[0])
|
w.autoUnlock(w.wallets[0])
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -154,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))
|
||||||
@@ -203,7 +202,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex flex-col items-center py-2">
|
<div class="flex flex-col items-center py-2">
|
||||||
{#if w.loading}
|
{#if w.loading && !w.syncing}
|
||||||
<div class="h-12 w-40 rounded-lg bg-muted animate-pulse"></div>
|
<div class="h-12 w-40 rounded-lg bg-muted animate-pulse"></div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-sm text-muted-foreground">Balance</p>
|
<p class="text-sm text-muted-foreground">Balance</p>
|
||||||
@@ -223,6 +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">
|
||||||
|
|||||||
@@ -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
|
||||||
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