add monero accounts/addresses, fix rpc auth, fix binary extraction
This commit is contained in:
@@ -21,6 +21,13 @@ import { db } from './lib/db'
|
||||
import { join } from 'path'
|
||||
import { getENV } from './lib/get-env'
|
||||
import { getClient, getTransactionDetails } from './lib/viem'
|
||||
import {
|
||||
isBinaryInstalled,
|
||||
downloadBinary,
|
||||
getBinaryPath,
|
||||
getBinaryDir,
|
||||
MoneroWalletManager,
|
||||
} from './lib/monero'
|
||||
|
||||
const env = getENV()
|
||||
|
||||
@@ -169,6 +176,71 @@ type RPC = {
|
||||
}
|
||||
response: void
|
||||
}
|
||||
moneroBinaryStatus: {
|
||||
params: {}
|
||||
response: {
|
||||
installed: boolean
|
||||
downloading: boolean
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
moneroDownloadBinary: {
|
||||
params: {}
|
||||
response: void
|
||||
}
|
||||
moneroStart: {
|
||||
params: { daemonAddress?: string }
|
||||
response: {
|
||||
running: boolean
|
||||
walletOpen: boolean
|
||||
walletName?: string
|
||||
connected: boolean
|
||||
}
|
||||
}
|
||||
moneroStop: {
|
||||
params: {}
|
||||
response: void
|
||||
}
|
||||
moneroCreateWallet: {
|
||||
params: { name: string; password: string }
|
||||
response: { mnemonic: string; address: string }
|
||||
}
|
||||
moneroRestoreWallet: {
|
||||
params: { name: string; password: string; mnemonic: string; restoreHeight?: number }
|
||||
response: { address: string }
|
||||
}
|
||||
moneroOpenWallet: {
|
||||
params: { name: string; password: string }
|
||||
response: void
|
||||
}
|
||||
moneroGetBalance: {
|
||||
params: {}
|
||||
response: {
|
||||
balance: string
|
||||
unlocked: string
|
||||
address: string
|
||||
height: number
|
||||
}
|
||||
}
|
||||
moneroGetTransactions: {
|
||||
params: {}
|
||||
response: Array<{
|
||||
hash: string
|
||||
amount: string
|
||||
timestamp: string
|
||||
direction: 'in' | 'out'
|
||||
height: number
|
||||
}>
|
||||
}
|
||||
moneroWalletStatus: {
|
||||
params: {}
|
||||
response: {
|
||||
running: boolean
|
||||
walletOpen: boolean
|
||||
walletName?: string
|
||||
connected: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
messages: {}
|
||||
}>
|
||||
@@ -274,8 +346,11 @@ function mapTransfer(t: any): TxEntry {
|
||||
}
|
||||
}
|
||||
|
||||
let moneroManager: MoneroWalletManager | null = null
|
||||
let moneroDownloading = false
|
||||
|
||||
const rpc = BrowserView.defineRPC<RPC>({
|
||||
maxRequestTime: 10000,
|
||||
maxRequestTime: 120000,
|
||||
handlers: {
|
||||
requests: {
|
||||
getSecret: async ({ name, service }) => {
|
||||
@@ -441,6 +516,112 @@ const rpc = BrowserView.defineRPC<RPC>({
|
||||
return null
|
||||
}
|
||||
},
|
||||
moneroBinaryStatus: async () => {
|
||||
const status = { installed: isBinaryInstalled(), downloading: moneroDownloading }
|
||||
console.log('[rpc] moneroBinaryStatus:', status)
|
||||
return status
|
||||
},
|
||||
moneroDownloadBinary: async () => {
|
||||
console.log('[rpc] moneroDownloadBinary: starting download...')
|
||||
moneroDownloading = true
|
||||
try {
|
||||
await downloadBinary()
|
||||
console.log('[rpc] moneroDownloadBinary: complete')
|
||||
} catch (e) {
|
||||
console.log('[rpc] moneroDownloadBinary failed:', e)
|
||||
throw e
|
||||
} finally {
|
||||
moneroDownloading = false
|
||||
}
|
||||
},
|
||||
moneroStart: async ({ daemonAddress }) => {
|
||||
console.log('[rpc] moneroStart:', { daemonAddress })
|
||||
if (!moneroManager) {
|
||||
moneroManager = new MoneroWalletManager()
|
||||
}
|
||||
try {
|
||||
await moneroManager.start(daemonAddress)
|
||||
} catch (e) {
|
||||
console.log('[rpc] moneroStart error:', e)
|
||||
return { running: false, walletOpen: false, connected: false }
|
||||
}
|
||||
const connected = await moneroManager.isConnected()
|
||||
console.log('[rpc] moneroStart complete, connected:', connected)
|
||||
return { running: true, walletOpen: false, connected }
|
||||
},
|
||||
moneroStop: async () => {
|
||||
console.log('[rpc] moneroStop')
|
||||
if (moneroManager) {
|
||||
await moneroManager.stop()
|
||||
moneroManager = null
|
||||
}
|
||||
},
|
||||
moneroCreateWallet: async ({ name, password }) => {
|
||||
console.log('[rpc] moneroCreateWallet:', name)
|
||||
if (!moneroManager) throw new Error('Monero wallet RPC not started')
|
||||
const result = await moneroManager.createWallet(name, password)
|
||||
console.log('[rpc] moneroCreateWallet complete')
|
||||
return result
|
||||
},
|
||||
moneroRestoreWallet: async ({ name, password, mnemonic, restoreHeight }) => {
|
||||
console.log('[rpc] moneroRestoreWallet:', name)
|
||||
if (!moneroManager) throw new Error('Monero wallet RPC not started')
|
||||
await moneroManager.restoreWallet(name, password, mnemonic, restoreHeight)
|
||||
const address = await moneroManager.getAddress()
|
||||
console.log('[rpc] moneroRestoreWallet complete, address:', address)
|
||||
return { address }
|
||||
},
|
||||
moneroOpenWallet: async ({ name, password }) => {
|
||||
console.log('[rpc] moneroOpenWallet:', name)
|
||||
if (!moneroManager) throw new Error('Monero wallet RPC not started')
|
||||
await moneroManager.openWallet(name, password)
|
||||
},
|
||||
moneroGetBalance: async () => {
|
||||
console.log('[rpc] moneroGetBalance')
|
||||
if (!moneroManager) throw new Error('Monero wallet RPC not started')
|
||||
const { balance, unlocked } = await moneroManager.getBalance()
|
||||
const address = await moneroManager.getAddress()
|
||||
const height = await moneroManager.getHeight()
|
||||
const daemonHeight = await moneroManager.getDaemonHeight()
|
||||
console.log('[rpc] balance:', { balance: balance.toString(), unlocked: unlocked.toString(), address, height, daemonHeight })
|
||||
return {
|
||||
balance: balance.toString(),
|
||||
unlocked: unlocked.toString(),
|
||||
address,
|
||||
height,
|
||||
daemonHeight,
|
||||
}
|
||||
},
|
||||
moneroGetTransactions: async () => {
|
||||
console.log('[rpc] moneroGetTransactions')
|
||||
if (!moneroManager) throw new Error('Monero wallet RPC not started')
|
||||
const txs = await moneroManager.getTransactions()
|
||||
const result = (txs || []).map((tx: any) => ({
|
||||
hash: tx.hash,
|
||||
amount: tx.amount?.toString() ?? '0',
|
||||
timestamp: String(tx.timestamp ?? 0),
|
||||
direction: tx.direction === 'in' ? 'in' as const : 'out' as const,
|
||||
height: tx.height ?? 0,
|
||||
}))
|
||||
console.log(`[rpc] moneroGetTransactions: ${result.length} txs`)
|
||||
return result
|
||||
},
|
||||
moneroWalletStatus: async () => {
|
||||
if (!moneroManager) {
|
||||
console.log('[rpc] moneroWalletStatus: not running')
|
||||
return { running: false, walletOpen: false, connected: false }
|
||||
}
|
||||
const connected = await moneroManager.isConnected()
|
||||
console.log('[rpc] moneroWalletStatus: running, connected:', connected)
|
||||
return { running: true, walletOpen: true, connected }
|
||||
},
|
||||
moneroGetAccounts: async () => {
|
||||
console.log('[rpc] moneroGetAccounts')
|
||||
if (!moneroManager) throw new Error('Monero wallet RPC not started')
|
||||
const accounts = await moneroManager.getAccounts()
|
||||
console.log(`[rpc] moneroGetAccounts: ${accounts.length} accounts`)
|
||||
return accounts
|
||||
},
|
||||
},
|
||||
messages: {},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import envPaths from 'env-paths'
|
||||
import { getENV } from '../get-env'
|
||||
|
||||
const env = getENV()
|
||||
const { data } = envPaths(env.appName, { suffix: '' })
|
||||
|
||||
export const MONERO_VERSION = '0.18.5.0'
|
||||
|
||||
const PLATFORM_MAP: Record<string, Record<string, string>> = {
|
||||
darwin: { arm64: 'mac-armv8', x64: 'mac-x64' },
|
||||
linux: { arm64: 'linux-armv8', x64: 'linux-x64' },
|
||||
}
|
||||
|
||||
function getPlatform(): string {
|
||||
const os = PLATFORM_MAP[process.platform]
|
||||
if (!os)
|
||||
throw new Error(
|
||||
`Unsupported platform: ${process.platform} ${process.arch}`,
|
||||
)
|
||||
const arch = os[process.arch]
|
||||
if (!arch)
|
||||
throw new Error(
|
||||
`Unsupported arch: ${process.arch} on ${process.platform}`,
|
||||
)
|
||||
return arch
|
||||
}
|
||||
|
||||
export function getDownloadUrl(): string {
|
||||
return `https://downloads.getmonero.org/cli/monero-${getPlatform()}-v${MONERO_VERSION}.tar.bz2`
|
||||
}
|
||||
|
||||
export function getBinaryDir(): string {
|
||||
const dir = join(data, 'monero-bin')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
export function getBinaryPath(): string {
|
||||
return join(getBinaryDir(), 'monero-wallet-rpc')
|
||||
}
|
||||
|
||||
export function getWalletDir(): string {
|
||||
const dir = join(data, 'monero-wallets')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
export function isBinaryInstalled(): boolean {
|
||||
return existsSync(getBinaryPath())
|
||||
}
|
||||
|
||||
export async function downloadBinary(): Promise<void> {
|
||||
const url = getDownloadUrl()
|
||||
const archivePath = join(
|
||||
getBinaryDir(),
|
||||
`monero-v${MONERO_VERSION}.tar.bz2`,
|
||||
)
|
||||
|
||||
console.log(`Downloading Monero CLI ${MONERO_VERSION}...`)
|
||||
console.log(`URL: ${url}`)
|
||||
const response = await fetch(url)
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Download failed: ${response.status} ${response.statusText}`,
|
||||
)
|
||||
|
||||
const total = Number(response.headers.get('content-length') ?? 0)
|
||||
console.log(`Download size: ${(total / 1024 / 1024).toFixed(1)} MB`)
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error('No response body')
|
||||
|
||||
const chunks: Uint8Array[] = []
|
||||
let received = 0
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
received += value.length
|
||||
const pct = total ? ((received / total) * 100).toFixed(0) : '?'
|
||||
console.log(
|
||||
`Download progress: ${pct}% (${(received / 1024 / 1024).toFixed(1)} MB)`,
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(received)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
buffer.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
await Bun.write(archivePath, buffer)
|
||||
console.log(`Downloaded to ${archivePath}`)
|
||||
|
||||
console.log('Extracting...')
|
||||
const result = Bun.spawnSync(['tar', '-xjf', archivePath, '--strip-components=1', '-C', getBinaryDir()])
|
||||
if (result.exitCode !== 0) throw new Error(`Extraction failed: ${result.stderr.toString()}`)
|
||||
|
||||
Bun.spawnSync(['rm', archivePath])
|
||||
|
||||
const binaryDst = getBinaryPath()
|
||||
if (!existsSync(binaryDst)) throw new Error(`monero-wallet-rpc not found after extraction`)
|
||||
|
||||
Bun.spawnSync(['chmod', '+x', binaryDst])
|
||||
|
||||
console.log(`Installed monero-wallet-rpc at ${binaryDst}`)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './binary'
|
||||
export { MoneroWalletManager } from './wallet'
|
||||
@@ -0,0 +1,225 @@
|
||||
import { MoneroWalletRpc, MoneroRpcConnection, connectToWalletRpc, connectToDaemonRpc } from 'monero-ts'
|
||||
import { getBinaryPath, getWalletDir } from './binary'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
const DEFAULT_DAEMON = 'xmr-node.cakewallet.com:18081'
|
||||
|
||||
export class MoneroWalletManager {
|
||||
private wallet: MoneroWalletRpc | null = null
|
||||
private process: Bun.Subprocess | null = null
|
||||
private rpcPort: number
|
||||
private rpcUser: string
|
||||
private rpcPassword: string
|
||||
private daemonAddress: string = DEFAULT_DAEMON
|
||||
|
||||
constructor() {
|
||||
this.rpcPort = 28084
|
||||
this.rpcUser = 'koins'
|
||||
this.rpcPassword = crypto.randomUUID()
|
||||
}
|
||||
|
||||
private get rpcUrl() {
|
||||
return `http://127.0.0.1:${this.rpcPort}`
|
||||
}
|
||||
|
||||
private get rpcConnection(): MoneroRpcConnection {
|
||||
return new MoneroRpcConnection({
|
||||
uri: this.rpcUrl,
|
||||
username: this.rpcUser,
|
||||
password: this.rpcPassword,
|
||||
})
|
||||
}
|
||||
|
||||
async start(daemonAddress: string = DEFAULT_DAEMON) {
|
||||
if (!existsSync(getBinaryPath())) throw new Error('monero-wallet-rpc not installed. Call downloadBinary() first.')
|
||||
this.daemonAddress = daemonAddress
|
||||
|
||||
console.log(`[monero] starting wallet-rpc process...`)
|
||||
console.log(`[monero] daemon address: ${this.daemonAddress}`)
|
||||
console.log(`[monero] wallet dir: ${getWalletDir()}`)
|
||||
|
||||
const cmd = [
|
||||
getBinaryPath(),
|
||||
'--daemon-address', this.daemonAddress,
|
||||
'--rpc-bind-port', String(this.rpcPort),
|
||||
'--rpc-bind-ip', '127.0.0.1',
|
||||
'--wallet-dir', getWalletDir(),
|
||||
'--rpc-login', `${this.rpcUser}:${this.rpcPassword}`,
|
||||
'--trusted-daemon',
|
||||
]
|
||||
|
||||
console.log(`[monero] spawning: ${cmd.join(' ')}`)
|
||||
this.process = Bun.spawn(cmd, {
|
||||
env: { ...process.env, LANG: 'en_US.UTF-8' },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
onExit(_proc, exitCode, _signalCode, error) {
|
||||
console.log(`[monero] wallet-rpc process exited with code ${exitCode}`, error?.message ?? '')
|
||||
},
|
||||
})
|
||||
console.log(`[monero] wallet-rpc pid: ${this.process.pid}`)
|
||||
|
||||
const stdout = this.process.stdout.getReader()
|
||||
;(async () => {
|
||||
while (true) {
|
||||
const { done, value } = await stdout.read()
|
||||
if (done) break
|
||||
console.log(`[monero:stdout] ${new TextDecoder().decode(value)}`)
|
||||
}
|
||||
})()
|
||||
|
||||
const stderr = this.process.stderr.getReader()
|
||||
;(async () => {
|
||||
while (true) {
|
||||
const { done, value } = await stderr.read()
|
||||
if (done) break
|
||||
console.log(`[monero:stderr] ${new TextDecoder().decode(value)}`)
|
||||
}
|
||||
})()
|
||||
|
||||
console.log(`[monero] waiting for RPC server to become available...`)
|
||||
await this.waitForRpc()
|
||||
console.log(`[monero] RPC server is ready`)
|
||||
|
||||
console.log(`[monero] connecting MoneroWalletRpc client...`)
|
||||
this.wallet = await connectToWalletRpc(this.rpcUrl, this.rpcUser, this.rpcPassword) as MoneroWalletRpc
|
||||
console.log(`[monero] MoneroWalletRpc connected successfully`)
|
||||
}
|
||||
|
||||
private async rawRpc(method: string, params: Record<string, any> = {}): Promise<any> {
|
||||
const conn = this.rpcConnection
|
||||
const res = await conn.sendJsonRequest(method, params)
|
||||
if (res?.error) throw new Error(res.error.message || JSON.stringify(res.error))
|
||||
return res?.result
|
||||
}
|
||||
|
||||
private async waitForRpc(timeoutMs = 30000): Promise<void> {
|
||||
const start = Date.now()
|
||||
let lastErr = ''
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
await Bun.connect({
|
||||
hostname: '127.0.0.1',
|
||||
port: this.rpcPort,
|
||||
socket: { open(s) { s.end() }, close() {}, data() {}, error() {} },
|
||||
})
|
||||
console.log(`[monero] RPC ready after ${Date.now() - start}ms`)
|
||||
return
|
||||
} catch (e) {
|
||||
lastErr = String(e)
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
throw new Error(`monero-wallet-rpc failed to start within ${timeoutMs}ms: ${lastErr}`)
|
||||
}
|
||||
|
||||
async stop() {
|
||||
console.log(`[monero] stopping wallet manager...`)
|
||||
if (this.process) {
|
||||
console.log(`[monero] killing wallet-rpc process (pid ${this.process.pid})`)
|
||||
this.process.kill()
|
||||
this.process = null
|
||||
}
|
||||
this.wallet = null
|
||||
console.log(`[monero] wallet manager stopped`)
|
||||
}
|
||||
|
||||
async createWallet(name: string, password: string) {
|
||||
console.log(`[monero] creating wallet: ${name}`)
|
||||
const result = await this.rawRpc('create_wallet', {
|
||||
filename: name,
|
||||
password,
|
||||
language: 'English',
|
||||
})
|
||||
if (!this.wallet) throw new Error('Wallet RPC not started')
|
||||
const address = await this.wallet.getAddress(0, 0)
|
||||
console.log(`[monero] wallet created: ${name} -> ${address}`)
|
||||
return { mnemonic: result.mnemonic, address }
|
||||
}
|
||||
|
||||
async restoreWallet(name: string, password: string, mnemonic: string, restoreHeight?: number) {
|
||||
console.log(`[monero] restoring wallet: ${name} (height: ${restoreHeight ?? 0})`)
|
||||
await this.rawRpc('restore_deterministic_wallet', {
|
||||
filename: name,
|
||||
password,
|
||||
seed: mnemonic,
|
||||
restore_height: restoreHeight ?? 0,
|
||||
language: 'English',
|
||||
})
|
||||
console.log(`[monero] wallet restored: ${name}`)
|
||||
}
|
||||
|
||||
async openWallet(name: string, password: string) {
|
||||
console.log(`[monero] opening wallet: ${name}`)
|
||||
if (!this.wallet) throw new Error('Wallet RPC not started')
|
||||
await this.wallet.openWallet(name, password)
|
||||
console.log(`[monero] wallet opened: ${name}`)
|
||||
}
|
||||
|
||||
async closeWallet() {
|
||||
console.log(`[monero] closing current wallet`)
|
||||
if (!this.wallet) throw new Error('Wallet RPC not started')
|
||||
await this.wallet.close()
|
||||
console.log(`[monero] wallet closed`)
|
||||
}
|
||||
|
||||
async getBalance(): Promise<{ balance: bigint; unlocked: bigint }> {
|
||||
if (!this.wallet) throw new Error('Wallet RPC not started')
|
||||
const bal = await this.wallet.getBalance() as bigint
|
||||
const unlocked = await this.wallet.getUnlockedBalance() as bigint
|
||||
console.log(`[monero] balance: ${bal} (unlocked: ${unlocked})`)
|
||||
return { balance: bal, unlocked }
|
||||
}
|
||||
|
||||
async getAddress(accountIdx = 0, subIdx = 0): Promise<string> {
|
||||
if (!this.wallet) throw new Error('Wallet RPC not started')
|
||||
const address = await this.wallet.getAddress(accountIdx, subIdx) as string
|
||||
console.log(`[monero] address: ${address}`)
|
||||
return address
|
||||
}
|
||||
|
||||
async getAccounts(): Promise<any[]> {
|
||||
if (!this.wallet) throw new Error('Wallet RPC not started')
|
||||
const accounts = await this.wallet.getAccounts(true) as any[]
|
||||
console.log(`[monero] accounts: ${accounts?.length ?? 0} returned`)
|
||||
return accounts?.map(a => a.toJson()) ?? []
|
||||
}
|
||||
|
||||
async getTransactions(): Promise<any[]> {
|
||||
if (!this.wallet) throw new Error('Wallet RPC not started')
|
||||
const txs = await this.wallet.getTxs() as any[]
|
||||
console.log(`[monero] transactions: ${txs?.length ?? 0} returned`)
|
||||
return txs ?? []
|
||||
}
|
||||
|
||||
async getHeight(): Promise<number> {
|
||||
if (!this.wallet) throw new Error('Wallet RPC not started')
|
||||
const height = await this.wallet.getHeight() as number
|
||||
console.log(`[monero] wallet height: ${height}`)
|
||||
return height
|
||||
}
|
||||
|
||||
async getDaemonHeight(): Promise<number> {
|
||||
if (!this.wallet) throw new Error('Wallet RPC not started')
|
||||
try {
|
||||
const daemon = await connectToDaemonRpc(`http://${this.daemonAddress}`)
|
||||
const height = await daemon.getHeight() as number
|
||||
console.log(`[monero] daemon height: ${height}`)
|
||||
return height
|
||||
} catch (e) {
|
||||
console.log(`[monero] failed to get daemon height:`, e)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async isConnected(): Promise<boolean> {
|
||||
if (!this.wallet) return false
|
||||
try {
|
||||
await this.wallet.getHeight()
|
||||
return true
|
||||
} catch (e) {
|
||||
console.log(`[monero] connection check failed:`, e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,57 @@
|
||||
import { Electroview } from 'electrobun/view'
|
||||
|
||||
export type MoneroBalance = {
|
||||
balance: string
|
||||
unlocked: string
|
||||
address: string
|
||||
height: number
|
||||
daemonHeight: number
|
||||
}
|
||||
|
||||
export type MoneroTxEntry = {
|
||||
hash: string
|
||||
amount: string
|
||||
timestamp: string
|
||||
direction: 'in' | 'out'
|
||||
height: number
|
||||
note?: string
|
||||
}
|
||||
|
||||
export type MoneroBinaryStatus = {
|
||||
installed: boolean
|
||||
downloading: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type MoneroWalletStatus = {
|
||||
running: boolean
|
||||
walletOpen: boolean
|
||||
walletName?: string
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export type MoneroSubaddressEntry = {
|
||||
accountIndex: number
|
||||
index: number
|
||||
address: string
|
||||
label: string
|
||||
balance: string
|
||||
unlockedBalance: string
|
||||
numUnspentOutputs: number
|
||||
isUsed: boolean
|
||||
numBlocksToUnlock: number
|
||||
}
|
||||
|
||||
export type MoneroAccountEntry = {
|
||||
index: number
|
||||
primaryAddress: string
|
||||
label: string
|
||||
balance: string
|
||||
unlockedBalance: string
|
||||
tag?: string
|
||||
subaddresses: MoneroSubaddressEntry[]
|
||||
}
|
||||
|
||||
export type TxEntry = {
|
||||
hash: string
|
||||
timeStamp: string
|
||||
@@ -82,6 +134,68 @@ type RPC = {
|
||||
}
|
||||
response: void
|
||||
}
|
||||
moneroBinaryStatus: {
|
||||
params: {}
|
||||
response: MoneroBinaryStatus
|
||||
}
|
||||
moneroDownloadBinary: {
|
||||
params: {}
|
||||
response: void
|
||||
}
|
||||
moneroStart: {
|
||||
params: {
|
||||
daemonAddress?: string
|
||||
}
|
||||
response: MoneroWalletStatus
|
||||
}
|
||||
moneroStop: {
|
||||
params: {}
|
||||
response: void
|
||||
}
|
||||
moneroCreateWallet: {
|
||||
params: {
|
||||
name: string
|
||||
password: string
|
||||
}
|
||||
response: {
|
||||
mnemonic: string
|
||||
address: string
|
||||
}
|
||||
}
|
||||
moneroRestoreWallet: {
|
||||
params: {
|
||||
name: string
|
||||
password: string
|
||||
mnemonic: string
|
||||
restoreHeight?: number
|
||||
}
|
||||
response: {
|
||||
address: string
|
||||
}
|
||||
}
|
||||
moneroOpenWallet: {
|
||||
params: {
|
||||
name: string
|
||||
password: string
|
||||
}
|
||||
response: void
|
||||
}
|
||||
moneroGetBalance: {
|
||||
params: {}
|
||||
response: MoneroBalance
|
||||
}
|
||||
moneroGetTransactions: {
|
||||
params: {}
|
||||
response: MoneroTxEntry[]
|
||||
}
|
||||
moneroWalletStatus: {
|
||||
params: {}
|
||||
response: MoneroWalletStatus
|
||||
}
|
||||
moneroGetAccounts: {
|
||||
params: {}
|
||||
response: MoneroAccountEntry[]
|
||||
}
|
||||
}
|
||||
messages: {}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { JsonRpcProvider, HDNodeWallet, formatEther } from 'ethers'
|
||||
import { electrobun, type TxEntry } from '$lib/electrobun'
|
||||
import { electrobun, type TxEntry, type MoneroTxEntry, type MoneroAccountEntry } from '$lib/electrobun'
|
||||
import { tryCatch } from '@koins/utils'
|
||||
|
||||
export const networks = [
|
||||
@@ -27,6 +27,14 @@ export const networks = [
|
||||
chainid: '1',
|
||||
explorerUrl: 'https://etherscan.io/tx/',
|
||||
},
|
||||
{
|
||||
id: 'monero',
|
||||
name: 'Monero',
|
||||
rpc: '',
|
||||
symbol: 'XMR',
|
||||
chainid: '',
|
||||
explorerUrl: 'https://moneroblocks.info/tx/',
|
||||
},
|
||||
] as const
|
||||
|
||||
export type NetworkId = (typeof networks)[number]['id']
|
||||
@@ -38,6 +46,13 @@ export type TokenBalance = {
|
||||
logo?: string
|
||||
}
|
||||
|
||||
export const atomicToXmr = (atomic: string): string => {
|
||||
const n = BigInt(atomic)
|
||||
const whole = n / 10_000_000_000_000n
|
||||
const frac = (n % 10_000_000_000_000n).toString().padStart(12, '0').replace(/0+$/, '')
|
||||
return frac ? `${whole}.${frac}` : `${whole}`
|
||||
}
|
||||
|
||||
export const Wallet = () => {
|
||||
let seed = $state('')
|
||||
let address = $state('')
|
||||
@@ -51,8 +66,24 @@ export const Wallet = () => {
|
||||
let apiKey = $state('')
|
||||
let transactions = $state<TxEntry[]>([])
|
||||
|
||||
let moneroRunning = $state(false)
|
||||
let moneroWalletOpen = $state(false)
|
||||
let moneroConnected = $state(false)
|
||||
let moneroBalAtomic = $state('0')
|
||||
let moneroUnlockedAtomic = $state('0')
|
||||
let moneroAddress = $state('')
|
||||
let moneroHeight = $state(0)
|
||||
let moneroDaemonHeight = $state(0)
|
||||
let moneroTxs = $state<MoneroTxEntry[]>([])
|
||||
let moneroInstalled = $state(false)
|
||||
let moneroDownloading = $state(false)
|
||||
let moneroWalletName = $state('')
|
||||
let moneroAccounts = $state<MoneroAccountEntry[]>([])
|
||||
|
||||
const net = () => networks.find((n) => n.id === network)!
|
||||
|
||||
const isMonero = () => network === 'monero'
|
||||
|
||||
const init = async () => {
|
||||
if (!electrobun.rpc) return
|
||||
const [raw, rawError] = await tryCatch(
|
||||
@@ -95,7 +126,11 @@ export const Wallet = () => {
|
||||
|
||||
const switchNetwork = async (id: NetworkId) => {
|
||||
network = id
|
||||
if (seed) await refresh()
|
||||
if (id === 'monero') {
|
||||
await checkMoneroStatus()
|
||||
} else if (seed) {
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
const saveVault = async (phrase: string) => {
|
||||
@@ -163,6 +198,19 @@ export const Wallet = () => {
|
||||
loading = false
|
||||
}
|
||||
|
||||
const moneroFetchAccounts = async () => {
|
||||
if (!electrobun.rpc) return false
|
||||
const [accounts, error] = await tryCatch(
|
||||
electrobun.rpc.request.moneroGetAccounts({}),
|
||||
)
|
||||
if (error) {
|
||||
console.log('moneroFetchAccounts error:', error)
|
||||
return false
|
||||
}
|
||||
moneroAccounts = accounts ?? []
|
||||
return true
|
||||
}
|
||||
|
||||
const saveApiKey = async (key: string) => {
|
||||
await electrobun.rpc?.request.setSecret({
|
||||
service: 'koins',
|
||||
@@ -185,58 +233,148 @@ export const Wallet = () => {
|
||||
transactions = txs ?? []
|
||||
}
|
||||
|
||||
const checkMoneroStatus = async () => {
|
||||
if (!electrobun.rpc) return
|
||||
const [binStatus] = await tryCatch(
|
||||
electrobun.rpc.request.moneroBinaryStatus({}),
|
||||
)
|
||||
if (binStatus) {
|
||||
moneroInstalled = binStatus.installed
|
||||
moneroDownloading = binStatus.downloading
|
||||
}
|
||||
const [status] = await tryCatch(
|
||||
electrobun.rpc.request.moneroWalletStatus({}),
|
||||
)
|
||||
if (status) {
|
||||
moneroRunning = status.running
|
||||
moneroWalletOpen = status.walletOpen
|
||||
moneroConnected = status.connected
|
||||
}
|
||||
}
|
||||
|
||||
const moneroDownload = async () => {
|
||||
if (!electrobun.rpc) return
|
||||
moneroDownloading = true
|
||||
try {
|
||||
await electrobun.rpc.request.moneroDownloadBinary({})
|
||||
moneroInstalled = true
|
||||
} finally {
|
||||
moneroDownloading = false
|
||||
}
|
||||
}
|
||||
|
||||
const moneroStart = async () => {
|
||||
if (!electrobun.rpc) return
|
||||
const status = await electrobun.rpc.request.moneroStart({})
|
||||
moneroRunning = status.running
|
||||
moneroConnected = status.connected
|
||||
}
|
||||
|
||||
const moneroStop = async () => {
|
||||
if (!electrobun.rpc) return
|
||||
await electrobun.rpc.request.moneroStop({})
|
||||
moneroRunning = false
|
||||
moneroWalletOpen = false
|
||||
moneroConnected = false
|
||||
moneroAddress = ''
|
||||
moneroBalAtomic = '0'
|
||||
moneroUnlockedAtomic = '0'
|
||||
moneroTxs = []
|
||||
moneroAccounts = []
|
||||
}
|
||||
|
||||
const moneroCreateWallet = async (name: string, password: string) => {
|
||||
if (!electrobun.rpc) throw new Error('RPC not available')
|
||||
const result = await electrobun.rpc.request.moneroCreateWallet({ name, password })
|
||||
moneroWalletName = name
|
||||
moneroWalletOpen = true
|
||||
moneroAddress = result.address
|
||||
await moneroRefresh()
|
||||
return result
|
||||
}
|
||||
|
||||
const moneroRestoreWallet = async (name: string, password: string, mnemonic: string, restoreHeight?: number) => {
|
||||
if (!electrobun.rpc) throw new Error('RPC not available')
|
||||
const result = await electrobun.rpc.request.moneroRestoreWallet({
|
||||
name, password, mnemonic, restoreHeight,
|
||||
})
|
||||
moneroWalletName = name
|
||||
moneroWalletOpen = true
|
||||
moneroAddress = result.address
|
||||
await moneroRefresh()
|
||||
}
|
||||
|
||||
const moneroOpenWallet = async (name: string, password: string) => {
|
||||
if (!electrobun.rpc) return
|
||||
await electrobun.rpc.request.moneroOpenWallet({ name, password })
|
||||
moneroWalletName = name
|
||||
moneroWalletOpen = true
|
||||
await moneroRefresh()
|
||||
}
|
||||
|
||||
const moneroRefresh = async () => {
|
||||
if (!electrobun.rpc) return
|
||||
loading = true
|
||||
try {
|
||||
const [bal] = await tryCatch(
|
||||
electrobun.rpc.request.moneroGetBalance({}),
|
||||
)
|
||||
if (bal) {
|
||||
moneroBalAtomic = bal.balance
|
||||
moneroUnlockedAtomic = bal.unlocked
|
||||
moneroAddress = bal.address
|
||||
moneroHeight = bal.height
|
||||
moneroDaemonHeight = bal.daemonHeight
|
||||
}
|
||||
const [txs] = await tryCatch(
|
||||
electrobun.rpc.request.moneroGetTransactions({}),
|
||||
)
|
||||
moneroTxs = txs ?? []
|
||||
await moneroFetchAccounts()
|
||||
} catch (e) {
|
||||
console.log('monero refresh error', e)
|
||||
error = e instanceof Error ? e.message : 'Monero refresh failed'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get isLocked() {
|
||||
return vaultExists && !seed
|
||||
},
|
||||
get seed() {
|
||||
return seed
|
||||
},
|
||||
get address() {
|
||||
return address
|
||||
},
|
||||
get balance() {
|
||||
return balance
|
||||
},
|
||||
get tokenBalances() {
|
||||
return tokenBalances
|
||||
},
|
||||
get network() {
|
||||
return network
|
||||
},
|
||||
get chainid() {
|
||||
return net().chainid
|
||||
},
|
||||
get networkName() {
|
||||
return net().name
|
||||
},
|
||||
get symbol() {
|
||||
return net().symbol
|
||||
},
|
||||
get explorerUrl() {
|
||||
return net().explorerUrl
|
||||
},
|
||||
get loading() {
|
||||
return loading
|
||||
},
|
||||
get error() {
|
||||
return error
|
||||
},
|
||||
get vaultExists() {
|
||||
return vaultExists
|
||||
},
|
||||
get ready() {
|
||||
return ready
|
||||
},
|
||||
get networks() {
|
||||
return networks
|
||||
},
|
||||
get apiKey() {
|
||||
return apiKey
|
||||
},
|
||||
get transactions() {
|
||||
return transactions
|
||||
return vaultExists && !seed && network !== 'monero'
|
||||
},
|
||||
get seed() { return seed },
|
||||
get address() { return address },
|
||||
get balance() { return balance },
|
||||
get tokenBalances() { return tokenBalances },
|
||||
get network() { return network },
|
||||
get chainid() { return net().chainid },
|
||||
get networkName() { return net().name },
|
||||
get symbol() { return net().symbol },
|
||||
get explorerUrl() { return net().explorerUrl },
|
||||
get loading() { return loading },
|
||||
get error() { return error },
|
||||
get vaultExists() { return vaultExists },
|
||||
get ready() { return ready },
|
||||
get networks() { return networks },
|
||||
get apiKey() { return apiKey },
|
||||
get transactions() { return transactions },
|
||||
isMonero,
|
||||
get moneroRunning() { return moneroRunning },
|
||||
get moneroWalletOpen() { return moneroWalletOpen },
|
||||
get moneroConnected() { return moneroConnected },
|
||||
get moneroBalAtomic() { return moneroBalAtomic },
|
||||
get moneroUnlockedAtomic() { return moneroUnlockedAtomic },
|
||||
get moneroBalance() { return atomicToXmr(moneroBalAtomic) },
|
||||
get moneroUnlocked() { return atomicToXmr(moneroUnlockedAtomic) },
|
||||
get moneroAddress() { return moneroAddress },
|
||||
get moneroHeight() { return moneroHeight },
|
||||
get moneroDaemonHeight() { return moneroDaemonHeight },
|
||||
get moneroTxs() { return moneroTxs },
|
||||
get moneroInstalled() { return moneroInstalled },
|
||||
get moneroDownloading() { return moneroDownloading },
|
||||
get moneroWalletName() { return moneroWalletName },
|
||||
get moneroAccounts() { return moneroAccounts },
|
||||
refresh,
|
||||
init,
|
||||
lock,
|
||||
@@ -244,5 +382,14 @@ export const Wallet = () => {
|
||||
switchNetwork,
|
||||
saveApiKey,
|
||||
fetchTxHistory,
|
||||
checkMoneroStatus,
|
||||
moneroDownload,
|
||||
moneroStart,
|
||||
moneroStop,
|
||||
moneroCreateWallet,
|
||||
moneroRestoreWallet,
|
||||
moneroOpenWallet,
|
||||
moneroRefresh,
|
||||
moneroFetchAccounts,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { electrobun, type TxEntry } from '$lib/electrobun.js'
|
||||
import { Wallet } from '$lib/states/wallet.svelte.js'
|
||||
import { electrobun, type TxEntry, type MoneroTxEntry } from '$lib/electrobun.js'
|
||||
import { Wallet, atomicToXmr } from '$lib/states/wallet.svelte.js'
|
||||
import { Button } from '$lib/components/ui/button/index.js'
|
||||
import { navigate } from 'sv-router/generated'
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js'
|
||||
import { Input } from '$lib/components/ui/input/index.js'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -22,11 +23,12 @@
|
||||
}
|
||||
|
||||
function nativeIcon(id: string) {
|
||||
if (id === 'monero') return ''
|
||||
return `${CDN}/${chainDir[id]}/info/logo.png`
|
||||
}
|
||||
|
||||
function tokenIcon(id: string, address?: string) {
|
||||
if (!address) return ''
|
||||
if (!address || id === 'monero') return ''
|
||||
return `${CDN}/${chainDir[id]}/assets/${address}/logo.png`
|
||||
}
|
||||
|
||||
@@ -44,6 +46,11 @@
|
||||
let inputSeed = $state('')
|
||||
let apiKeyInput = $state('')
|
||||
|
||||
let moneroWalletName = $state('')
|
||||
let moneroWalletPass = $state('')
|
||||
let moneroMnemonic = $state('')
|
||||
let moneroRestoreHeight = $state<number | undefined>(undefined)
|
||||
|
||||
$effect(() => {
|
||||
w.init()
|
||||
})
|
||||
@@ -51,14 +58,12 @@
|
||||
|
||||
<div class="mx-auto mt-16 max-w-md">
|
||||
{#if !w.ready}
|
||||
<!-- loading -->
|
||||
{:else if !w.vaultExists}
|
||||
{:else if !w.vaultExists && !w.isMonero()}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Import Wallet</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your seed phrase to save it securely in your system
|
||||
keychain
|
||||
Enter your seed phrase to save it securely in your system keychain
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -77,7 +82,7 @@
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if w.isLocked}
|
||||
{:else if w.isLocked && !w.isMonero()}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p class="text-muted-foreground text-xs">Unlocking...</p>
|
||||
@@ -85,195 +90,394 @@
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Wallet</CardTitle>
|
||||
<div class="flex gap-1 justify-center" role="group">
|
||||
{#each w.networks as net}
|
||||
<button
|
||||
class="cursor-pointer rounded-md px-2.5 py-1 text-xs font-medium transition-colors {w.network ===
|
||||
net.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
|
||||
onclick={() => w.switchNetwork(net.id)}>
|
||||
{net.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if w.isMonero()}
|
||||
<!-- MONERO DASHBOARD -->
|
||||
{#if w.moneroDownloading}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p class="text-muted-foreground text-xs">Downloading Monero binary (70MB)...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if !w.moneroInstalled}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Monero Setup</CardTitle>
|
||||
<CardDescription>Download monero-wallet-rpc to get started</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onclick={() => w.moneroDownload()}>Download (~70MB)</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if !w.moneroRunning}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Monero Wallet</CardTitle>
|
||||
<CardDescription>Start the wallet RPC server to connect to the Monero network</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onclick={() => w.moneroStart()} disabled={w.loading}>
|
||||
{w.loading ? 'Starting...' : 'Start'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if !w.moneroWalletOpen}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Monero Wallet</CardTitle>
|
||||
<CardDescription>Create a new wallet or restore an existing one</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-3">
|
||||
<Input placeholder="Wallet name" bind:value={moneroWalletName} />
|
||||
<Input type="password" placeholder="Password" bind:value={moneroWalletPass} />
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
onclick={async () => {
|
||||
const result = await w.moneroCreateWallet(moneroWalletName, moneroWalletPass)
|
||||
moneroMnemonic = result.mnemonic
|
||||
}}
|
||||
disabled={w.loading || !moneroWalletName || !moneroWalletPass}>
|
||||
Create Wallet
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => w.moneroOpenWallet(moneroWalletName, moneroWalletPass)}
|
||||
disabled={w.loading || !moneroWalletName || !moneroWalletPass}>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
<hr class="border-muted" />
|
||||
<p class="text-xs text-muted-foreground">Restore from seed</p>
|
||||
<Textarea placeholder="Enter your Monero seed phrase (16 or 25 words)" bind:value={moneroMnemonic} />
|
||||
<Input type="number" placeholder="Restore height (optional)" bind:value={moneroRestoreHeight} />
|
||||
<Button
|
||||
onclick={() => w.moneroRestoreWallet(moneroWalletName, moneroWalletPass, moneroMnemonic, moneroRestoreHeight)}
|
||||
disabled={w.loading || !moneroWalletName || !moneroWalletPass || !moneroMnemonic.trim()}>
|
||||
Restore Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<!-- Monero wallet unlocked -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle>Monero Wallet</CardTitle>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="size-2 rounded-full {w.moneroConnected ? 'bg-green-500' : 'bg-red-500'}"></div>
|
||||
<span class="text-xs text-muted-foreground">{w.moneroConnected ? 'Connected' : 'Disconnected'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Your wallet is unlocked
|
||||
{#if w.moneroHeight < w.moneroDaemonHeight}
|
||||
Scanning: {w.moneroHeight.toLocaleString()} / {w.moneroDaemonHeight.toLocaleString()}
|
||||
{:else}
|
||||
Height: {w.moneroHeight.toLocaleString()}
|
||||
{/if}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div class="flex gap-1" role="group">
|
||||
{#each w.networks as net}
|
||||
<button
|
||||
class="cursor-pointer rounded-md px-2.5 py-1 text-xs font-medium transition-colors {w.network ===
|
||||
net.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
|
||||
onclick={() => w.switchNetwork(net.id)}>
|
||||
{net.name}
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mb-4 space-y-1">
|
||||
<p class="font-medium text-xs">Address</p>
|
||||
<p class="font-mono text-xs break-all">{w.moneroAddress}</p>
|
||||
<p class="mt-2 font-medium text-xs">Balance</p>
|
||||
<p class="font-mono text-lg">{w.moneroBalance} XMR</p>
|
||||
{#if w.moneroUnlockedAtomic !== w.moneroBalAtomic}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Unlocked: {w.moneroUnlocked} XMR
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if moneroMnemonic}
|
||||
<div class="mb-4 rounded-md bg-muted p-3">
|
||||
<p class="text-xs font-medium mb-1">Seed phrase (save this!)</p>
|
||||
<p class="font-mono text-xs break-all">{moneroMnemonic}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button onclick={() => { w.moneroRefresh(); w.moneroFetchAccounts(); }} disabled={w.loading}>
|
||||
{w.loading ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={() => w.moneroStop()}>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Monero transactions -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if w.moneroTxs.length === 0}
|
||||
<p class="text-muted-foreground text-xs">No transactions found</p>
|
||||
{:else}
|
||||
<div class="max-h-96 space-y-1 overflow-y-auto">
|
||||
{#each w.moneroTxs as tx}
|
||||
<div class="flex items-center justify-between rounded-md bg-muted px-3 py-2 text-xs">
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<p class="font-medium">
|
||||
{tx.direction === 'in' ? 'Received' : 'Sent'}
|
||||
</p>
|
||||
<p class="font-mono text-muted-foreground">
|
||||
{Number(tx.amount) > 0
|
||||
? `${tx.direction === 'in' ? '+' : '-'}${(BigInt(tx.amount) / 10_000_000_000_000n).toString()} XMR`
|
||||
: '0 XMR'}
|
||||
</p>
|
||||
{#if tx.height > 0}
|
||||
<p class="text-muted-foreground">Block {tx.height}</p>
|
||||
{/if}
|
||||
<p class="text-muted-foreground/50 truncate font-mono">
|
||||
{tx.hash.slice(0, 12)}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Monero accounts & subaddresses -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Accounts & Addresses</CardTitle>
|
||||
<CardDescription>
|
||||
{w.moneroAccounts.length} account{w.moneroAccounts.length !== 1 ? 's' : ''}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#each w.moneroAccounts as account}
|
||||
<div class="mb-3 rounded-md bg-muted p-3">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<p class="text-xs font-medium">Account {account.index}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{atomicToXmr(account.balance)} XMR
|
||||
</p>
|
||||
</div>
|
||||
<p class="mb-2 font-mono text-xs text-muted-foreground break-all">
|
||||
{account.primaryAddress}
|
||||
</p>
|
||||
{#if account.subaddresses.length > 1}
|
||||
<div class="space-y-1 border-t border-border pt-2">
|
||||
<p class="text-xs text-muted-foreground mb-1">Subaddresses</p>
|
||||
{#each account.subaddresses as sub}
|
||||
{#if sub.index > 0}
|
||||
<div class="rounded-sm bg-background px-2 py-1.5 text-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium">#{sub.index}{sub.label ? ` - ${sub.label}` : ''}</p>
|
||||
<p class="text-muted-foreground">{atomicToXmr(sub.balance)} XMR</p>
|
||||
</div>
|
||||
<p class="font-mono text-muted-foreground/50 truncate">{sub.address}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<!-- EVM DASHBOARD -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Wallet</CardTitle>
|
||||
<CardDescription>
|
||||
Your wallet is unlocked
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if w.address}
|
||||
<div class="mb-4 space-y-1">
|
||||
<p class="font-medium text-xs">Address</p>
|
||||
<p class="font-mono text-xs break-all">{w.address}</p>
|
||||
<p class="mt-2 font-medium text-xs">
|
||||
{w.symbol} Balance
|
||||
</p>
|
||||
<p class="flex items-center gap-1.5 font-mono text-lg">
|
||||
<img
|
||||
src={nativeIcon(w.network)}
|
||||
alt=""
|
||||
class="size-4 rounded-full"
|
||||
onerror={(e) =>
|
||||
((e.target as HTMLElement).style.display =
|
||||
'none')} />
|
||||
{w.balance}
|
||||
{w.symbol}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-4 space-y-1">
|
||||
<p class="font-medium text-xs">Tokens</p>
|
||||
{#each w.tokenBalances as t}
|
||||
<p
|
||||
class="flex items-center gap-1.5 font-mono text-sm">
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if w.address}
|
||||
<div class="mb-4 space-y-1">
|
||||
<p class="font-medium text-xs">Address</p>
|
||||
<p class="font-mono text-xs break-all">{w.address}</p>
|
||||
<p class="mt-2 font-medium text-xs">
|
||||
{w.symbol} Balance
|
||||
</p>
|
||||
<p class="flex items-center gap-1.5 font-mono text-lg">
|
||||
<img
|
||||
src={t.logo}
|
||||
src={nativeIcon(w.network)}
|
||||
alt=""
|
||||
class="size-4 rounded-full"
|
||||
onerror={(e) =>
|
||||
((e.target as HTMLElement).style.display =
|
||||
'none')} />
|
||||
{Number(t.balance) < 0.0001 && Number(t.balance) > 0
|
||||
? '<0.0001'
|
||||
: Number(t.balance).toFixed(4)}
|
||||
{t.symbol}
|
||||
{w.balance}
|
||||
{w.symbol}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mb-4 space-y-1">
|
||||
<p class="font-medium text-xs">Tokens</p>
|
||||
{#each w.tokenBalances as t}
|
||||
<p
|
||||
class="flex items-center gap-1.5 font-mono text-sm">
|
||||
<img
|
||||
src={t.logo}
|
||||
alt=""
|
||||
class="size-4 rounded-full"
|
||||
onerror={(e) =>
|
||||
((e.target as HTMLElement).style.display =
|
||||
'none')} />
|
||||
{Number(t.balance) < 0.0001 && Number(t.balance) > 0
|
||||
? '<0.0001'
|
||||
: Number(t.balance).toFixed(4)}
|
||||
{t.symbol}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if w.error}
|
||||
<p class="mb-3 text-red-500">{w.error}</p>
|
||||
{/if}
|
||||
{#if w.error}
|
||||
<p class="mb-3 text-red-500">{w.error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button onclick={() => w.refresh()} disabled={w.loading}>
|
||||
{w.loading ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={() => w.lock()}>
|
||||
Lock
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if !w.apiKey}
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
class="flex h-8 w-full rounded-md border border-input bg-transparent px-2 py-1 font-mono text-xs"
|
||||
placeholder="Alchemy API key"
|
||||
bind:value={apiKeyInput} />
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => w.saveApiKey(apiKeyInput)}
|
||||
disabled={!apiKeyInput.trim()}>
|
||||
Save
|
||||
<div class="flex gap-2">
|
||||
<Button onclick={() => w.refresh()} disabled={w.loading}>
|
||||
{w.loading ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={() => w.lock()}>
|
||||
Lock
|
||||
</Button>
|
||||
</div>
|
||||
{:else if w.transactions.length === 0}
|
||||
<p class="text-muted-foreground text-xs">
|
||||
No transactions found
|
||||
</p>
|
||||
{:else}
|
||||
<div class="max-h-96 space-y-1 overflow-y-auto">
|
||||
{#each w.transactions as tx}
|
||||
<button
|
||||
onclick={() =>
|
||||
navigate('/tx/:chainid/:hash', {
|
||||
params: {
|
||||
chainid: w.chainid,
|
||||
hash: tx.hash,
|
||||
},
|
||||
})}
|
||||
class="flex w-full cursor-pointer items-center justify-between rounded-md bg-muted px-3 py-2 text-xs text-left hover:bg-muted/80 transition-colors">
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<p class="font-medium">
|
||||
{txAction(tx, w.address)}
|
||||
</p>
|
||||
<p
|
||||
class="flex items-center gap-1 text-muted-foreground font-mono">
|
||||
{#if tx.pairedValue}
|
||||
<img
|
||||
src={nativeIcon(w.network)}
|
||||
alt=""
|
||||
class="size-3.5 rounded-full"
|
||||
onerror={(e) =>
|
||||
((e.target as HTMLElement).style.display =
|
||||
'none')} />
|
||||
{Number(tx.value).toFixed(4)}
|
||||
{w.symbol}
|
||||
<span class="mx-0.5">→</span>
|
||||
<img
|
||||
src={tx.pairedLogo || tokenIcon(
|
||||
w.network,
|
||||
tx.contractAddress,
|
||||
)}
|
||||
alt=""
|
||||
class="size-3.5 rounded-full"
|
||||
onerror={(e) =>
|
||||
((e.target as HTMLElement).style.display =
|
||||
'none')} />
|
||||
{Number(tx.pairedValue).toFixed(4)}
|
||||
{tx.pairedSymbol}
|
||||
{:else if tx.tokenSymbol}
|
||||
<img
|
||||
src={tx.logo || tokenIcon(
|
||||
w.network,
|
||||
tx.contractAddress,
|
||||
)}
|
||||
alt=""
|
||||
class="size-3.5 rounded-full"
|
||||
onerror={(e) =>
|
||||
((e.target as HTMLElement).style.display =
|
||||
'none')} />
|
||||
{Number(tx.value).toFixed(4)}
|
||||
{tx.tokenSymbol}
|
||||
{:else}
|
||||
<img
|
||||
src={nativeIcon(w.network)}
|
||||
alt=""
|
||||
class="size-3.5 rounded-full"
|
||||
onerror={(e) =>
|
||||
((e.target as HTMLElement).style.display =
|
||||
'none')} />
|
||||
{Number(tx.value).toFixed(4)}
|
||||
{w.symbol}
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
{new Date(
|
||||
Number(tx.timeStamp) * 1000,
|
||||
).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if !w.apiKey}
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
class="flex h-8 w-full rounded-md border border-input bg-transparent px-2 py-1 font-mono text-xs"
|
||||
placeholder="Alchemy API key"
|
||||
bind:value={apiKeyInput} />
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => w.saveApiKey(apiKeyInput)}
|
||||
disabled={!apiKeyInput.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{:else if w.transactions.length === 0}
|
||||
<p class="text-muted-foreground text-xs">
|
||||
No transactions found
|
||||
</p>
|
||||
{:else}
|
||||
<div class="max-h-96 space-y-1 overflow-y-auto">
|
||||
{#each w.transactions as tx}
|
||||
<button
|
||||
onclick={() =>
|
||||
navigate('/tx/:chainid/:hash', {
|
||||
params: {
|
||||
chainid: w.chainid,
|
||||
hash: tx.hash,
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
<p
|
||||
class="text-muted-foreground/50 truncate font-mono">
|
||||
{tx.hash.slice(0, 10)}...
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
class="flex w-full cursor-pointer items-center justify-between rounded-md bg-muted px-3 py-2 text-xs text-left hover:bg-muted/80 transition-colors">
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<p class="font-medium">
|
||||
{txAction(tx, w.address)}
|
||||
</p>
|
||||
<p
|
||||
class="flex items-center gap-1 text-muted-foreground font-mono">
|
||||
{#if tx.pairedValue}
|
||||
<img
|
||||
src={nativeIcon(w.network)}
|
||||
alt=""
|
||||
class="size-3.5 rounded-full"
|
||||
onerror={(e) =>
|
||||
((e.target as HTMLElement).style.display =
|
||||
'none')} />
|
||||
{Number(tx.value).toFixed(4)}
|
||||
{w.symbol}
|
||||
<span class="mx-0.5">→</span>
|
||||
<img
|
||||
src={tx.pairedLogo || tokenIcon(
|
||||
w.network,
|
||||
tx.contractAddress,
|
||||
)}
|
||||
alt=""
|
||||
class="size-3.5 rounded-full"
|
||||
onerror={(e) =>
|
||||
((e.target as HTMLElement).style.display =
|
||||
'none')} />
|
||||
{Number(tx.pairedValue).toFixed(4)}
|
||||
{tx.pairedSymbol}
|
||||
{:else if tx.tokenSymbol}
|
||||
<img
|
||||
src={tx.logo || tokenIcon(
|
||||
w.network,
|
||||
tx.contractAddress,
|
||||
)}
|
||||
alt=""
|
||||
class="size-3.5 rounded-full"
|
||||
onerror={(e) =>
|
||||
((e.target as HTMLElement).style.display =
|
||||
'none')} />
|
||||
{Number(tx.value).toFixed(4)}
|
||||
{tx.tokenSymbol}
|
||||
{:else}
|
||||
<img
|
||||
src={nativeIcon(w.network)}
|
||||
alt=""
|
||||
class="size-3.5 rounded-full"
|
||||
onerror={(e) =>
|
||||
((e.target as HTMLElement).style.display =
|
||||
'none')} />
|
||||
{Number(tx.value).toFixed(4)}
|
||||
{w.symbol}
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
{new Date(
|
||||
Number(tx.timeStamp) * 1000,
|
||||
).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
<p
|
||||
class="text-muted-foreground/50 truncate font-mono">
|
||||
{tx.hash.slice(0, 10)}...
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user