remove evm + add xrp
This commit is contained in:
@@ -6,36 +6,21 @@ import {
|
||||
Updater,
|
||||
Utils,
|
||||
} from 'electrobun/bun'
|
||||
import { migrate } from 'drizzle-orm/bun-sqlite/migrator'
|
||||
import { db } from './lib/db'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { getENV } from './lib/get-env'
|
||||
import { createEvmHandlers } from './rpc/evm'
|
||||
import { createMoneroHandlers } from './rpc/monero'
|
||||
import { createXrpHandlers } from './rpc/xrp'
|
||||
import type { MoneroWalletState } from './lib/monero'
|
||||
import { canPromptTouchID, promptTouchID } from './lib/biometric'
|
||||
import { sqlite } from './lib/sqlite'
|
||||
import { evmWallets } from './lib/db/schema'
|
||||
import { getXrpWalletList } from './lib/xrp'
|
||||
import type { RPC } from '../lib/rpc-schema'
|
||||
import { log, logError } from './lib/logger'
|
||||
import { log } from './lib/logger'
|
||||
|
||||
const env = getENV()
|
||||
|
||||
log(`env: ${JSON.stringify(env)}`)
|
||||
|
||||
if (env.env === 'stable') {
|
||||
try {
|
||||
log('running migrations...')
|
||||
migrate(db, {
|
||||
migrationsFolder: join(PATHS.RESOURCES_FOLDER, 'app', 'drizzle'),
|
||||
})
|
||||
log('migrations complete')
|
||||
} catch (e) {
|
||||
logError('migration failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
const DEV_SERVER_PORT = 5173
|
||||
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
|
||||
|
||||
@@ -92,10 +77,6 @@ const moneroState = {
|
||||
manager: null as MoneroWalletState | null,
|
||||
downloading: false,
|
||||
}
|
||||
const syncState = {
|
||||
target: null as { address: string; chainid: string } | null,
|
||||
timer: null as ReturnType<typeof setInterval> | null,
|
||||
}
|
||||
|
||||
const rpc = BrowserView.defineRPC<RPC>({
|
||||
maxRequestTime: 120000,
|
||||
@@ -108,11 +89,11 @@ const rpc = BrowserView.defineRPC<RPC>({
|
||||
rpc.setRequestHandler({
|
||||
resetApp: async () => {
|
||||
try {
|
||||
const wallets = db.select().from(evmWallets).all()
|
||||
const wallets = await getXrpWalletList()
|
||||
await Promise.all([
|
||||
Bun.secrets.delete({
|
||||
service: 'koins',
|
||||
name: 'alchemy_key',
|
||||
name: 'xrp_wallets',
|
||||
}),
|
||||
...wallets.flatMap((w) => [
|
||||
Bun.secrets.delete({
|
||||
@@ -121,15 +102,10 @@ rpc.setRequestHandler({
|
||||
}),
|
||||
Bun.secrets.delete({
|
||||
service: 'koins',
|
||||
name: `evm_auth_${w.id}`,
|
||||
name: `xrp_auth_${w.id}`,
|
||||
}),
|
||||
]),
|
||||
])
|
||||
sqlite.run('DELETE FROM token_metadata')
|
||||
sqlite.run('DELETE FROM transactions')
|
||||
sqlite.run('DELETE FROM transaction_receipts')
|
||||
sqlite.run('DELETE FROM tx_history')
|
||||
sqlite.run('DELETE FROM evm_wallets')
|
||||
console.log('[rpc] resetApp complete')
|
||||
return true
|
||||
} catch (e) {
|
||||
@@ -174,8 +150,8 @@ rpc.setRequestHandler({
|
||||
logToFile: async ({ message }) => {
|
||||
log(message)
|
||||
},
|
||||
...createEvmHandlers(rpc, syncState),
|
||||
...createMoneroHandlers(moneroState),
|
||||
...createXrpHandlers(),
|
||||
})
|
||||
|
||||
log('creating BrowserWindow...')
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { tryCatch } from '@koins/utils'
|
||||
|
||||
const TIMEOUT = 15_000
|
||||
|
||||
export const post = async <T>(
|
||||
url: string,
|
||||
body: any,
|
||||
): Promise<{ jsonrpc: '2.0'; id: number; result: T }> => {
|
||||
const [response, error] = await tryCatch(
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
}),
|
||||
)
|
||||
|
||||
if (error) {
|
||||
console.log(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
const [data, dataError] = await tryCatch(response.json())
|
||||
|
||||
if (dataError) {
|
||||
console.log(dataError)
|
||||
throw dataError
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { post } from './call'
|
||||
import { getChainID, getURL } from './network'
|
||||
import { inspect } from 'util'
|
||||
|
||||
export const getTokensBalances = async (
|
||||
key: string,
|
||||
chainid: string,
|
||||
address: string,
|
||||
) => {
|
||||
const chainId = getChainID(chainid)
|
||||
if (!chainId) return
|
||||
|
||||
const url = getURL({
|
||||
chainId,
|
||||
key,
|
||||
})
|
||||
|
||||
const balRes = await post<{
|
||||
address: `0x${string}`
|
||||
tokenBalances: {
|
||||
contractAddress: `0x${string}`
|
||||
tokenBalance: string
|
||||
}[]
|
||||
}>(url, {
|
||||
jsonrpc: '2.0',
|
||||
id: 0,
|
||||
method: 'alchemy_getTokenBalances',
|
||||
params: [address, 'erc20'],
|
||||
})
|
||||
|
||||
return balRes
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { post } from './call'
|
||||
import { getChainID, getURL } from './network'
|
||||
|
||||
export const getTokenMetadata = async (
|
||||
key: string,
|
||||
chainid: string,
|
||||
tokenAddress: string,
|
||||
) => {
|
||||
const chainId = getChainID(chainid)
|
||||
if (!chainId) return
|
||||
|
||||
const url = getURL({ chainId, key })
|
||||
|
||||
return await post<{
|
||||
name: string | null
|
||||
symbol: string | null
|
||||
decimals: number | null
|
||||
logo: string | null
|
||||
}>(url, {
|
||||
jsonrpc: '2.0',
|
||||
id: 0,
|
||||
method: 'alchemy_getTokenMetadata',
|
||||
params: [tokenAddress],
|
||||
})
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { post } from './call'
|
||||
import { getChainID, getURL } from './network'
|
||||
import type { Transaction } from './types'
|
||||
import { numberToHex } from 'viem'
|
||||
|
||||
type PageResult = {
|
||||
transfers: Transaction[]
|
||||
pageKey?: string
|
||||
}
|
||||
|
||||
async function fetchPage(
|
||||
url: string,
|
||||
fromAddr: string | undefined,
|
||||
toAddr: string | undefined,
|
||||
fromBlock: string | undefined,
|
||||
chainId: string,
|
||||
): Promise<{ result?: PageResult }> {
|
||||
const category = ['external', 'erc20']
|
||||
if (chainId !== '56') {
|
||||
category.push('internal')
|
||||
}
|
||||
|
||||
const body = {
|
||||
jsonrpc: '2.0',
|
||||
id: 0,
|
||||
method: 'alchemy_getAssetTransfers',
|
||||
params: [
|
||||
{
|
||||
...(fromAddr ? { fromAddress: fromAddr } : {}),
|
||||
...(toAddr ? { toAddress: toAddr } : {}),
|
||||
...(fromBlock ? { fromBlock } : {}),
|
||||
category,
|
||||
withMetadata: true,
|
||||
maxCount: numberToHex(1000),
|
||||
order: 'asc' as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return post<PageResult>(url, body)
|
||||
}
|
||||
|
||||
export const getTransactionHistory = async (
|
||||
key: string,
|
||||
chainid: string,
|
||||
address: string,
|
||||
opts: { maxCount: number; fromBlock?: string },
|
||||
) => {
|
||||
const chainId = getChainID(chainid)
|
||||
if (!chainId) return
|
||||
|
||||
const url = getURL({ chainId, key })
|
||||
const seen = new Set<string>()
|
||||
const all: Transaction[] = []
|
||||
|
||||
for (const direction of ['out', 'in'] as const) {
|
||||
const fromAddr = direction === 'out' ? address : undefined
|
||||
const toAddr = direction === 'in' ? address : undefined
|
||||
let cursorBlock = opts.fromBlock
|
||||
|
||||
for (let page = 0; page < 100; page++) {
|
||||
const res = await fetchPage(url, fromAddr, toAddr, cursorBlock, chainId)
|
||||
const transfers = res.result?.transfers ?? []
|
||||
if (transfers.length === 0) break
|
||||
|
||||
let pageMaxBlock = 0
|
||||
let firstTimestamp = ''
|
||||
let lastTimestamp = ''
|
||||
for (const t of transfers) {
|
||||
if (!t.hash) continue
|
||||
const dedupKey = `${t.hash}_${t.category}_${t.rawContract?.address ?? ''}`
|
||||
if (seen.has(dedupKey)) continue
|
||||
seen.add(dedupKey)
|
||||
all.push(t)
|
||||
|
||||
if (t.blockNum) {
|
||||
const blockNum = parseInt(t.blockNum, 16)
|
||||
if (blockNum > pageMaxBlock) {
|
||||
pageMaxBlock = blockNum
|
||||
}
|
||||
}
|
||||
if (t.metadata?.blockTimestamp) {
|
||||
if (!firstTimestamp) firstTimestamp = t.metadata.blockTimestamp
|
||||
lastTimestamp = t.metadata.blockTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[alchemy] ${direction} page ${page + 1}: ${transfers.length} transfers, ` +
|
||||
`block ${pageMaxBlock}, ` +
|
||||
`${firstTimestamp ? new Date(firstTimestamp).toISOString() : '?'} → ${lastTimestamp ? new Date(lastTimestamp).toISOString() : '?'}`,
|
||||
)
|
||||
|
||||
if (pageMaxBlock === 0) break
|
||||
cursorBlock = numberToHex(pageMaxBlock + 1)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[alchemy] total: ${all.length} transfers for ${chainid}/${address.slice(0, 10)}... ` +
|
||||
`from ${opts.fromBlock ?? 'genesis'}`,
|
||||
)
|
||||
return all
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './get-token-balances'
|
||||
export * from './get-token-metadata'
|
||||
export * from './types'
|
||||
@@ -1,31 +0,0 @@
|
||||
export const ALCHEMY_NETWORKS = {
|
||||
'1': 'eth-mainnet',
|
||||
'137': 'polygon-mainnet',
|
||||
'56': 'bnb-mainnet',
|
||||
} as const
|
||||
|
||||
export type ChainID = keyof typeof ALCHEMY_NETWORKS
|
||||
|
||||
export const CHAIN_ID = Object.keys(
|
||||
ALCHEMY_NETWORKS,
|
||||
) as Array<ChainID>
|
||||
|
||||
export const getChainID = (id: string) => {
|
||||
if (CHAIN_ID.includes(id as ChainID)) {
|
||||
return id as ChainID
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const getURL = ({
|
||||
key,
|
||||
chainId,
|
||||
}: {
|
||||
chainId: ChainID
|
||||
key: string
|
||||
}) => {
|
||||
const network = ALCHEMY_NETWORKS[chainId]
|
||||
const url = `https://${network}.g.alchemy.com/v2/${key}`
|
||||
|
||||
return url
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
export type Token = {}
|
||||
|
||||
export type TokenBalance = {}
|
||||
|
||||
export type Transaction = {
|
||||
category?:
|
||||
| 'external'
|
||||
| 'internal'
|
||||
| 'token'
|
||||
| 'erc20'
|
||||
| 'erc721'
|
||||
| 'erc1155'
|
||||
| 'specialnft'
|
||||
blockNum?: string
|
||||
from?: string
|
||||
to?: string
|
||||
value?: number
|
||||
erc721TokenId?: string
|
||||
erc1155Metadata?: {
|
||||
tokenId: string
|
||||
value: string
|
||||
}[]
|
||||
tokenId?: string
|
||||
asset?: string
|
||||
uniqueId?: string
|
||||
hash?: string
|
||||
rawContract?: {
|
||||
value?: string
|
||||
address?: string
|
||||
decimal?: string
|
||||
}
|
||||
metadata?: {
|
||||
blockTimestamp?: string
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite'
|
||||
import { sqlite } from '../sqlite'
|
||||
import { relations } from './relations'
|
||||
|
||||
export const db = drizzle({ client: sqlite, relations })
|
||||
@@ -1,4 +0,0 @@
|
||||
import { defineRelations } from 'drizzle-orm'
|
||||
import * as schema from './schema'
|
||||
|
||||
export const relations = defineRelations(schema, () => ({}))
|
||||
@@ -1,115 +0,0 @@
|
||||
import { int, sqliteTable, text, uniqueIndex, index } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
export const tokenMetadata = sqliteTable(
|
||||
'token_metadata',
|
||||
{
|
||||
name: text(),
|
||||
symbol: text().notNull(),
|
||||
decimals: int(),
|
||||
logo: text(),
|
||||
contractAddress: text().notNull(),
|
||||
chainId: text().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
contractUnique: uniqueIndex('contract_unique').on(
|
||||
table.chainId,
|
||||
table.contractAddress,
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
export const transactions = sqliteTable(
|
||||
'transactions',
|
||||
{
|
||||
hash: text().notNull(),
|
||||
chainId: text().notNull(),
|
||||
from: text().notNull(),
|
||||
to: text(),
|
||||
value: text().notNull(),
|
||||
blockNumber: text(),
|
||||
blockHash: text(),
|
||||
gas: text().notNull(),
|
||||
gasPrice: text(),
|
||||
maxFeePerGas: text(),
|
||||
maxPriorityFeePerGas: text(),
|
||||
input: text().notNull(),
|
||||
nonce: int().notNull(),
|
||||
transactionIndex: int(),
|
||||
type: text(),
|
||||
v: text(),
|
||||
r: text(),
|
||||
s: text(),
|
||||
yParity: int(),
|
||||
},
|
||||
(table) => ({
|
||||
txUnique: uniqueIndex('tx_unique').on(table.chainId, table.hash),
|
||||
}),
|
||||
)
|
||||
|
||||
export const transactionReceipts = sqliteTable(
|
||||
'transaction_receipts',
|
||||
{
|
||||
transactionHash: text().notNull(),
|
||||
chainId: text().notNull(),
|
||||
blockHash: text().notNull(),
|
||||
blockNumber: text().notNull(),
|
||||
from: text().notNull(),
|
||||
to: text(),
|
||||
cumulativeGasUsed: text().notNull(),
|
||||
gasUsed: text().notNull(),
|
||||
status: text().notNull(),
|
||||
effectiveGasPrice: text(),
|
||||
contractAddress: text(),
|
||||
type: text().notNull(),
|
||||
transactionIndex: int().notNull(),
|
||||
logsBloom: text(),
|
||||
},
|
||||
(table) => ({
|
||||
receiptUnique: uniqueIndex('receipt_unique').on(
|
||||
table.chainId,
|
||||
table.transactionHash,
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
export const evmWallets = sqliteTable('evm_wallets', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
passwordHash: text('password_hash'),
|
||||
vaultKey: text('vault_key').notNull(),
|
||||
createdAt: text('created_at').notNull(),
|
||||
})
|
||||
|
||||
export const txSyncStatus = sqliteTable('tx_sync_status', {
|
||||
chainId: text('chain_id').notNull(),
|
||||
address: text('address').notNull(),
|
||||
lastSyncBlock: text('last_sync_block').notNull(),
|
||||
syncedAt: text('synced_at').notNull(),
|
||||
}, (table) => ({
|
||||
syncUnique: uniqueIndex('sync_unique').on(table.chainId, table.address),
|
||||
}))
|
||||
|
||||
export const txHistory = sqliteTable(
|
||||
'tx_history',
|
||||
{
|
||||
chainId: text().notNull(),
|
||||
hash: text().notNull(),
|
||||
blockNum: text(),
|
||||
from: text(),
|
||||
to: text(),
|
||||
value: text(),
|
||||
asset: text(),
|
||||
category: text(),
|
||||
rawContractAddress: text(),
|
||||
rawContractDecimal: text(),
|
||||
metadataBlockTimestamp: text(),
|
||||
raw: text().notNull(),
|
||||
syncedAt: text().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
historyUnique: uniqueIndex('tx_history_unique').on(table.chainId, table.hash, table.category, table.rawContractAddress),
|
||||
blockIdx: index('tx_history_block_idx').on(table.chainId, table.blockNum),
|
||||
fromIdx: index('tx_history_from_idx').on(table.from),
|
||||
toIdx: index('tx_history_to_idx').on(table.to),
|
||||
}),
|
||||
)
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Database } from 'bun:sqlite'
|
||||
import envPaths from 'env-paths'
|
||||
import { join } from 'path'
|
||||
import { mkdirSync } from 'fs'
|
||||
import { getENV } from '../get-env'
|
||||
|
||||
const env = getENV()
|
||||
|
||||
const { data } = envPaths(env.appName, { suffix: '' })
|
||||
mkdirSync(data, { recursive: true })
|
||||
|
||||
console.log(data)
|
||||
|
||||
export const sqlite = new Database(join(data, env.dbFilename), {
|
||||
create: true,
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export { syncTransactionHistory } from './sync-tx-history'
|
||||
@@ -1,76 +0,0 @@
|
||||
import { getClient, getTransactionDetails } from '../viem'
|
||||
import { db } from '../db'
|
||||
import { transactions, transactionReceipts } from '../db/schema'
|
||||
import { getChainID } from '../alchemy/network'
|
||||
|
||||
export const syncTxDetails = async (
|
||||
chainId: string,
|
||||
apiKey: string,
|
||||
hash: string,
|
||||
) => {
|
||||
const client = getClient(chainId, apiKey)
|
||||
|
||||
const id = getChainID(chainId)
|
||||
if (!id) return null
|
||||
|
||||
const raw = await getTransactionDetails(client, hash)
|
||||
if (!raw) return null
|
||||
|
||||
const hashLower = hash.toLowerCase()
|
||||
|
||||
try {
|
||||
await db
|
||||
.insert(transactions)
|
||||
.values({
|
||||
hash: hashLower,
|
||||
chainId: id,
|
||||
from: raw.tx.from.toLowerCase(),
|
||||
to: raw.tx.to?.toLowerCase() ?? null,
|
||||
value: raw.tx.value.toString(),
|
||||
blockNumber: raw.tx.blockNumber?.toString() ?? null,
|
||||
blockHash: raw.tx.blockHash?.toLowerCase() ?? null,
|
||||
gas: raw.tx.gas.toString(),
|
||||
gasPrice: raw.tx.gasPrice?.toString() ?? null,
|
||||
maxFeePerGas: raw.tx.maxFeePerGas?.toString() ?? null,
|
||||
maxPriorityFeePerGas:
|
||||
raw.tx.maxPriorityFeePerGas?.toString() ?? null,
|
||||
input: raw.tx.input,
|
||||
nonce: raw.tx.nonce,
|
||||
transactionIndex: raw.tx.transactionIndex ?? null,
|
||||
type: raw.tx.type ?? null,
|
||||
v: raw.tx.v?.toString() ?? null,
|
||||
r: raw.tx.r?.toLowerCase() ?? null,
|
||||
s: raw.tx.s?.toLowerCase() ?? null,
|
||||
yParity: raw.tx.yParity ?? null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
} catch (e) {
|
||||
console.log('Failed to cache transaction', e)
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.insert(transactionReceipts)
|
||||
.values({
|
||||
transactionHash: hashLower,
|
||||
chainId: id,
|
||||
blockHash: raw.receipt.blockHash.toLowerCase(),
|
||||
blockNumber: raw.receipt.blockNumber.toString(),
|
||||
from: raw.receipt.from.toLowerCase(),
|
||||
to: raw.receipt.to?.toLowerCase() ?? null,
|
||||
cumulativeGasUsed: raw.receipt.cumulativeGasUsed.toString(),
|
||||
gasUsed: raw.receipt.gasUsed.toString(),
|
||||
status: raw.receipt.status,
|
||||
effectiveGasPrice:
|
||||
raw.receipt.effectiveGasPrice?.toString() ?? null,
|
||||
contractAddress:
|
||||
raw.receipt.contractAddress?.toLowerCase() ?? null,
|
||||
type: raw.receipt.type,
|
||||
transactionIndex: raw.receipt.transactionIndex,
|
||||
logsBloom: raw.receipt.logsBloom?.toLowerCase() ?? null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
} catch (e) {
|
||||
console.log('Failed to cache transaction receipt', e)
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { numberToHex } from 'viem'
|
||||
import { db } from '../db'
|
||||
import { txHistory, txSyncStatus } from '../db/schema'
|
||||
import { getTransactionHistory } from '../alchemy/get-transaction-history'
|
||||
import { syncTxDetails } from './sync-tx-details'
|
||||
|
||||
const PAGE_SIZE = 76
|
||||
|
||||
export const syncTransactionHistory = async (
|
||||
key: string,
|
||||
chainid: string,
|
||||
address: string,
|
||||
): Promise<number> => {
|
||||
const addrLower = address.toLowerCase()
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(txSyncStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(txSyncStatus.chainId, chainid),
|
||||
eq(txSyncStatus.address, addrLower),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
|
||||
const fromBlock = existing?.lastSyncBlock
|
||||
? numberToHex(Number(existing.lastSyncBlock) + 1)
|
||||
: undefined
|
||||
|
||||
const transfers = await getTransactionHistory(
|
||||
key,
|
||||
chainid,
|
||||
address,
|
||||
{
|
||||
maxCount: 1000,
|
||||
fromBlock,
|
||||
},
|
||||
)
|
||||
|
||||
if (!transfers || transfers.length === 0) return 0
|
||||
|
||||
const prepared: Array<typeof txHistory.$inferInsert> = []
|
||||
let latestBlock = Number(existing?.lastSyncBlock ?? '0')
|
||||
|
||||
for (const t of transfers) {
|
||||
if (!t.hash) continue
|
||||
if (t.blockNum) {
|
||||
const blockNum = parseInt(t.blockNum, 16)
|
||||
if (blockNum > latestBlock) latestBlock = blockNum
|
||||
}
|
||||
|
||||
prepared.push({
|
||||
chainId: chainid,
|
||||
hash: t.hash,
|
||||
blockNum: t.blockNum ? String(parseInt(t.blockNum, 16)) : null,
|
||||
from: t.from?.toLowerCase() ?? null,
|
||||
to: t.to?.toLowerCase() ?? null,
|
||||
value: t.value?.toString() ?? null,
|
||||
asset: t.asset ?? null,
|
||||
category: t.category ?? null,
|
||||
rawContractAddress:
|
||||
t.rawContract?.address?.toLowerCase() ?? null,
|
||||
rawContractDecimal: t.rawContract?.decimal ?? null,
|
||||
metadataBlockTimestamp: t.metadata?.blockTimestamp ?? null,
|
||||
raw: JSON.stringify(t),
|
||||
syncedAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
await syncTxDetails(chainid, key, t.hash)
|
||||
}
|
||||
|
||||
flush(prepared)
|
||||
db.insert(txSyncStatus)
|
||||
.values({
|
||||
chainId: chainid,
|
||||
address: addrLower,
|
||||
lastSyncBlock: String(latestBlock),
|
||||
syncedAt: new Date().toISOString(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [txSyncStatus.chainId, txSyncStatus.address],
|
||||
set: {
|
||||
lastSyncBlock: String(latestBlock),
|
||||
syncedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
.run()
|
||||
|
||||
console.log(
|
||||
`[sync] ${chainid}/${addrLower}: synced ${prepared.length} transfers up to block ${latestBlock}`,
|
||||
)
|
||||
|
||||
return prepared.length
|
||||
}
|
||||
|
||||
function flush(prepared: Array<typeof txHistory.$inferInsert>) {
|
||||
while (prepared.length > 0) {
|
||||
const batch = prepared.splice(0, PAGE_SIZE)
|
||||
const cols = [
|
||||
'chainId',
|
||||
'hash',
|
||||
'blockNum',
|
||||
'"from"',
|
||||
'"to"',
|
||||
'value',
|
||||
'asset',
|
||||
'category',
|
||||
'rawContractAddress',
|
||||
'rawContractDecimal',
|
||||
'metadataBlockTimestamp',
|
||||
'raw',
|
||||
'syncedAt',
|
||||
]
|
||||
const colKeys = [
|
||||
'chainId',
|
||||
'hash',
|
||||
'blockNum',
|
||||
'from',
|
||||
'to',
|
||||
'value',
|
||||
'asset',
|
||||
'category',
|
||||
'rawContractAddress',
|
||||
'rawContractDecimal',
|
||||
'metadataBlockTimestamp',
|
||||
'raw',
|
||||
'syncedAt',
|
||||
]
|
||||
const placeholders = batch
|
||||
.map(() => `(${colKeys.map(() => '?').join(',')})`)
|
||||
.join(',')
|
||||
const flat = batch.flatMap((r) =>
|
||||
colKeys.map((c) => (r as any)[c] ?? null),
|
||||
)
|
||||
const sql = `INSERT OR IGNORE INTO tx_history (${cols.join(',')}) VALUES ${placeholders}`
|
||||
|
||||
const dbClient = db.$client
|
||||
dbClient.exec('BEGIN')
|
||||
try {
|
||||
dbClient.run(sql, ...flat)
|
||||
dbClient.exec('COMMIT')
|
||||
} catch (e) {
|
||||
dbClient.exec('ROLLBACK')
|
||||
console.error('[sync] flush error:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { db } from '../db'
|
||||
import { tokenMetadata } from '../db/schema'
|
||||
import { getChainID } from '../alchemy/network'
|
||||
import { getTokenMetadata as getTokenMetadataFromAlchemy } from '../alchemy/get-token-metadata'
|
||||
|
||||
export const getTokenMetadata = async (
|
||||
key: string,
|
||||
chainid: string,
|
||||
tokenAddress: string,
|
||||
) => {
|
||||
const chainId = getChainID(chainid)
|
||||
if (!chainId) return
|
||||
|
||||
const cached = db
|
||||
.select()
|
||||
.from(tokenMetadata)
|
||||
.where(
|
||||
and(
|
||||
eq(tokenMetadata.chainId, chainId),
|
||||
eq(tokenMetadata.contractAddress, tokenAddress.toLowerCase()),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
|
||||
if (cached) {
|
||||
return {
|
||||
jsonrpc: '2.0' as const,
|
||||
id: 0,
|
||||
result: {
|
||||
name: cached.name ?? null,
|
||||
symbol: cached.symbol,
|
||||
decimals: cached.decimals ?? null,
|
||||
logo: cached.logo ?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getTokenMetadataFromAlchemy(
|
||||
key,
|
||||
chainid,
|
||||
tokenAddress,
|
||||
)
|
||||
|
||||
if (result?.result?.symbol) {
|
||||
try {
|
||||
await db
|
||||
.insert(tokenMetadata)
|
||||
.values({
|
||||
name: result.result.name,
|
||||
symbol: result.result.symbol,
|
||||
decimals: result.result.decimals,
|
||||
logo: result.result.logo,
|
||||
contractAddress: tokenAddress.toLowerCase(),
|
||||
chainId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
} catch (e) {
|
||||
console.log('Failed to cache token metadata', e)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { getTokenMetadata } from './get-metadata'
|
||||
@@ -1,66 +0,0 @@
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { formatEther, formatGwei, type PublicClient } from 'viem'
|
||||
import { db } from '../db'
|
||||
import { transactions, transactionReceipts } from '../db/schema'
|
||||
import { getChainID } from '../alchemy/network'
|
||||
|
||||
export const getTransactionDetails = async (
|
||||
hash: string,
|
||||
chainId: string,
|
||||
) => {
|
||||
if (!hash.startsWith('0x')) return null
|
||||
|
||||
const id = getChainID(chainId)
|
||||
if (!id) return null
|
||||
|
||||
const hashLower = hash.toLowerCase()
|
||||
|
||||
const cachedTx = db
|
||||
.select()
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.hash, hashLower),
|
||||
eq(transactions.chainId, id),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
|
||||
const cachedReceipt = db
|
||||
.select()
|
||||
.from(transactionReceipts)
|
||||
.where(
|
||||
and(
|
||||
eq(transactionReceipts.transactionHash, hashLower),
|
||||
eq(transactionReceipts.chainId, id),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
|
||||
if (cachedTx && cachedReceipt) {
|
||||
const value = BigInt(cachedTx.value)
|
||||
const gasUsed = BigInt(cachedReceipt.gasUsed)
|
||||
const gasPrice = cachedTx.gasPrice
|
||||
? BigInt(cachedTx.gasPrice)
|
||||
: BigInt(0)
|
||||
|
||||
return {
|
||||
hash: cachedTx.hash,
|
||||
from: cachedTx.from,
|
||||
to: cachedTx.to,
|
||||
value: formatEther(value),
|
||||
blockNumber: cachedReceipt.blockNumber,
|
||||
fee: (
|
||||
parseFloat(formatGwei(gasUsed)) *
|
||||
parseFloat(formatGwei(gasPrice))
|
||||
).toString(),
|
||||
gasPrice: formatGwei(gasPrice),
|
||||
status: cachedReceipt.status as 'success' | 'reverted',
|
||||
type: cachedTx.type ?? 'legacy',
|
||||
nonce: cachedTx.nonce,
|
||||
input: cachedTx.input,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { eq, and, or, desc } from 'drizzle-orm'
|
||||
import { db } from '../db'
|
||||
import { txHistory } from '../db/schema'
|
||||
import type { Transaction } from '../alchemy/types'
|
||||
|
||||
export const getTransactionHistory = async (
|
||||
key: string,
|
||||
chainid: string,
|
||||
address: string,
|
||||
opts: { maxCount: number },
|
||||
) => {
|
||||
return getCachedTransactionHistory(chainid, address)
|
||||
}
|
||||
|
||||
export const getCachedTransactionHistory = (
|
||||
chainid: string,
|
||||
address: string,
|
||||
) => {
|
||||
const addrLower = address.toLowerCase()
|
||||
const rows = db
|
||||
.select()
|
||||
.from(txHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(txHistory.chainId, chainid),
|
||||
or(
|
||||
eq(txHistory.from, addrLower),
|
||||
eq(txHistory.to, addrLower),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(txHistory.blockNum))
|
||||
.all()
|
||||
|
||||
return rows.map(toTransfer)
|
||||
}
|
||||
|
||||
function toTransfer(row: typeof txHistory.$inferSelect): Transaction {
|
||||
if (row.raw) {
|
||||
return JSON.parse(row.raw) as Transaction
|
||||
}
|
||||
return {
|
||||
hash: row.hash,
|
||||
blockNum: row.blockNum
|
||||
? `0x${parseInt(row.blockNum).toString(16)}`
|
||||
: undefined,
|
||||
from: row.from ?? undefined,
|
||||
to: row.to ?? undefined,
|
||||
value: row.value ? Number(row.value) : undefined,
|
||||
asset: row.asset ?? undefined,
|
||||
category: row.category as Transaction['category'],
|
||||
rawContract:
|
||||
row.rawContractAddress || row.rawContractDecimal
|
||||
? {
|
||||
address: row.rawContractAddress ?? undefined,
|
||||
decimal: row.rawContractDecimal ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
metadata: row.metadataBlockTimestamp
|
||||
? { blockTimestamp: row.metadataBlockTimestamp }
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
getTransactionHistory,
|
||||
getCachedTransactionHistory,
|
||||
} from './get-history'
|
||||
export { getTransactionDetails } from './get-details'
|
||||
@@ -1,50 +0,0 @@
|
||||
import { isHex, type PublicClient } from 'viem'
|
||||
|
||||
export const getTransactionDetails = async (
|
||||
client: PublicClient,
|
||||
hash: string,
|
||||
) => {
|
||||
if (!isHex(hash)) return null
|
||||
|
||||
const [tx, receipt] = await Promise.all([
|
||||
client.getTransaction({ hash }),
|
||||
client.getTransactionReceipt({ hash }),
|
||||
])
|
||||
|
||||
return {
|
||||
tx: {
|
||||
hash: tx.hash,
|
||||
from: tx.from,
|
||||
to: tx.to ?? null,
|
||||
value: tx.value,
|
||||
blockNumber: tx.blockNumber ?? null,
|
||||
gasPrice: tx.gasPrice ?? null,
|
||||
gas: tx.gas,
|
||||
input: tx.input,
|
||||
nonce: tx.nonce,
|
||||
type: tx.type ?? 'legacy',
|
||||
blockHash: tx.blockHash ?? null,
|
||||
maxFeePerGas: tx.maxFeePerGas ?? null,
|
||||
maxPriorityFeePerGas: tx.maxPriorityFeePerGas ?? null,
|
||||
transactionIndex: tx.transactionIndex ?? null,
|
||||
v: tx.v ?? null,
|
||||
r: tx.r?.toString() ?? null,
|
||||
s: tx.s?.toString() ?? null,
|
||||
yParity: tx.yParity ?? null,
|
||||
},
|
||||
receipt: {
|
||||
blockHash: receipt.blockHash,
|
||||
blockNumber: receipt.blockNumber,
|
||||
from: receipt.from,
|
||||
to: receipt.to ?? null,
|
||||
cumulativeGasUsed: receipt.cumulativeGasUsed,
|
||||
gasUsed: receipt.gasUsed,
|
||||
status: receipt.status,
|
||||
effectiveGasPrice: receipt.effectiveGasPrice ?? null,
|
||||
contractAddress: receipt.contractAddress ?? null,
|
||||
type: receipt.type,
|
||||
transactionIndex: receipt.transactionIndex,
|
||||
logsBloom: receipt.logsBloom?.toString() ?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { bsc, mainnet, polygon, type Chain } from 'viem/chains'
|
||||
import type { ChainID } from '../alchemy/network'
|
||||
|
||||
const chains: Record<ChainID, Chain> = {
|
||||
'1': mainnet,
|
||||
'56': bsc,
|
||||
'137': polygon,
|
||||
}
|
||||
|
||||
export const getChain = (id: ChainID) => {
|
||||
return chains[id]
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createPublicClient, http } from 'viem'
|
||||
import { getChainID, getURL } from '../alchemy/network'
|
||||
import { getChain } from './getChain'
|
||||
|
||||
export const getClient = (chainId: string, apiKey: string) => {
|
||||
const id = getChainID(chainId)
|
||||
|
||||
if (!id) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
const url = getURL({ chainId: id, key: apiKey })
|
||||
const publicClient = createPublicClient({
|
||||
chain: getChain(id),
|
||||
transport: http(url),
|
||||
})
|
||||
|
||||
return publicClient
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './getClient'
|
||||
export { getTransactionDetails } from './get-transaction-details'
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
Client,
|
||||
Wallet,
|
||||
xrpToDrops,
|
||||
dropsToXrp,
|
||||
isValidClassicAddress,
|
||||
} from 'xrpl'
|
||||
import type {
|
||||
XrpBalance,
|
||||
XrpTxDetails,
|
||||
XrpTxEntry,
|
||||
XrpWalletInfo,
|
||||
} from '../../../lib/rpc-schema'
|
||||
|
||||
const SERVER = 'wss://s1.ripple.com'
|
||||
const RIPPLE_EPOCH = 946684800
|
||||
const WALLET_LIST_KEY = 'xrp_wallets'
|
||||
const SERVICE = 'koins'
|
||||
|
||||
let client: Client | null = null
|
||||
|
||||
export async function getClient(): Promise<Client> {
|
||||
if (client?.isConnected()) return client
|
||||
client = new Client(SERVER)
|
||||
await client.connect()
|
||||
return client
|
||||
}
|
||||
|
||||
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 async function getXrpWalletList(): Promise<XrpWalletInfo[]> {
|
||||
const raw = await Bun.secrets.get({ service: SERVICE, name: WALLET_LIST_KEY })
|
||||
if (!raw) return []
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveXrpWalletList(
|
||||
wallets: XrpWalletInfo[],
|
||||
): Promise<void> {
|
||||
await Bun.secrets.set({
|
||||
service: SERVICE,
|
||||
name: WALLET_LIST_KEY,
|
||||
value: JSON.stringify(wallets),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBalance(address: string): Promise<XrpBalance> {
|
||||
const c = await getClient()
|
||||
try {
|
||||
const balance = await c.getXrpBalance(address)
|
||||
return { address, balance: String(balance), funded: true }
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('Account not found')) {
|
||||
return { address, 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 c = await getClient()
|
||||
const res = await c.request({
|
||||
command: 'account_tx',
|
||||
account: address,
|
||||
ledger_index_min: -1,
|
||||
ledger_index_max: -1,
|
||||
limit: 25,
|
||||
})
|
||||
const entries: XrpTxEntry[] = []
|
||||
for (const entry of res.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 ? String(tx.date + RIPPLE_EPOCH) : '0',
|
||||
direction: tx.Destination === address ? 'in' : 'out',
|
||||
from: tx.Account,
|
||||
to: tx.Destination,
|
||||
destinationTag: tx.DestinationTag,
|
||||
fee: dropsAmount(tx.Fee),
|
||||
confirmed: entry.validated === true,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export async function getTxDetails(
|
||||
hash: string,
|
||||
): Promise<XrpTxDetails | null> {
|
||||
const c = await getClient()
|
||||
try {
|
||||
const res = await c.request({ command: 'tx', transaction: hash })
|
||||
const d = res.result as any
|
||||
const tx = d.tx_json ?? d
|
||||
if (tx.TransactionType !== 'Payment') return null
|
||||
const meta = d.meta ?? {}
|
||||
const delivered = meta.delivered_amount ?? meta.DeliveredAmount ?? tx.Amount
|
||||
return {
|
||||
hash: d.hash ?? hash,
|
||||
from: tx.Account,
|
||||
to: tx.Destination,
|
||||
amount: dropsAmount(delivered),
|
||||
fee: dropsAmount(tx.Fee),
|
||||
timestamp: tx.date ? String(tx.date + RIPPLE_EPOCH) : '0',
|
||||
ledgerIndex: d.ledger_index ?? 0,
|
||||
destinationTag: tx.DestinationTag,
|
||||
confirmed: d.validated === true,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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 c = await getClient()
|
||||
const prepared = await c.autofill({
|
||||
TransactionType: 'Payment',
|
||||
Account: wallet.classicAddress,
|
||||
Amount: xrpToDrops(amount),
|
||||
Destination: to,
|
||||
...(destinationTag !== undefined ? { DestinationTag: destinationTag } : {}),
|
||||
})
|
||||
const signed = wallet.sign(prepared)
|
||||
const result = await c.submit(signed.tx_blob)
|
||||
const engineResult = (result.result as any).engine_result
|
||||
if (engineResult !== 'tesSUCCESS') {
|
||||
const msg =
|
||||
(result.result as any).engine_result_message ?? 'Transaction failed'
|
||||
throw new Error(`${engineResult}: ${msg}`)
|
||||
}
|
||||
return { hash: signed.hash, fee: String(dropsToXrp((prepared as any).Fee ?? '0')) }
|
||||
}
|
||||
|
||||
export async function getFee(): Promise<{ fee: string } | null> {
|
||||
try {
|
||||
const c = await getClient()
|
||||
const res = await c.request({ command: 'fee' })
|
||||
return { fee: String(dropsToXrp(res.result.drops.open_ledger_fee)) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,641 +0,0 @@
|
||||
import { getTokenMetadata } from '../../lib/tokens'
|
||||
import { getTokensBalances } from '../../lib/alchemy'
|
||||
import { tryCatch } from '@koins/utils'
|
||||
import {
|
||||
getTransactionHistory,
|
||||
getTransactionDetails,
|
||||
getCachedTransactionHistory,
|
||||
} from '../../lib/transactions'
|
||||
import { syncTransactionHistory } from '../../lib/sync'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { db } from '../../lib/db'
|
||||
import { getClient } from '../../lib/viem'
|
||||
import {
|
||||
formatGwei,
|
||||
parseEther,
|
||||
parseUnits,
|
||||
encodeFunctionData,
|
||||
createWalletClient,
|
||||
} from 'viem'
|
||||
import { mnemonicToAccount } from 'viem/accounts'
|
||||
import { getChain } from '../../lib/viem/getChain'
|
||||
import { getChainID } from '../../lib/alchemy/network'
|
||||
import { alchemyTransport } from '@alchemy/common'
|
||||
import { sqlite } from '../../lib/sqlite'
|
||||
import { txHistory, evmWallets } from '../../lib/db/schema'
|
||||
import type {
|
||||
TokenBalanceResult,
|
||||
TokenPriceEntry,
|
||||
TxEntry,
|
||||
} from '../../../lib/rpc-schema'
|
||||
|
||||
export function createEvmHandlers(
|
||||
rpc: {
|
||||
send: {
|
||||
transfersUpdate: (p: {
|
||||
count: number
|
||||
chainid: string
|
||||
address: string
|
||||
}) => void
|
||||
}
|
||||
},
|
||||
syncState: {
|
||||
target: { address: string; chainid: string } | null
|
||||
timer: ReturnType<typeof setInterval> | null
|
||||
},
|
||||
) {
|
||||
const ALCHEMY_NETWORKS: Record<string, string> = {
|
||||
'1': 'eth-mainnet',
|
||||
'137': 'polygon-mainnet',
|
||||
'56': 'bnb-mainnet',
|
||||
}
|
||||
|
||||
const NATIVE_SYMBOLS: Record<string, string> = {
|
||||
'1': 'ETH',
|
||||
'137': 'POL',
|
||||
'56': 'BNB',
|
||||
}
|
||||
|
||||
async function fetchAlchemyTransfers(
|
||||
key: string,
|
||||
chainid: string,
|
||||
address: string,
|
||||
) {
|
||||
const transactions = await getTransactionHistory(
|
||||
key,
|
||||
chainid,
|
||||
address,
|
||||
{ maxCount: 10 },
|
||||
)
|
||||
if (!transactions) return []
|
||||
return transactions
|
||||
}
|
||||
|
||||
function formatUnits(balance: string, decimals: number): string {
|
||||
const bal = BigInt(balance)
|
||||
if (bal === 0n) return '0'
|
||||
const divisor = 10n ** BigInt(decimals)
|
||||
const intPart = bal / divisor
|
||||
const fracPart = (bal % divisor)
|
||||
.toString()
|
||||
.padStart(decimals, '0')
|
||||
.replace(/0+$/, '')
|
||||
return fracPart ? `${intPart}.${fracPart}` : `${intPart}`
|
||||
}
|
||||
|
||||
async function fetchAlchemyTokenBalances(
|
||||
key: string,
|
||||
chainid: string,
|
||||
address: string,
|
||||
): Promise<TokenBalanceResult[]> {
|
||||
const [balRes, balResError] = await tryCatch(
|
||||
getTokensBalances(key, chainid, address),
|
||||
)
|
||||
if (balResError) {
|
||||
console.log('Get Tokens Balance Error', balResError)
|
||||
return []
|
||||
}
|
||||
if (!balRes) return []
|
||||
const tokens = balRes.result?.tokenBalances ?? []
|
||||
const nonZero = tokens.filter(
|
||||
(t: any) => t.tokenBalance !== '0x0',
|
||||
)
|
||||
const metas = await Promise.allSettled(
|
||||
nonZero.map((t: any) =>
|
||||
getTokenMetadata(key, chainid, t.contractAddress),
|
||||
),
|
||||
)
|
||||
if (!metas) return []
|
||||
return nonZero
|
||||
.map((t: any, i: number) => {
|
||||
const meta =
|
||||
metas[i].status === 'fulfilled' && metas[i].value
|
||||
? metas[i].value.result
|
||||
: null
|
||||
return {
|
||||
symbol: meta?.symbol ?? 'Unknown',
|
||||
decimals: meta?.decimals ?? 0,
|
||||
balance: formatUnits(t.tokenBalance, meta?.decimals ?? 18),
|
||||
contractAddress: t.contractAddress,
|
||||
logo: meta?.logo ?? undefined,
|
||||
}
|
||||
})
|
||||
.filter((t) => t.logo && t.decimals !== 0)
|
||||
}
|
||||
|
||||
function mapTransfer(t: any): TxEntry {
|
||||
const isExternal = t.category === 'external'
|
||||
return {
|
||||
hash: t.hash,
|
||||
timeStamp: String(
|
||||
Math.floor(
|
||||
new Date(t.metadata?.blockTimestamp).getTime() / 1000,
|
||||
),
|
||||
),
|
||||
from: t.from,
|
||||
to: t.to,
|
||||
value: String(t.value),
|
||||
...(isExternal
|
||||
? {}
|
||||
: {
|
||||
tokenSymbol: t.asset,
|
||||
tokenDecimal: t.rawContract?.decimal
|
||||
? String(parseInt(t.rawContract.decimal, 16))
|
||||
: undefined,
|
||||
contractAddress: t.rawContract?.address ?? undefined,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function pairTransfers(
|
||||
transfers: any[],
|
||||
address: string,
|
||||
key?: string,
|
||||
chainid?: string,
|
||||
): Promise<TxEntry[]> {
|
||||
const addrLower = address.toLowerCase()
|
||||
const byHash = new Map<string, any[]>()
|
||||
for (const t of transfers) {
|
||||
if (!t.hash) continue
|
||||
const h = t.hash
|
||||
if (!byHash.has(h)) byHash.set(h, [])
|
||||
byHash.get(h)!.push(t)
|
||||
}
|
||||
|
||||
const combined: TxEntry[] = []
|
||||
|
||||
for (const [, group] of byHash) {
|
||||
const native = group.filter(
|
||||
(t: any) =>
|
||||
t.category === 'external' || t.category === 'internal',
|
||||
)
|
||||
const tokens = group.filter((t: any) => t.category === 'erc20')
|
||||
|
||||
const extOut = native.filter(
|
||||
(t: any) => t.from?.toLowerCase() === addrLower,
|
||||
)
|
||||
const extIn = native.filter(
|
||||
(t: any) => t.to?.toLowerCase() === addrLower,
|
||||
)
|
||||
const tokIn = tokens.filter(
|
||||
(t: any) => t.to?.toLowerCase() === addrLower,
|
||||
)
|
||||
const tokOut = tokens.filter(
|
||||
(t: any) => t.from?.toLowerCase() === addrLower,
|
||||
)
|
||||
|
||||
if (extOut.length > 0 && tokIn.length > 0) {
|
||||
combined.push({
|
||||
...mapTransfer(extOut[0]),
|
||||
tokenSymbol: tokIn[0].asset,
|
||||
tokenDecimal: tokIn[0].rawContract?.decimal
|
||||
? String(parseInt(tokIn[0].rawContract.decimal, 16))
|
||||
: undefined,
|
||||
contractAddress: tokIn[0].rawContract?.address ?? undefined,
|
||||
pairedValue: String(tokIn[0].value),
|
||||
pairedSymbol: tokIn[0].asset,
|
||||
pairedDecimals: tokIn[0].rawContract?.decimal
|
||||
? String(parseInt(tokIn[0].rawContract.decimal, 16))
|
||||
: undefined,
|
||||
pairedContractAddress:
|
||||
tokIn[0].rawContract?.address ?? undefined,
|
||||
})
|
||||
} else if (extOut.length > 0) {
|
||||
combined.push(mapTransfer(extOut[0]))
|
||||
} else if (extIn.length > 0) {
|
||||
combined.push(mapTransfer(extIn[0]))
|
||||
} else if (tokOut.length > 0 && tokIn.length > 0) {
|
||||
combined.push({
|
||||
...mapTransfer(tokOut[0]),
|
||||
pairedValue: String(tokIn[0].value),
|
||||
pairedSymbol: tokIn[0].asset,
|
||||
pairedDecimals: tokIn[0].rawContract?.decimal
|
||||
? String(parseInt(tokIn[0].rawContract.decimal, 16))
|
||||
: undefined,
|
||||
pairedContractAddress:
|
||||
tokIn[0].rawContract?.address ?? undefined,
|
||||
})
|
||||
} else if (tokOut.length > 0 && extIn.length > 0) {
|
||||
combined.push({
|
||||
...mapTransfer(tokOut[0]),
|
||||
pairedValue: String(extIn[0].value),
|
||||
pairedSymbol: chainid ? NATIVE_SYMBOLS[chainid] : undefined,
|
||||
})
|
||||
} else if (tokOut.length > 0) {
|
||||
combined.push(mapTransfer(tokOut[0]))
|
||||
} else if (tokIn.length > 0) {
|
||||
combined.push(mapTransfer(tokIn[0]))
|
||||
}
|
||||
}
|
||||
|
||||
if (key && chainid) {
|
||||
const addrSet = new Set<string>()
|
||||
for (const entry of combined) {
|
||||
if (entry.contractAddress)
|
||||
addrSet.add(entry.contractAddress.toLowerCase())
|
||||
if (entry.pairedContractAddress)
|
||||
addrSet.add(entry.pairedContractAddress.toLowerCase())
|
||||
}
|
||||
const logoMap = new Map<string, string>()
|
||||
await Promise.all(
|
||||
Array.from(addrSet).map(async (addr) => {
|
||||
const meta = await getTokenMetadata(key, chainid, addr)
|
||||
if (meta?.result?.logo) logoMap.set(addr, meta.result.logo)
|
||||
}),
|
||||
)
|
||||
for (const entry of combined) {
|
||||
if (entry.contractAddress)
|
||||
entry.logo = logoMap.get(
|
||||
entry.contractAddress.toLowerCase(),
|
||||
)
|
||||
if (entry.pairedContractAddress)
|
||||
entry.pairedLogo = logoMap.get(
|
||||
entry.pairedContractAddress.toLowerCase(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
combined.sort((a, b) => Number(b.timeStamp) - Number(a.timeStamp))
|
||||
return combined
|
||||
}
|
||||
|
||||
return {
|
||||
fetchTxHistory: async ({
|
||||
address,
|
||||
chainid,
|
||||
}: {
|
||||
address: string
|
||||
chainid: string
|
||||
}) => {
|
||||
const key = await Bun.secrets.get({
|
||||
service: 'koins',
|
||||
name: 'alchemy_key',
|
||||
})
|
||||
if (!key) return []
|
||||
const [all, allError] = await tryCatch(
|
||||
fetchAlchemyTransfers(key, chainid, address),
|
||||
)
|
||||
if (allError) {
|
||||
console.log(allError)
|
||||
return []
|
||||
}
|
||||
return pairTransfers(all, address, key, chainid)
|
||||
},
|
||||
fetchCachedTxHistory: async ({
|
||||
address,
|
||||
chainid,
|
||||
}: {
|
||||
address: string
|
||||
chainid: string
|
||||
}) => {
|
||||
const transfers = await getCachedTransactionHistory(
|
||||
chainid,
|
||||
address,
|
||||
)
|
||||
return pairTransfers(transfers, address)
|
||||
},
|
||||
syncTxHistory: async ({
|
||||
address,
|
||||
chainid,
|
||||
}: {
|
||||
address: string
|
||||
chainid: string
|
||||
}) => {
|
||||
const key = await Bun.secrets.get({
|
||||
service: 'koins',
|
||||
name: 'alchemy_key',
|
||||
})
|
||||
if (!key) return
|
||||
try {
|
||||
const count = await syncTransactionHistory(
|
||||
key,
|
||||
chainid,
|
||||
address,
|
||||
)
|
||||
rpc.send.transfersUpdate({ count, chainid, address })
|
||||
} catch (e) {
|
||||
console.error('[sync] error:', e)
|
||||
}
|
||||
},
|
||||
setAutoSync: async (
|
||||
target: { address: string; chainid: string } | null,
|
||||
) => {
|
||||
if (syncState.timer) clearInterval(syncState.timer)
|
||||
syncState.timer = null
|
||||
syncState.target = null
|
||||
if (!target) return
|
||||
syncState.target = target
|
||||
syncState.timer = setInterval(async () => {
|
||||
if (!syncState.target) return
|
||||
const key = await Bun.secrets.get({
|
||||
service: 'koins',
|
||||
name: 'alchemy_key',
|
||||
})
|
||||
if (!key) return
|
||||
try {
|
||||
const count = await syncTransactionHistory(
|
||||
key,
|
||||
syncState.target.chainid,
|
||||
syncState.target.address,
|
||||
)
|
||||
rpc.send.transfersUpdate({
|
||||
count,
|
||||
chainid: syncState.target.chainid,
|
||||
address: syncState.target.address,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[auto-sync] error:', e)
|
||||
}
|
||||
}, 30000)
|
||||
},
|
||||
flushTxCache: async () => {
|
||||
sqlite.run('DELETE FROM tx_history')
|
||||
sqlite.run('DELETE FROM tx_sync_status')
|
||||
console.log('[rpc] flushTxCache complete')
|
||||
},
|
||||
fetchTokenBalances: async ({
|
||||
address,
|
||||
chainid,
|
||||
}: {
|
||||
address: string
|
||||
chainid: string
|
||||
}) => {
|
||||
const key = await Bun.secrets.get({
|
||||
service: 'koins',
|
||||
name: 'alchemy_key',
|
||||
})
|
||||
if (!key) return []
|
||||
try {
|
||||
return await fetchAlchemyTokenBalances(key, chainid, address)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
fetchTokenPrices: async ({
|
||||
symbols,
|
||||
addresses,
|
||||
}: {
|
||||
symbols?: string[]
|
||||
addresses?: { network: string; address: string }[]
|
||||
}) => {
|
||||
const key = await Bun.secrets.get({
|
||||
service: 'koins',
|
||||
name: 'alchemy_key',
|
||||
})
|
||||
if (!key) return []
|
||||
try {
|
||||
const results: TokenPriceEntry[] = []
|
||||
if (symbols && symbols.length > 0) {
|
||||
const url = `https://api.g.alchemy.com/prices/v1/${key}/tokens/by-symbol?${symbols.map((s) => `symbols=${encodeURIComponent(s)}`).join('&')}`
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
if (res.ok) {
|
||||
const body = await res.json()
|
||||
for (const d of body.data ?? []) {
|
||||
for (const p of d.prices ?? []) {
|
||||
results.push({
|
||||
symbol: d.symbol,
|
||||
currency: p.currency,
|
||||
value: p.value,
|
||||
lastUpdatedAt: p.lastUpdatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (addresses && addresses.length > 0) {
|
||||
const url = `https://api.g.alchemy.com/prices/v1/${key}/tokens/by-address`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ addresses }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
if (res.ok) {
|
||||
const body = await res.json()
|
||||
for (const d of body.data ?? []) {
|
||||
for (const p of d.prices ?? []) {
|
||||
results.push({
|
||||
symbol: d.symbol,
|
||||
currency: p.currency,
|
||||
value: p.value,
|
||||
lastUpdatedAt: p.lastUpdatedAt,
|
||||
network: d.network,
|
||||
address: d.address,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('[prices] results:', results.length)
|
||||
return results
|
||||
} catch (error) {
|
||||
console.log('[prices] error:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
fetchGasPrice: async ({ chainid }: { chainid: string }) => {
|
||||
const key = await Bun.secrets.get({
|
||||
service: 'koins',
|
||||
name: 'alchemy_key',
|
||||
})
|
||||
if (!key) {
|
||||
console.log('[gas] no key')
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const client = getClient(chainid, key)
|
||||
const gasPrice = await client.getGasPrice()
|
||||
console.log(
|
||||
'[gas] price for chain',
|
||||
chainid,
|
||||
':',
|
||||
gasPrice.toString(),
|
||||
)
|
||||
return formatGwei(gasPrice)
|
||||
} catch (error) {
|
||||
console.log('[gas] error:', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
fetchTransactionDetails: async ({
|
||||
hash,
|
||||
chainid,
|
||||
address,
|
||||
}: {
|
||||
hash: string
|
||||
chainid: string
|
||||
address?: string
|
||||
}) => {
|
||||
const key = await Bun.secrets.get({
|
||||
service: 'koins',
|
||||
name: 'alchemy_key',
|
||||
})
|
||||
if (!key) return null
|
||||
try {
|
||||
const details = await getTransactionDetails(hash, chainid)
|
||||
if (!details) return null
|
||||
if (address) {
|
||||
const transfers = db
|
||||
.select()
|
||||
.from(txHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(txHistory.chainId, chainid),
|
||||
eq(txHistory.hash, hash.toLowerCase()),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
if (transfers.length > 0) {
|
||||
const paired = await pairTransfers(transfers, address)
|
||||
if (paired.length > 0 && paired[0].pairedValue) {
|
||||
details.pairedValue = paired[0].pairedValue
|
||||
details.pairedSymbol = paired[0].pairedSymbol
|
||||
details.pairedDecimals = paired[0].pairedDecimals
|
||||
details.pairedContractAddress =
|
||||
paired[0].pairedContractAddress
|
||||
details.pairedLogo = paired[0].pairedLogo
|
||||
}
|
||||
}
|
||||
}
|
||||
return details
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
evmCreateWallet: async ({
|
||||
name,
|
||||
phrase,
|
||||
passwordHash,
|
||||
}: {
|
||||
name: string
|
||||
phrase: string
|
||||
passwordHash?: string
|
||||
}) => {
|
||||
console.log('[rpc] evmCreateWallet:', name)
|
||||
const id = crypto.randomUUID()
|
||||
const vaultKey = `evm_seed_${id}`
|
||||
await Bun.secrets.set({
|
||||
service: 'koins',
|
||||
name: vaultKey,
|
||||
value: phrase,
|
||||
})
|
||||
await db.insert(evmWallets).values({
|
||||
id,
|
||||
name,
|
||||
passwordHash: passwordHash ?? null,
|
||||
vaultKey,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
console.log('[rpc] evmCreateWallet complete:', id)
|
||||
return { id, name, createdAt: new Date().toISOString() }
|
||||
},
|
||||
evmListWallets: async () => {
|
||||
console.log('[rpc] evmListWallets')
|
||||
const wallets = db.select().from(evmWallets).all()
|
||||
return wallets.map((w: any) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
hasPassword: !!w.passwordHash,
|
||||
vaultKey: w.vaultKey,
|
||||
createdAt: w.createdAt,
|
||||
}))
|
||||
},
|
||||
evmGetSeed: async ({ vaultKey }: { vaultKey: string }) => {
|
||||
console.log('[rpc] evmGetSeed:', vaultKey)
|
||||
const seed = await Bun.secrets.get({
|
||||
service: 'koins',
|
||||
name: vaultKey,
|
||||
})
|
||||
if (!seed) throw new Error('Seed not found in keychain')
|
||||
return seed
|
||||
},
|
||||
evmDeleteWallet: async ({ id }: { id: string }) => {
|
||||
console.log('[rpc] evmDeleteWallet:', id)
|
||||
const wallet = db
|
||||
.select()
|
||||
.from(evmWallets)
|
||||
.where(eq(evmWallets.id, id))
|
||||
.get()
|
||||
if (!wallet) throw new Error('Wallet not found')
|
||||
await Promise.all([
|
||||
Bun.secrets.delete({
|
||||
service: 'koins',
|
||||
name: wallet.vaultKey,
|
||||
}),
|
||||
Bun.secrets.delete({
|
||||
service: 'koins',
|
||||
name: `evm_auth_${id}`,
|
||||
}),
|
||||
])
|
||||
db.delete(evmWallets).where(eq(evmWallets.id, id)).run()
|
||||
console.log('[rpc] evmDeleteWallet complete')
|
||||
},
|
||||
evmSendTransfer: async ({
|
||||
seed,
|
||||
to,
|
||||
amount,
|
||||
chainid,
|
||||
contractAddress,
|
||||
tokenDecimals,
|
||||
}: {
|
||||
seed: string
|
||||
to: string
|
||||
amount: string
|
||||
chainid: string
|
||||
contractAddress?: string
|
||||
tokenDecimals?: number
|
||||
}) => {
|
||||
const key = await Bun.secrets.get({
|
||||
service: 'koins',
|
||||
name: 'alchemy_key',
|
||||
})
|
||||
if (!key) throw new Error('API key not set')
|
||||
const _amount = String(amount)
|
||||
const account = mnemonicToAccount(seed)
|
||||
const id = getChainID(chainid)
|
||||
if (!id) throw new Error('Unsupported chain')
|
||||
const chain = getChain(id)
|
||||
const walletClient = createWalletClient({
|
||||
account,
|
||||
chain,
|
||||
transport: alchemyTransport({ apiKey: key }),
|
||||
})
|
||||
if (contractAddress && tokenDecimals !== undefined) {
|
||||
const erc20Abi = [
|
||||
{
|
||||
name: 'transfer',
|
||||
type: 'function',
|
||||
inputs: [
|
||||
{ name: 'to', type: 'address' },
|
||||
{ name: 'amount', type: 'uint256' },
|
||||
],
|
||||
outputs: [{ name: '', type: 'bool' }],
|
||||
},
|
||||
] as const
|
||||
const data = encodeFunctionData({
|
||||
abi: erc20Abi,
|
||||
functionName: 'transfer',
|
||||
args: [
|
||||
to as `0x${string}`,
|
||||
parseUnits(_amount, tokenDecimals),
|
||||
],
|
||||
})
|
||||
const hash = await walletClient.sendTransaction({
|
||||
to: contractAddress as `0x${string}`,
|
||||
data,
|
||||
value: 0n,
|
||||
})
|
||||
return hash
|
||||
}
|
||||
const hash = await walletClient.sendTransaction({
|
||||
to: to as `0x${string}`,
|
||||
value: parseEther(_amount),
|
||||
})
|
||||
return hash
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
generateWallet,
|
||||
walletFromSecret,
|
||||
getXrpWalletList,
|
||||
saveXrpWalletList,
|
||||
getBalance,
|
||||
getTransactions,
|
||||
getTxDetails,
|
||||
sendPayment,
|
||||
getFee,
|
||||
} from '../../lib/xrp'
|
||||
import type { XrpWalletInfo } from '../../../lib/rpc-schema'
|
||||
|
||||
const SERVICE = 'koins'
|
||||
|
||||
export function createXrpHandlers() {
|
||||
return {
|
||||
xrpListWallets: async () => {
|
||||
console.log('[rpc] xrpListWallets')
|
||||
return getXrpWalletList()
|
||||
},
|
||||
xrpCreateWallet: async ({
|
||||
name,
|
||||
hasPassword,
|
||||
}: {
|
||||
name: string
|
||||
hasPassword?: boolean
|
||||
}) => {
|
||||
console.log('[rpc] xrpCreateWallet:', name)
|
||||
const { address, seed } = generateWallet()
|
||||
const id = crypto.randomUUID()
|
||||
const vaultKey = `xrp_seed_${id}`
|
||||
await Bun.secrets.set({ service: SERVICE, name: vaultKey, value: seed })
|
||||
const wallets = await getXrpWalletList()
|
||||
const createdAt = new Date().toISOString()
|
||||
const info: XrpWalletInfo = {
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
hasPassword: hasPassword ?? false,
|
||||
vaultKey,
|
||||
createdAt,
|
||||
}
|
||||
await saveXrpWalletList([...wallets, info])
|
||||
console.log('[rpc] xrpCreateWallet complete:', id)
|
||||
return { id, address, seed, createdAt }
|
||||
},
|
||||
xrpImportWallet: async ({
|
||||
name,
|
||||
secret,
|
||||
hasPassword,
|
||||
}: {
|
||||
name: string
|
||||
secret: string
|
||||
hasPassword?: boolean
|
||||
}) => {
|
||||
console.log('[rpc] xrpImportWallet:', name)
|
||||
const { address } = walletFromSecret(secret)
|
||||
const id = crypto.randomUUID()
|
||||
const vaultKey = `xrp_seed_${id}`
|
||||
await Bun.secrets.set({
|
||||
service: SERVICE,
|
||||
name: vaultKey,
|
||||
value: secret.trim(),
|
||||
})
|
||||
const wallets = await getXrpWalletList()
|
||||
const createdAt = new Date().toISOString()
|
||||
const info: XrpWalletInfo = {
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
hasPassword: hasPassword ?? false,
|
||||
vaultKey,
|
||||
createdAt,
|
||||
}
|
||||
await saveXrpWalletList([...wallets, info])
|
||||
console.log('[rpc] xrpImportWallet complete:', id)
|
||||
return { id, address, createdAt }
|
||||
},
|
||||
xrpDeleteWallet: async ({ id }: { id: string }) => {
|
||||
console.log('[rpc] xrpDeleteWallet:', id)
|
||||
const wallets = await getXrpWalletList()
|
||||
const wallet = wallets.find((w) => w.id === id)
|
||||
if (!wallet) throw new Error('Wallet not found')
|
||||
await Promise.all([
|
||||
Bun.secrets.delete({ service: SERVICE, name: wallet.vaultKey }),
|
||||
Bun.secrets.delete({ service: SERVICE, name: `xrp_auth_${id}` }),
|
||||
])
|
||||
await saveXrpWalletList(wallets.filter((w) => w.id !== id))
|
||||
console.log('[rpc] xrpDeleteWallet complete')
|
||||
},
|
||||
xrpGetSeed: async ({ vaultKey }: { vaultKey: string }) => {
|
||||
console.log('[rpc] xrpGetSeed:', vaultKey)
|
||||
const seed = await Bun.secrets.get({ service: SERVICE, name: vaultKey })
|
||||
if (!seed) throw new Error('Seed not found in keychain')
|
||||
return seed
|
||||
},
|
||||
xrpGetBalance: async ({ address }: { address: string }) => {
|
||||
console.log('[rpc] xrpGetBalance:', address)
|
||||
return getBalance(address)
|
||||
},
|
||||
xrpGetTransactions: async ({ address }: { address: string }) => {
|
||||
console.log('[rpc] xrpGetTransactions:', address)
|
||||
try {
|
||||
return await getTransactions(address)
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('Account not found')) {
|
||||
return []
|
||||
}
|
||||
throw e
|
||||
}
|
||||
},
|
||||
xrpGetTxDetails: async ({ hash }: { hash: string }) => {
|
||||
console.log('[rpc] xrpGetTxDetails:', hash)
|
||||
return getTxDetails(hash)
|
||||
},
|
||||
xrpSend: async ({
|
||||
secret,
|
||||
to,
|
||||
amount,
|
||||
destinationTag,
|
||||
}: {
|
||||
secret: string
|
||||
to: string
|
||||
amount: string
|
||||
destinationTag?: number
|
||||
}) => {
|
||||
console.log('[rpc] xrpSend:', { to, amount, destinationTag })
|
||||
const result = await sendPayment(secret, to, amount, destinationTag)
|
||||
console.log('[rpc] xrpSend complete:', result.hash)
|
||||
return result
|
||||
},
|
||||
xrpGetFee: async () => {
|
||||
console.log('[rpc] xrpGetFee')
|
||||
return getFee()
|
||||
},
|
||||
fetchXrpPrice: async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
'https://api.coingecko.com/api/v3/simple/price?ids=ripple&vs_currencies=usd',
|
||||
{ signal: AbortSignal.timeout(10000) },
|
||||
)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
if (!data?.ripple?.usd) return null
|
||||
return { usd: String(data.ripple.usd) }
|
||||
} catch (error) {
|
||||
console.log('[xrp][price] error:', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,799 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { electrobun, type TxEntry } from '$lib/electrobun.js'
|
||||
import {
|
||||
evmWallet as wallet,
|
||||
type NetworkId,
|
||||
} from '$lib/states/evm-wallet.svelte.js'
|
||||
import { Button } from '$lib/components/ui/button/index.js'
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js'
|
||||
import { generateMnemonic } from 'viem/accounts'
|
||||
import { english } from 'viem/accounts'
|
||||
import { navigate } from 'sv-router/generated'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '$lib/components/ui/card/index.js'
|
||||
import ArrowDown from '@lucide/svelte/icons/arrow-down'
|
||||
import ArrowUp from '@lucide/svelte/icons/arrow-up'
|
||||
import ArrowLeftRight from '@lucide/svelte/icons/arrow-left-right'
|
||||
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||
import Fingerprint from '@lucide/svelte/icons/fingerprint'
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2'
|
||||
import Plus from '@lucide/svelte/icons/plus'
|
||||
import WalletIcon from '@lucide/svelte/icons/wallet'
|
||||
import LockKeyhole from '@lucide/svelte/icons/lock-keyhole'
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link'
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js'
|
||||
import QRCode from 'qrcode'
|
||||
import Loader from './loader.svelte'
|
||||
import { LineChart } from 'layerchart'
|
||||
import { scaleUtc } from 'd3-scale'
|
||||
import { curveLinear } from 'd3-shape'
|
||||
import * as Chart from '$lib/components/ui/chart/index.js'
|
||||
import { formatEther, parseEther } from 'viem'
|
||||
import { frontendLog, frontendLogError } from '$lib/electrobun.js'
|
||||
|
||||
let { networkId }: { networkId: NetworkId } = $props()
|
||||
|
||||
const CDN =
|
||||
'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains'
|
||||
|
||||
const chainDir: Record<string, string> = {
|
||||
eth: 'ethereum',
|
||||
bsc: 'smartchain',
|
||||
polygon: 'polygon',
|
||||
}
|
||||
|
||||
function nativeIcon(id: string) {
|
||||
return `${CDN}/${chainDir[id]}/info/logo.png`
|
||||
}
|
||||
|
||||
function tokenIcon(id: string, address?: string) {
|
||||
if (!address) return ''
|
||||
return `${CDN}/${chainDir[id]}/assets/${address}/logo.png`
|
||||
}
|
||||
|
||||
function txAction(tx: TxEntry, address: string): string {
|
||||
if (tx.pairedValue) return 'Swap'
|
||||
const dir =
|
||||
tx.from.toLowerCase() === address.toLowerCase()
|
||||
? 'Sent'
|
||||
: 'Received'
|
||||
if (tx.tokenSymbol) return `${dir} ${tx.tokenSymbol}`
|
||||
return dir
|
||||
}
|
||||
|
||||
const w = wallet
|
||||
let inputSeed = $state('')
|
||||
let inputPassword = $state('')
|
||||
let inputUnlockPassword = $state('')
|
||||
let inputWalletName = $state('')
|
||||
let apiKeyInput = $state('')
|
||||
let qrDataUrl = $state('')
|
||||
let qrDialogOpen = $state(false)
|
||||
let showCreateForm = $state(false)
|
||||
$effect(() => {
|
||||
if (w.address) {
|
||||
QRCode.toDataURL(w.address, { width: 256, margin: 1 }).then(
|
||||
(url) => (qrDataUrl = url),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function navTarget(id: NetworkId): string {
|
||||
if (id === 'eth') return '/multicoin'
|
||||
return `/multicoin/${id}`
|
||||
}
|
||||
|
||||
const CHAIN_NETWORKS: Record<string, string> = {
|
||||
'1': 'eth-mainnet',
|
||||
'137': 'polygon-mainnet',
|
||||
'56': 'bnb-mainnet',
|
||||
}
|
||||
|
||||
let initStarted = false
|
||||
let nativePrice = $state<{
|
||||
value: string
|
||||
currency: string
|
||||
} | null>(null)
|
||||
let tokenPrices = $state<
|
||||
Record<string, { value: string; currency: string }>
|
||||
>({})
|
||||
let gasPrice = $state<string | null>(null)
|
||||
|
||||
$effect(() => {
|
||||
const syms: string[] = w.symbol ? [w.symbol] : []
|
||||
const addrs: { network: string; address: string }[] = []
|
||||
const network = CHAIN_NETWORKS[w.chainid]
|
||||
if (network) {
|
||||
for (const t of w.tokenBalances) {
|
||||
if (t.contractAddress) {
|
||||
addrs.push({ network, address: t.contractAddress })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (syms.length > 0 || addrs.length > 0) {
|
||||
electrobun.rpc?.request
|
||||
.fetchTokenPrices({
|
||||
symbols: syms.length > 0 ? syms : undefined,
|
||||
addresses: addrs.length > 0 ? addrs : undefined,
|
||||
})
|
||||
.then((prices) => {
|
||||
const usd = prices.find(
|
||||
(p) =>
|
||||
p.currency === 'usd' &&
|
||||
p.symbol === w.symbol &&
|
||||
!p.address,
|
||||
)
|
||||
nativePrice = usd
|
||||
? { value: usd.value, currency: usd.currency }
|
||||
: null
|
||||
const map: Record<
|
||||
string,
|
||||
{ value: string; currency: string }
|
||||
> = {}
|
||||
for (const p of prices) {
|
||||
if (p.currency === 'usd' && p.address) {
|
||||
map[p.address.toLowerCase()] = {
|
||||
value: p.value,
|
||||
currency: p.currency,
|
||||
}
|
||||
}
|
||||
}
|
||||
tokenPrices = map
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (w.chainid) {
|
||||
electrobun.rpc?.request
|
||||
.fetchGasPrice({ chainid: w.chainid })
|
||||
.then((gp) => (gasPrice = gp))
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!initStarted) {
|
||||
initStarted = true
|
||||
w.init().then(async () => {
|
||||
if (w.accountType === 'multi') {
|
||||
await w.switchNetwork(networkId)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function isNative(tx: TxEntry): boolean {
|
||||
return !tx.contractAddress
|
||||
}
|
||||
|
||||
const balanceHistory = $derived.by(() => {
|
||||
const native = w.transactions.filter(isNative)
|
||||
const sorted = [...native].reverse()
|
||||
if (sorted.length === 0) return []
|
||||
let cum = 0n
|
||||
const addr = w.address.toLowerCase()
|
||||
const points: { date: Date; balance: number }[] = []
|
||||
for (const tx of sorted) {
|
||||
console.log(tx.value)
|
||||
const val = parseEther(tx.value)
|
||||
if (tx.from.toLowerCase() === addr) cum -= val
|
||||
else cum += val
|
||||
points.push({
|
||||
date: new Date(Number(tx.timeStamp) * 1000),
|
||||
balance: Number(formatEther(cum)),
|
||||
})
|
||||
}
|
||||
const cur = Number(w.balance)
|
||||
const offset = cur - Number(formatEther(cum))
|
||||
for (const p of points) p.balance += offset
|
||||
return points
|
||||
})
|
||||
|
||||
$inspect(balanceHistory)
|
||||
|
||||
let selectedRange = $state<'1D' | '1W' | '1M' | '1Y' | 'ALL'>('ALL')
|
||||
|
||||
const rangeCutoff = $derived(
|
||||
selectedRange === 'ALL'
|
||||
? 0
|
||||
: Date.now() -
|
||||
(
|
||||
{
|
||||
'1D': 86400000,
|
||||
'1W': 604800000,
|
||||
'1M': 2592000000,
|
||||
'1Y': 31536000000,
|
||||
} as const
|
||||
)[selectedRange],
|
||||
)
|
||||
|
||||
const chartData = $derived(
|
||||
selectedRange === 'ALL'
|
||||
? balanceHistory
|
||||
: balanceHistory.filter((p) => p.date.getTime() >= rangeCutoff),
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if !w.ready}
|
||||
<div
|
||||
class="mx-auto mt-16 max-w-md text-center text-muted-foreground text-sm">
|
||||
Loading...
|
||||
</div>
|
||||
{:else if w.wallets.length === 0 || showCreateForm}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{#if showCreateForm}
|
||||
{@const cur = w.currentWallet}
|
||||
{cur ? `Add Another Wallet` : `Import / Create Wallet`}
|
||||
{:else}
|
||||
Import / Create Wallet
|
||||
{/if}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Name your wallet and enter your seed phrase to save it
|
||||
securely, or generate a new wallet
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Wallet name (e.g. Main, Savings)"
|
||||
class="flex h-8 w-full rounded-md border border-input bg-transparent px-2 py-1 font-mono text-xs"
|
||||
bind:value={inputWalletName} />
|
||||
<Textarea
|
||||
placeholder="Enter your 12 or 24 word seed phrase"
|
||||
bind:value={inputSeed} />
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
inputSeed = generateMnemonic(english)
|
||||
}}
|
||||
class="w-full mt-1">
|
||||
Generate Random Seed
|
||||
</Button>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Set a password (optional)"
|
||||
class="flex h-8 w-full rounded-md border border-input bg-transparent px-2 py-1 font-mono text-xs"
|
||||
bind:value={inputPassword} />
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
onclick={() =>
|
||||
w.createWallet(
|
||||
inputWalletName,
|
||||
inputSeed,
|
||||
inputPassword || undefined,
|
||||
)}
|
||||
disabled={w.loading ||
|
||||
!inputSeed.trim() ||
|
||||
!inputWalletName.trim()}>
|
||||
{w.loading ? 'Saving...' : 'Save Wallet'}
|
||||
</Button>
|
||||
{#if showCreateForm}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (showCreateForm = false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if w.error}
|
||||
<p class="mt-3 text-red-500">{w.error}</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if !w.currentWalletId}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Wallets</CardTitle>
|
||||
<CardDescription>Select a wallet to use</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each w.wallets as wal}
|
||||
<button
|
||||
onclick={async () => {
|
||||
if (wal.hasPassword) {
|
||||
await w.selectWallet(wal.id)
|
||||
} else {
|
||||
await w.selectAndUnlockWallet(wal.id)
|
||||
}
|
||||
}}
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md border border-input p-3 text-left hover:bg-muted transition-colors">
|
||||
<WalletIcon
|
||||
size={20}
|
||||
class="shrink-0 text-muted-foreground" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm">{wal.name}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{new Date(wal.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if wal.hasPassword}
|
||||
<LockKeyhole
|
||||
size={14}
|
||||
class="text-muted-foreground" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (showCreateForm = true)}
|
||||
class="mt-2">
|
||||
<Plus size={16} />
|
||||
Add Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if w.isLocked}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{w.currentWallet?.name ?? 'Wallet'} Locked
|
||||
</CardTitle>
|
||||
<CardDescription>Unlock to access your wallet</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if w.biometricAvailable}
|
||||
<Button
|
||||
onclick={() => w.unlockWithBiometrics()}
|
||||
disabled={w.loading}>
|
||||
<Fingerprint size={16} />
|
||||
{w.loading ? 'Unlocking...' : 'Unlock with Touch ID'}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if w.currentPasswordHash}
|
||||
<div class="flex gap-2 items-end">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
class="flex h-8 w-full rounded-md border border-input bg-transparent px-2 py-1 font-mono text-xs"
|
||||
bind:value={inputUnlockPassword} />
|
||||
<Button
|
||||
onclick={async () => {
|
||||
const ok = await w.unlockWithPassword(
|
||||
inputUnlockPassword,
|
||||
)
|
||||
if (!ok) w.error = 'Wrong password'
|
||||
inputUnlockPassword = ''
|
||||
}}
|
||||
disabled={w.loading || !inputUnlockPassword}
|
||||
size="sm">
|
||||
{w.loading ? '...' : 'Unlock'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<Button variant="outline" onclick={() => w.clearSelection()}>
|
||||
Pick another wallet
|
||||
</Button>
|
||||
</div>
|
||||
{#if w.error}
|
||||
<p class="mt-3 text-red-500">{w.error}</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
<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={() => navigate(navTarget(net.id))}>
|
||||
<img
|
||||
src={nativeIcon(net.id)}
|
||||
alt=""
|
||||
class="-ml-0.5 mr-1 inline-block size-3.5 rounded-full align-middle"
|
||||
onerror={(e) =>
|
||||
((e.target as HTMLElement).style.display = 'none')} />
|
||||
{net.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{w.currentWallet?.name ?? 'Wallet'}
|
||||
</CardTitle>
|
||||
<CardDescription>Your wallet is unlocked</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if w.address}
|
||||
<div class="mb-4 space-y-1">
|
||||
<p class="font-medium text-xs">Address</p>
|
||||
<div class="flex items-start gap-2">
|
||||
<p class="font-mono text-xs break-all flex-1">
|
||||
{w.address}
|
||||
</p>
|
||||
<button
|
||||
onclick={() =>
|
||||
electrobun.rpc?.request.openExternal({
|
||||
url: w.explorerAddressUrl,
|
||||
})}
|
||||
class="shrink-0 cursor-pointer rounded-md border p-1.5 text-muted-foreground hover:bg-muted transition-colors">
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
{#if qrDataUrl}
|
||||
<Dialog.Root bind:open={qrDialogOpen}>
|
||||
<Dialog.Trigger>
|
||||
<button
|
||||
class="shrink-0 cursor-pointer rounded-md border p-1.5 text-muted-foreground hover:bg-muted transition-colors">
|
||||
<QrCodeIcon size={16} />
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 py-4">
|
||||
<div class="relative">
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="QR code"
|
||||
class="size-52 rounded-md" />
|
||||
<img
|
||||
src={nativeIcon(w.network)}
|
||||
alt=""
|
||||
class="absolute left-1/2 top-1/2 size-7 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white p-0.5" />
|
||||
</div>
|
||||
<p
|
||||
class="font-mono text-xs break-all text-center max-w-64">
|
||||
{w.address}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
{/if}
|
||||
</div>
|
||||
<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>
|
||||
{#if nativePrice}
|
||||
{@const balVal =
|
||||
Number(w.balance) * Number(nativePrice.value)}
|
||||
<p class="font-mono text-xs text-muted-foreground">
|
||||
${balVal.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})} @ ${Number(nativePrice.value).toLocaleString(
|
||||
undefined,
|
||||
{
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
},
|
||||
)}/{w.symbol}
|
||||
</p>
|
||||
{/if}
|
||||
{#if gasPrice}
|
||||
{@const gasNative = (Number(gasPrice) * 21000) / 1e9}
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">
|
||||
Gas: {Number(gasPrice).toFixed(1)} Gwei ({gasNative.toLocaleString(
|
||||
undefined,
|
||||
{
|
||||
minimumFractionDigits: 6,
|
||||
maximumFractionDigits: 6,
|
||||
},
|
||||
)}
|
||||
{w.symbol})
|
||||
{#if nativePrice}
|
||||
${(
|
||||
gasNative * Number(nativePrice.value)
|
||||
).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 4,
|
||||
})}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mb-4 space-y-1">
|
||||
<p class="font-medium text-xs">Tokens</p>
|
||||
{#each w.tokenBalances as t}
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
{#if t.contractAddress && tokenPrices[t.contractAddress.toLowerCase()]}
|
||||
{@const val =
|
||||
tokenPrices[t.contractAddress.toLowerCase()]}
|
||||
{@const balVal =
|
||||
Number(t.balance) * Number(val.value)}
|
||||
<p
|
||||
class="font-mono text-xs text-muted-foreground -mt-0.5 ml-6">
|
||||
${balVal.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})} @ ${Number(val.value).toLocaleString(
|
||||
undefined,
|
||||
{
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
},
|
||||
)}/{t.symbol}
|
||||
</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if w.loadingBalance && w.address}
|
||||
<div class="flex justify-center py-4">
|
||||
<Loader />
|
||||
</div>
|
||||
{/if}
|
||||
{#if w.error}
|
||||
<p class="mb-3 text-red-500">{w.error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => navigate('/multicoin/send')}>
|
||||
Send
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => navigate('/settings')}>
|
||||
<SettingsIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={async () => {
|
||||
await w.logout()
|
||||
navigate('/')
|
||||
}}>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if balanceHistory.length > 1}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle>Balance</CardTitle>
|
||||
<div class="flex gap-1">
|
||||
{#each ['1D', '1W', '1M', '1Y', 'ALL'] as const as range}
|
||||
<button
|
||||
onclick={() => (selectedRange = range)}
|
||||
class="rounded px-1.5 py-0.5 text-xs font-medium transition-colors {selectedRange ===
|
||||
range
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'}">
|
||||
{range}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if chartData.length > 1}
|
||||
<Chart.Container
|
||||
config={{
|
||||
balance: { label: w.symbol, color: 'var(--chart-1)' },
|
||||
}}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
x="date"
|
||||
xScale={scaleUtc()}
|
||||
axis="x"
|
||||
points
|
||||
series={[
|
||||
{
|
||||
key: 'balance',
|
||||
label: w.symbol,
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
]}
|
||||
yDomain={[
|
||||
chartData.reduce((a, b) =>
|
||||
a.balance < b.balance ? a : b,
|
||||
).balance,
|
||||
chartData.reduce((a, b) =>
|
||||
a.balance > b.balance ? a : b,
|
||||
).balance,
|
||||
]}
|
||||
props={{
|
||||
spline: {
|
||||
curve: curveLinear,
|
||||
motion: 'tween',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
yAxis: {},
|
||||
xAxis: {
|
||||
format: (v: Date) =>
|
||||
v.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
},
|
||||
highlight: { points: { r: 4 } },
|
||||
}}>
|
||||
{#snippet tooltip()}
|
||||
<Chart.Tooltip
|
||||
labelFormatter={(value: Date) => {
|
||||
return value.toLocaleDateString()
|
||||
}}
|
||||
valueFormatter={(value: Number) => {
|
||||
return Number(value).toFixed(6)
|
||||
}} />
|
||||
{/snippet}
|
||||
</LineChart>
|
||||
</Chart.Container>
|
||||
{:else}
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
No transactions in this time range.
|
||||
</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if w.loadingTransactions}
|
||||
<div class="flex justify-center py-4">
|
||||
<Loader />
|
||||
</div>
|
||||
{:else 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.filter((tx) => tx.tokenDecimal !== '0') as tx}
|
||||
<button
|
||||
onclick={() =>
|
||||
navigate('/tx/:chainid/:hash', {
|
||||
params: {
|
||||
chainid: w.chainid,
|
||||
hash: tx.hash,
|
||||
},
|
||||
})}
|
||||
class="flex w-full cursor-pointer items-start gap-2 rounded-md bg-muted px-3 py-2 text-xs text-left hover:bg-muted/80 transition-colors">
|
||||
<div
|
||||
class="shrink-0 mt-0.5 {txAction(tx, w.address) ===
|
||||
'Swap'
|
||||
? 'text-yellow-500'
|
||||
: tx.from.toLowerCase() ===
|
||||
w.address.toLowerCase()
|
||||
? 'text-muted-foreground'
|
||||
: 'text-green-500'}">
|
||||
{#if txAction(tx, w.address) === 'Swap'}
|
||||
<ArrowLeftRight size={16} />
|
||||
{:else if tx.from.toLowerCase() === w.address.toLowerCase()}
|
||||
<ArrowUp size={16} />
|
||||
{:else}
|
||||
<ArrowDown size={16} />
|
||||
{/if}
|
||||
</div>
|
||||
<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 ?? w.symbol}
|
||||
{: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>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -9,10 +9,11 @@ export type {
|
||||
MoneroAccountEntry,
|
||||
MoneroSendResult,
|
||||
MoneroTransferDetails,
|
||||
TxEntry,
|
||||
TransactionDetails,
|
||||
TokenBalanceResult,
|
||||
EvmWalletInfo,
|
||||
XrpWalletInfo,
|
||||
XrpBalance,
|
||||
XrpTxEntry,
|
||||
XrpTxDetails,
|
||||
XrpSendResult,
|
||||
} from './rpc-schema'
|
||||
|
||||
const rpc = Electroview.defineRPC<RPC>({
|
||||
|
||||
@@ -79,67 +79,50 @@ export type MoneroFeeEstimate = {
|
||||
estimatedFee: string
|
||||
}
|
||||
|
||||
export type TxEntry = {
|
||||
hash: string
|
||||
timeStamp: string
|
||||
from: string
|
||||
to: string
|
||||
value: string
|
||||
tokenSymbol?: string
|
||||
tokenDecimal?: string
|
||||
contractAddress?: string
|
||||
logo?: string
|
||||
pairedValue?: string
|
||||
pairedSymbol?: string
|
||||
pairedDecimals?: string
|
||||
pairedContractAddress?: string
|
||||
pairedLogo?: string
|
||||
}
|
||||
|
||||
export type TransactionDetails = {
|
||||
hash: string
|
||||
from: string
|
||||
to: string | null
|
||||
value: string
|
||||
blockNumber: string | null
|
||||
fee: string
|
||||
gasPrice: string
|
||||
status: 'success' | 'reverted'
|
||||
type: string
|
||||
nonce: number
|
||||
input: string
|
||||
pairedValue?: string
|
||||
pairedSymbol?: string
|
||||
pairedDecimals?: string
|
||||
pairedContractAddress?: string
|
||||
pairedLogo?: string
|
||||
}
|
||||
|
||||
export type TokenBalanceResult = {
|
||||
symbol: string
|
||||
decimals: number
|
||||
balance: string
|
||||
contractAddress: string
|
||||
logo?: string
|
||||
}
|
||||
|
||||
export type TokenPriceEntry = {
|
||||
symbol: string
|
||||
currency: string
|
||||
value: string
|
||||
lastUpdatedAt: string
|
||||
network?: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
export type EvmWalletInfo = {
|
||||
export type XrpWalletInfo = {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
hasPassword: boolean
|
||||
vaultKey: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type XrpBalance = {
|
||||
address: string
|
||||
balance: string
|
||||
funded: boolean
|
||||
}
|
||||
|
||||
export type XrpTxEntry = {
|
||||
hash: string
|
||||
amount: string
|
||||
timestamp: string
|
||||
direction: 'in' | 'out'
|
||||
from: string
|
||||
to: string
|
||||
destinationTag?: number
|
||||
fee: string
|
||||
confirmed: boolean
|
||||
}
|
||||
|
||||
export type XrpTxDetails = {
|
||||
hash: string
|
||||
from: string
|
||||
to: string
|
||||
amount: string
|
||||
fee: string
|
||||
timestamp: string
|
||||
ledgerIndex: number
|
||||
destinationTag?: number
|
||||
confirmed: boolean
|
||||
}
|
||||
|
||||
export type XrpSendResult = {
|
||||
hash: string
|
||||
fee: string
|
||||
}
|
||||
|
||||
export type GeneralRpcRequests = {
|
||||
resetApp: { params: {}; response: boolean }
|
||||
biometricCanAuth: { params: {}; response: boolean }
|
||||
@@ -151,23 +134,6 @@ export type GeneralRpcRequests = {
|
||||
logToFile: { params: { message: string }; response: void }
|
||||
}
|
||||
|
||||
export type EvmRpcRequests = {
|
||||
fetchTxHistory: { params: { address: string; chainid: string }; response: TxEntry[] }
|
||||
fetchCachedTxHistory: { params: { address: string; chainid: string }; response: TxEntry[] }
|
||||
syncTxHistory: { params: { address: string; chainid: string }; response: void }
|
||||
flushTxCache: { params: {}; response: void }
|
||||
setAutoSync: { params: { address: string; chainid: string } | null; response: void }
|
||||
fetchTokenBalances: { params: { address: string; chainid: string }; response: TokenBalanceResult[] }
|
||||
fetchTransactionDetails: { params: { hash: string; chainid: string; address?: string }; response: TransactionDetails | null }
|
||||
fetchTokenPrices: { params: { symbols?: string[]; addresses?: { network: string; address: string }[] }; response: TokenPriceEntry[] }
|
||||
fetchGasPrice: { params: { chainid: string }; response: string | null }
|
||||
evmCreateWallet: { params: { name: string; phrase: string; passwordHash?: string }; response: { id: string; name: string; createdAt: string } }
|
||||
evmListWallets: { params: {}; response: EvmWalletInfo[] }
|
||||
evmGetSeed: { params: { vaultKey: string }; response: string }
|
||||
evmDeleteWallet: { params: { id: string }; response: void }
|
||||
evmSendTransfer: { params: { seed: string; to: string; amount: string; chainid: string; contractAddress?: string; tokenDecimals?: number }; response: string }
|
||||
}
|
||||
|
||||
export type MoneroRpcRequests = {
|
||||
moneroBinaryStatus: { params: {}; response: MoneroBinaryStatus }
|
||||
moneroDownloadBinary: { params: {}; response: void }
|
||||
@@ -190,15 +156,27 @@ export type MoneroRpcRequests = {
|
||||
fetchMoneroPrice: { params: {}; response: { usd: string } | null }
|
||||
}
|
||||
|
||||
export type XrpRpcRequests = {
|
||||
xrpListWallets: { params: {}; response: XrpWalletInfo[] }
|
||||
xrpCreateWallet: { params: { name: string; hasPassword?: boolean }; response: { id: string; address: string; seed: string; createdAt: string } }
|
||||
xrpImportWallet: { params: { name: string; secret: string; hasPassword?: boolean }; response: { id: string; address: string; createdAt: string } }
|
||||
xrpDeleteWallet: { params: { id: string }; response: void }
|
||||
xrpGetSeed: { params: { vaultKey: string }; response: string }
|
||||
xrpGetBalance: { params: { address: string }; response: XrpBalance }
|
||||
xrpGetTransactions: { params: { address: string }; response: XrpTxEntry[] }
|
||||
xrpGetTxDetails: { params: { hash: string }; response: XrpTxDetails | null }
|
||||
xrpSend: { params: { secret: string; to: string; amount: string; destinationTag?: number }; response: XrpSendResult }
|
||||
xrpGetFee: { params: {}; response: { fee: string } | null }
|
||||
fetchXrpPrice: { params: {}; response: { usd: string } | null }
|
||||
}
|
||||
|
||||
export type RPC = {
|
||||
bun: {
|
||||
requests: GeneralRpcRequests & EvmRpcRequests & MoneroRpcRequests
|
||||
requests: GeneralRpcRequests & MoneroRpcRequests & XrpRpcRequests
|
||||
messages: {}
|
||||
}
|
||||
webview: {
|
||||
requests: {}
|
||||
messages: {
|
||||
transfersUpdate: { count: number; chainid: string; address: string }
|
||||
}
|
||||
messages: {}
|
||||
}
|
||||
}
|
||||
|
||||
+103
-242
@@ -1,73 +1,37 @@
|
||||
import { mnemonicToAccount } from 'viem/accounts'
|
||||
import { createPublicClient, http, formatEther } from 'viem'
|
||||
import {
|
||||
electrobun,
|
||||
type TxEntry,
|
||||
type EvmWalletInfo,
|
||||
type XrpTxEntry,
|
||||
type XrpWalletInfo,
|
||||
} from '$lib/electrobun'
|
||||
import { tryCatch } from '@koins/utils'
|
||||
import { moneroWallet } from './monero-wallet.svelte.js'
|
||||
|
||||
export const networks = [
|
||||
{
|
||||
id: 'bsc',
|
||||
name: 'BNB Smart Chain',
|
||||
rpc: 'https://bsc-dataseed.binance.org',
|
||||
symbol: 'BNB',
|
||||
chainid: '56',
|
||||
explorerUrl: 'https://bscscan.com/tx/',
|
||||
},
|
||||
{
|
||||
id: 'polygon',
|
||||
name: 'Polygon',
|
||||
rpc: 'https://polygon-bor.publicnode.com',
|
||||
symbol: 'POL',
|
||||
chainid: '137',
|
||||
explorerUrl: 'https://polygonscan.com/tx/',
|
||||
},
|
||||
{
|
||||
id: 'eth',
|
||||
name: 'Ethereum',
|
||||
rpc: 'https://ethereum-rpc.publicnode.com',
|
||||
symbol: 'ETH',
|
||||
chainid: '1',
|
||||
explorerUrl: 'https://etherscan.io/tx/',
|
||||
},
|
||||
] as const
|
||||
export const xrpNetwork = {
|
||||
id: 'xrp',
|
||||
name: 'XRP Ledger',
|
||||
symbol: 'XRP',
|
||||
explorerUrl: 'https://xrpscan.com/tx/',
|
||||
explorerAddressUrl: 'https://xrpscan.com/account/',
|
||||
} as const
|
||||
|
||||
export type NetworkId = (typeof networks)[number]['id']
|
||||
|
||||
export type TokenBalance = {
|
||||
symbol: string
|
||||
decimals: number
|
||||
balance: string
|
||||
contractAddress: string
|
||||
logo?: string
|
||||
}
|
||||
|
||||
export type AccountType = 'multi' | 'monero'
|
||||
|
||||
export const EvmWallet = () => {
|
||||
let accountType = $state<AccountType | null>(null)
|
||||
export const XrpWallet = () => {
|
||||
let accountType = $state<'xrp' | null>(null)
|
||||
let ready = $state(false)
|
||||
let biometricAvailable = $state(false)
|
||||
let seed = $state('')
|
||||
let address = $state('')
|
||||
let balance = $state('0')
|
||||
let tokenBalances = $state<TokenBalance[]>([])
|
||||
let network = $state<NetworkId>('eth')
|
||||
let apiKey = $state('')
|
||||
let transactions = $state<TxEntry[]>([])
|
||||
let funded = $state(true)
|
||||
let price = $state<string | null>(null)
|
||||
let fee = $state<string | null>(null)
|
||||
let transactions = $state<XrpTxEntry[]>([])
|
||||
let loadingBalance = $state(false)
|
||||
let loadingTransactions = $state(false)
|
||||
let loading = $state(false)
|
||||
let error = $state('')
|
||||
let wallets = $state<EvmWalletInfo[]>([])
|
||||
let wallets = $state<XrpWalletInfo[]>([])
|
||||
let currentWalletId = $state<string | null>(null)
|
||||
let currentPasswordHash = $state<string | null>(null)
|
||||
|
||||
const net = () => networks.find((n) => n.id === network)!
|
||||
|
||||
async function hashPassword(
|
||||
password: string,
|
||||
salt?: string,
|
||||
@@ -111,25 +75,8 @@ export const EvmWallet = () => {
|
||||
|
||||
const init = async () => {
|
||||
if (!electrobun.rpc) return
|
||||
|
||||
electrobun.rpc.addMessageListener(
|
||||
'transfersUpdate',
|
||||
(payload: {
|
||||
chainid: string
|
||||
address: string
|
||||
count: number
|
||||
}) => {
|
||||
if (
|
||||
payload.address === address &&
|
||||
payload.chainid === net().chainid
|
||||
) {
|
||||
fetchTxHistory()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const [walletList] = await tryCatch(
|
||||
electrobun.rpc.request.evmListWallets({}),
|
||||
electrobun.rpc.request.xrpListWallets({}),
|
||||
)
|
||||
if (walletList) {
|
||||
wallets = walletList
|
||||
@@ -137,28 +84,13 @@ export const EvmWallet = () => {
|
||||
currentWalletId = walletList[0].id
|
||||
}
|
||||
}
|
||||
|
||||
const [key] = await tryCatch(
|
||||
electrobun.rpc.request.getSecret({
|
||||
service: 'koins',
|
||||
name: 'alchemy_key',
|
||||
}),
|
||||
)
|
||||
if (key) apiKey = key
|
||||
|
||||
// await moneroWallet.checkStatus()
|
||||
// if (moneroWallet.installed && !moneroWallet.running && !moneroWallet.downloading) {
|
||||
// await moneroWallet.start()
|
||||
// await moneroWallet.checkStatus()
|
||||
// }
|
||||
|
||||
await checkBiometric()
|
||||
ready = true
|
||||
}
|
||||
|
||||
const login = async () => {
|
||||
accountType = 'multi'
|
||||
if (seed) await switchNetwork(network)
|
||||
accountType = 'xrp'
|
||||
if (seed) await refresh()
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
@@ -170,27 +102,25 @@ export const EvmWallet = () => {
|
||||
seed = ''
|
||||
address = ''
|
||||
balance = '0'
|
||||
tokenBalances = []
|
||||
funded = true
|
||||
transactions = []
|
||||
error = ''
|
||||
currentPasswordHash = null
|
||||
electrobun.rpc?.request.setAutoSync(null)
|
||||
}
|
||||
|
||||
const unlockWallet = async (walletId: string) => {
|
||||
const loadSeedForWallet = async (walletId: string) => {
|
||||
if (!electrobun.rpc) return false
|
||||
const wallet = wallets.find((w) => w.id === walletId)
|
||||
if (!wallet) return false
|
||||
currentWalletId = walletId
|
||||
const [vaultSeed] = await tryCatch(
|
||||
electrobun.rpc.request.evmGetSeed({
|
||||
electrobun.rpc.request.xrpGetSeed({
|
||||
vaultKey: wallet.vaultKey,
|
||||
}),
|
||||
)
|
||||
if (!vaultSeed) return false
|
||||
seed = vaultSeed
|
||||
address = wallet.address
|
||||
await refresh()
|
||||
await setAutoSync()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -203,8 +133,7 @@ export const EvmWallet = () => {
|
||||
)
|
||||
if (!authed) return false
|
||||
if (!currentWalletId) return false
|
||||
await loadSeedForWallet(currentWalletId)
|
||||
return !!seed
|
||||
return loadSeedForWallet(currentWalletId)
|
||||
}
|
||||
|
||||
const unlockWithPassword = async (password: string) => {
|
||||
@@ -219,22 +148,6 @@ export const EvmWallet = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadSeedForWallet = async (walletId: string) => {
|
||||
if (!electrobun.rpc) return false
|
||||
const wallet = wallets.find((w) => w.id === walletId)
|
||||
if (!wallet) return false
|
||||
const [vaultSeed] = await tryCatch(
|
||||
electrobun.rpc.request.evmGetSeed({
|
||||
vaultKey: wallet.vaultKey,
|
||||
}),
|
||||
)
|
||||
if (!vaultSeed) return false
|
||||
seed = vaultSeed
|
||||
await refresh()
|
||||
await setAutoSync()
|
||||
return true
|
||||
}
|
||||
|
||||
const selectWallet = async (walletId: string) => {
|
||||
currentWalletId = walletId
|
||||
clearWallet()
|
||||
@@ -244,7 +157,7 @@ export const EvmWallet = () => {
|
||||
const [ph] = await tryCatch(
|
||||
electrobun.rpc.request.getSecret({
|
||||
service: 'koins',
|
||||
name: `evm_auth_${walletId}`,
|
||||
name: `xrp_auth_${walletId}`,
|
||||
}),
|
||||
)
|
||||
if (ph) {
|
||||
@@ -260,61 +173,89 @@ export const EvmWallet = () => {
|
||||
if (!currentPasswordHash && biometricAvailable) {
|
||||
return unlockWithBiometrics()
|
||||
}
|
||||
if (!currentPasswordHash) {
|
||||
return loadSeedForWallet(walletId)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const createWallet = async (
|
||||
const saveWallet = async (
|
||||
kind: 'create' | 'import',
|
||||
name: string,
|
||||
phrase: string,
|
||||
secret: string | undefined,
|
||||
password?: string,
|
||||
) => {
|
||||
loading = true
|
||||
error = ''
|
||||
try {
|
||||
mnemonicToAccount(phrase.trim())
|
||||
if (!electrobun.rpc) throw new Error('RPC not available')
|
||||
let passwordHash: string | undefined
|
||||
if (password) {
|
||||
const ph = await hashPassword(password)
|
||||
passwordHash = JSON.stringify(ph)
|
||||
}
|
||||
const [result, resultError] = await tryCatch(
|
||||
electrobun.rpc!.request.evmCreateWallet({
|
||||
name,
|
||||
phrase: phrase.trim(),
|
||||
passwordHash,
|
||||
}),
|
||||
)
|
||||
if (resultError) throw resultError
|
||||
const result: {
|
||||
id: string
|
||||
address: string
|
||||
createdAt: string
|
||||
seed?: string
|
||||
} =
|
||||
kind === 'create'
|
||||
? await electrobun.rpc.request.xrpCreateWallet({
|
||||
name,
|
||||
hasPassword: !!passwordHash,
|
||||
})
|
||||
: await electrobun.rpc.request.xrpImportWallet({
|
||||
name,
|
||||
secret: secret!,
|
||||
hasPassword: !!passwordHash,
|
||||
})
|
||||
|
||||
if (passwordHash) {
|
||||
await electrobun.rpc?.request.setSecret({
|
||||
await electrobun.rpc.request.setSecret({
|
||||
service: 'koins',
|
||||
name: `evm_auth_${result.id}`,
|
||||
name: `xrp_auth_${result.id}`,
|
||||
value: passwordHash,
|
||||
})
|
||||
}
|
||||
|
||||
const newWallet: EvmWalletInfo = {
|
||||
const newWallet: XrpWalletInfo = {
|
||||
id: result.id,
|
||||
name,
|
||||
address: result.address,
|
||||
hasPassword: !!passwordHash,
|
||||
vaultKey: `evm_seed_${result.id}`,
|
||||
vaultKey: `xrp_seed_${result.id}`,
|
||||
createdAt: result.createdAt,
|
||||
}
|
||||
wallets = [...wallets, newWallet]
|
||||
currentWalletId = result.id
|
||||
if (passwordHash) currentPasswordHash = passwordHash
|
||||
seed = phrase.trim()
|
||||
seed = result.seed ?? secret!
|
||||
address = result.address
|
||||
await refresh()
|
||||
return result.seed
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Invalid seed phrase'
|
||||
error = e instanceof Error ? e.message : 'Failed to save wallet'
|
||||
throw e
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const createWallet = async (name: string, password?: string) => {
|
||||
return saveWallet('create', name, undefined, password)
|
||||
}
|
||||
|
||||
const importWallet = async (
|
||||
name: string,
|
||||
secret: string,
|
||||
password?: string,
|
||||
) => {
|
||||
return saveWallet('import', name, secret, password)
|
||||
}
|
||||
|
||||
const deleteWallet = async (walletId: string) => {
|
||||
await electrobun.rpc?.request.evmDeleteWallet({ id: walletId })
|
||||
await electrobun.rpc?.request.xrpDeleteWallet({ id: walletId })
|
||||
wallets = wallets.filter((w) => w.id !== walletId)
|
||||
if (currentWalletId === walletId) {
|
||||
clearWallet()
|
||||
@@ -327,103 +268,49 @@ export const EvmWallet = () => {
|
||||
currentWalletId = null
|
||||
}
|
||||
|
||||
const switchNetwork = async (id: NetworkId) => {
|
||||
network = id
|
||||
if (seed) await refresh()
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
if (!seed || !electrobun.rpc) return
|
||||
if (!address || !electrobun.rpc) return
|
||||
const rpc = electrobun.rpc
|
||||
loadingBalance = true
|
||||
loadingTransactions = true
|
||||
balance = '0'
|
||||
error = ''
|
||||
const account = mnemonicToAccount(seed)
|
||||
address = account.address
|
||||
const client = createPublicClient({ transport: http(net().rpc) })
|
||||
|
||||
syncTxHistory()
|
||||
;(async () => {
|
||||
const [nativeBal] = await tryCatch(
|
||||
client.getBalance({ address: account.address }),
|
||||
const [bal] = await tryCatch(
|
||||
rpc.request.xrpGetBalance({ address }),
|
||||
)
|
||||
if (nativeBal !== undefined) balance = formatEther(nativeBal)
|
||||
const [bals] = await tryCatch(
|
||||
electrobun.rpc.request.fetchTokenBalances({
|
||||
address,
|
||||
chainid: net().chainid,
|
||||
}),
|
||||
)
|
||||
if (bals) tokenBalances = bals
|
||||
if (bal) {
|
||||
balance = bal.balance
|
||||
funded = bal.funded
|
||||
}
|
||||
const [p] = await tryCatch(rpc.request.fetchXrpPrice({}))
|
||||
if (p) price = p.usd
|
||||
const [f] = await tryCatch(rpc.request.xrpGetFee({}))
|
||||
if (f) fee = f.fee
|
||||
loadingBalance = false
|
||||
})()
|
||||
;(async () => {
|
||||
await fetchTxHistory()
|
||||
const [txs] = await tryCatch(
|
||||
rpc.request.xrpGetTransactions({ address }),
|
||||
)
|
||||
if (txs) transactions = txs
|
||||
loadingTransactions = false
|
||||
})()
|
||||
}
|
||||
|
||||
const syncTxHistory = () => {
|
||||
if (!address || !apiKey || !electrobun.rpc) return
|
||||
electrobun.rpc.request.syncTxHistory({
|
||||
address,
|
||||
chainid: net().chainid,
|
||||
})
|
||||
}
|
||||
|
||||
const setAutoSync = async () => {
|
||||
if (!address || !electrobun.rpc) return
|
||||
electrobun.rpc.request.setAutoSync({
|
||||
address,
|
||||
chainid: net().chainid,
|
||||
})
|
||||
}
|
||||
|
||||
const flushTxCache = async () => {
|
||||
await electrobun.rpc?.request.flushTxCache({})
|
||||
await fetchTxHistory()
|
||||
}
|
||||
|
||||
const saveApiKey = async (key: string) => {
|
||||
await electrobun.rpc?.request.setSecret({
|
||||
service: 'koins',
|
||||
name: 'alchemy_key',
|
||||
value: key,
|
||||
})
|
||||
apiKey = key
|
||||
syncTxHistory()
|
||||
if (address) await fetchTxHistory()
|
||||
}
|
||||
|
||||
const fetchTxHistory = async () => {
|
||||
if (!address) {
|
||||
transactions = []
|
||||
return
|
||||
}
|
||||
const txs = await electrobun.rpc?.request.fetchCachedTxHistory({
|
||||
address,
|
||||
chainid: net().chainid,
|
||||
})
|
||||
transactions = txs ?? []
|
||||
}
|
||||
|
||||
const send = async (
|
||||
to: string,
|
||||
amount: string,
|
||||
contractAddress?: string,
|
||||
tokenDecimals?: number,
|
||||
destinationTag?: number,
|
||||
) => {
|
||||
if (!electrobun.rpc || !seed) throw new Error('Wallet not unlocked')
|
||||
const hash = await electrobun.rpc.request.evmSendTransfer({
|
||||
seed,
|
||||
const result = await electrobun.rpc.request.xrpSend({
|
||||
secret: seed,
|
||||
to,
|
||||
amount,
|
||||
chainid: net().chainid,
|
||||
contractAddress,
|
||||
tokenDecimals,
|
||||
destinationTag,
|
||||
})
|
||||
await refresh()
|
||||
return hash
|
||||
return result
|
||||
}
|
||||
|
||||
const lock = () => {
|
||||
@@ -432,7 +319,6 @@ export const EvmWallet = () => {
|
||||
|
||||
const reset = () => {
|
||||
clearWallet()
|
||||
apiKey = ''
|
||||
wallets = []
|
||||
currentWalletId = null
|
||||
}
|
||||
@@ -464,34 +350,14 @@ export const EvmWallet = () => {
|
||||
get balance() {
|
||||
return balance
|
||||
},
|
||||
get tokenBalances() {
|
||||
return tokenBalances
|
||||
get funded() {
|
||||
return funded
|
||||
},
|
||||
get network() {
|
||||
return network
|
||||
get price() {
|
||||
return price
|
||||
},
|
||||
get chainid() {
|
||||
return net().chainid
|
||||
},
|
||||
get networkName() {
|
||||
return net().name
|
||||
},
|
||||
get symbol() {
|
||||
return net().symbol
|
||||
},
|
||||
get explorerUrl() {
|
||||
return net().explorerUrl
|
||||
},
|
||||
get explorerAddressUrl() {
|
||||
return address
|
||||
? `${net().explorerUrl.replace('/tx/', '/address/')}${address}`
|
||||
: ''
|
||||
},
|
||||
get networks() {
|
||||
return networks
|
||||
},
|
||||
get apiKey() {
|
||||
return apiKey
|
||||
get fee() {
|
||||
return fee
|
||||
},
|
||||
get transactions() {
|
||||
return transactions
|
||||
@@ -526,8 +392,8 @@ export const EvmWallet = () => {
|
||||
get currentPasswordHash() {
|
||||
return currentPasswordHash
|
||||
},
|
||||
set apiKey(v: string) {
|
||||
apiKey = v
|
||||
get explorerAddressUrl() {
|
||||
return address ? `${xrpNetwork.explorerAddressUrl}${address}` : ''
|
||||
},
|
||||
set error(v: string) {
|
||||
error = v
|
||||
@@ -537,9 +403,7 @@ export const EvmWallet = () => {
|
||||
logout,
|
||||
refresh,
|
||||
createWallet,
|
||||
switchNetwork,
|
||||
saveApiKey,
|
||||
fetchTxHistory,
|
||||
importWallet,
|
||||
lock,
|
||||
reset,
|
||||
resetApp,
|
||||
@@ -548,14 +412,11 @@ export const EvmWallet = () => {
|
||||
hashPassword,
|
||||
selectWallet,
|
||||
selectAndUnlockWallet,
|
||||
unlockWallet,
|
||||
loadSeedForWallet,
|
||||
deleteWallet,
|
||||
clearSelection,
|
||||
send,
|
||||
syncTxHistory,
|
||||
setAutoSync,
|
||||
flushTxCache,
|
||||
}
|
||||
}
|
||||
|
||||
export const evmWallet = EvmWallet()
|
||||
export const xrpWallet = XrpWallet()
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { evmWallet as wallet } from '$lib/states/evm-wallet.svelte.js'
|
||||
import { moneroWallet } from '$lib/states/monero-wallet.svelte.js'
|
||||
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
|
||||
import { Button } from '$lib/components/ui/button/index.js'
|
||||
import {
|
||||
Card,
|
||||
@@ -13,14 +13,20 @@
|
||||
import { onMount } from 'svelte'
|
||||
import { frontendLog, frontendLogError } from '$lib/electrobun.js'
|
||||
|
||||
const w = wallet
|
||||
|
||||
let feError = $state<string | null>(null)
|
||||
|
||||
onMount(() => {
|
||||
frontendLog('index.svelte mounted')
|
||||
w.init().catch((e) => frontendLogError('w.init() failed', e))
|
||||
moneroWallet.init().catch((e) => frontendLogError('moneroWallet.init() failed', e))
|
||||
xrpWallet.init().catch((e) => frontendLogError('xrpWallet.init() failed', e))
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (moneroWallet.accountType === 'monero') {
|
||||
navigate('/monero')
|
||||
} else if (xrpWallet.accountType === 'xrp') {
|
||||
navigate('/xrp')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
@@ -56,61 +62,42 @@
|
||||
{/if}
|
||||
|
||||
<div class="mx-auto mt-24 max-w-sm">
|
||||
{#if !w.ready}
|
||||
<p class="text-center text-muted-foreground text-sm">
|
||||
Loading...
|
||||
</p>
|
||||
{:else if w.accountType}
|
||||
{#if w.accountType === 'multi'}
|
||||
{navigate('/multicoin')}
|
||||
{:else}
|
||||
{navigate('/monero')}
|
||||
{/if}
|
||||
{:else}
|
||||
<Card>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle>Welcome</CardTitle>
|
||||
<CardDescription>
|
||||
Choose an account type to get started
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-3">
|
||||
<Button
|
||||
onclick={async () => {
|
||||
await w.login()
|
||||
navigate('/multicoin')
|
||||
}}
|
||||
class="w-full">
|
||||
<span class="-ml-1 mr-2 flex items-center">
|
||||
<img
|
||||
src="/icons/ethereum.png"
|
||||
alt=""
|
||||
class="relative z-30 size-5 rounded-full ring-2 ring-accent" />
|
||||
<img
|
||||
src="/icons/binance.png"
|
||||
alt=""
|
||||
class="-ml-1.5 relative z-20 size-5 rounded-full ring-2 ring-accent" />
|
||||
<img
|
||||
src="/icons/polygon.png"
|
||||
alt=""
|
||||
class="-ml-1.5 relative z-10 size-5 rounded-full ring-2 ring-accent" />
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
onclick={async () => {
|
||||
await moneroWallet.login()
|
||||
navigate('/monero')
|
||||
}}
|
||||
variant="outline"
|
||||
class="w-full">
|
||||
<img
|
||||
src="/icons/monero.png"
|
||||
alt=""
|
||||
class="-ml-1 mr-1.5 inline-block size-5 rounded-full align-middle" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
<Card>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle>Welcome</CardTitle>
|
||||
<CardDescription>
|
||||
Choose an account type to get started
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-3">
|
||||
<Button
|
||||
onclick={async () => {
|
||||
await moneroWallet.login()
|
||||
navigate('/monero')
|
||||
}}
|
||||
variant="outline"
|
||||
class="w-full">
|
||||
<img
|
||||
src="/icons/monero.png"
|
||||
alt=""
|
||||
class="-ml-1 mr-1.5 inline-block size-5 rounded-full align-middle" />
|
||||
Monero
|
||||
</Button>
|
||||
<Button
|
||||
onclick={async () => {
|
||||
await xrpWallet.login()
|
||||
navigate('/xrp')
|
||||
}}
|
||||
variant="outline"
|
||||
class="w-full">
|
||||
<img
|
||||
src="/icons/xrp.png"
|
||||
alt=""
|
||||
class="-ml-1 mr-1.5 inline-block size-5 rounded-full align-middle" />
|
||||
XRP
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
import EvmDashboard from '$lib/components/EvmDashboard.svelte'
|
||||
</script>
|
||||
|
||||
<div class="mx-auto mt-16 max-w-md">
|
||||
<EvmDashboard networkId="bsc" />
|
||||
</div>
|
||||
@@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
import EvmDashboard from '$lib/components/EvmDashboard.svelte'
|
||||
</script>
|
||||
|
||||
<div class="mx-auto mt-16 max-w-md">
|
||||
<EvmDashboard networkId="eth" />
|
||||
</div>
|
||||
@@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
import EvmDashboard from '$lib/components/EvmDashboard.svelte'
|
||||
</script>
|
||||
|
||||
<div class="mx-auto mt-16 max-w-md">
|
||||
<EvmDashboard networkId="polygon" />
|
||||
</div>
|
||||
@@ -1,220 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { evmWallet as wallet } from '$lib/states/evm-wallet.svelte.js'
|
||||
import { electrobun } from '$lib/electrobun.js'
|
||||
import { Button } from '$lib/components/ui/button/index.js'
|
||||
import { Input } from '$lib/components/ui/input/index.js'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '$lib/components/ui/select/index.js'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '$lib/components/ui/card/index.js'
|
||||
import { navigate } from 'sv-router/generated'
|
||||
import { parseEther, formatEther, parseGwei } from 'viem'
|
||||
|
||||
const w = wallet
|
||||
|
||||
type TokenOption = {
|
||||
symbol: string
|
||||
balance: string
|
||||
contractAddress: string
|
||||
decimals: number
|
||||
logo?: string
|
||||
}
|
||||
|
||||
const tokens = $derived<TokenOption[]>([
|
||||
{
|
||||
symbol: w.symbol,
|
||||
balance: w.balance,
|
||||
contractAddress: '',
|
||||
decimals: 18,
|
||||
},
|
||||
...w.tokenBalances.map((t) => ({
|
||||
symbol: t.symbol,
|
||||
balance: t.balance,
|
||||
contractAddress: t.contractAddress,
|
||||
decimals: t.decimals,
|
||||
logo: t.logo,
|
||||
})),
|
||||
])
|
||||
|
||||
let selectedSymbol = $state(w.symbol)
|
||||
let recipient = $state('')
|
||||
let amount = $state('')
|
||||
let sending = $state(false)
|
||||
let sentResult = $state<{ txHash: string } | null>(null)
|
||||
let sendError = $state('')
|
||||
let sendAll = $state(false)
|
||||
|
||||
const selectedToken = $derived(
|
||||
tokens.find((t) => t.symbol === selectedSymbol) ?? tokens[0],
|
||||
)
|
||||
|
||||
const exceedsBalance = $derived(
|
||||
amount !== '' &&
|
||||
selectedToken.balance !== '0' &&
|
||||
parseFloat(amount) > parseFloat(selectedToken.balance),
|
||||
)
|
||||
|
||||
const canSend = $derived(
|
||||
recipient.length === 42 &&
|
||||
recipient.startsWith('0x') &&
|
||||
!sending &&
|
||||
(sendAll || (amount !== '' && parseFloat(amount) > 0)),
|
||||
)
|
||||
|
||||
const handleSend = async () => {
|
||||
sending = true
|
||||
sendError = ''
|
||||
sentResult = null
|
||||
try {
|
||||
const isNative = !selectedToken.contractAddress
|
||||
let finalAmount = String(amount)
|
||||
if (sendAll && isNative) {
|
||||
const gasPriceGwei = await electrobun.rpc?.request.fetchGasPrice({ chainid: w.chainid })
|
||||
if (!gasPriceGwei) throw new Error('Could not fetch gas price')
|
||||
const gasWei = parseGwei(gasPriceGwei) * 21000n
|
||||
const balanceWei = parseEther(selectedToken.balance)
|
||||
const sendWei = balanceWei - gasWei
|
||||
if (sendWei <= 0n) throw new Error('Balance too low to cover gas')
|
||||
finalAmount = formatEther(sendWei)
|
||||
} else if (sendAll) {
|
||||
finalAmount = String(selectedToken.balance)
|
||||
}
|
||||
const txHash = await w.send(
|
||||
recipient,
|
||||
finalAmount,
|
||||
selectedToken.contractAddress || undefined,
|
||||
selectedToken.decimals,
|
||||
)
|
||||
sentResult = { txHash }
|
||||
recipient = ''
|
||||
amount = ''
|
||||
sendAll = false
|
||||
} catch (e) {
|
||||
sendError = e instanceof Error ? e.message : 'Send failed'
|
||||
} finally {
|
||||
sending = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto mt-16 max-w-md">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => navigate(w.network === 'eth' ? '/multicoin' : `/multicoin/${w.network}`)}
|
||||
class="mb-4">
|
||||
← Back
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Send</CardTitle>
|
||||
<CardDescription>
|
||||
Balance: {selectedToken.balance} {selectedToken.symbol}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}}
|
||||
class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium">Asset</label>
|
||||
<Select type="single" bind:value={selectedSymbol}>
|
||||
<SelectTrigger>
|
||||
{selectedToken.symbol}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each tokens as t}
|
||||
<SelectItem value={t.symbol}>
|
||||
{t.symbol} ({t.balance})
|
||||
</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium">Recipient Address</label>
|
||||
<Input
|
||||
placeholder="0x..."
|
||||
bind:value={recipient} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium">Amount ({selectedToken.symbol})</label>
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.000000000000000001"
|
||||
min="0"
|
||||
placeholder="0.0"
|
||||
bind:value={amount} />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => { amount = selectedToken.balance }}
|
||||
class="mt-0 shrink-0">
|
||||
All
|
||||
</Button>
|
||||
</div>
|
||||
{#if exceedsBalance}
|
||||
<p class="text-xs text-red-500">
|
||||
Amount exceeds balance
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedToken.contractAddress}
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={sendAll}
|
||||
class="h-3.5 w-3.5" />
|
||||
<span class="text-xs text-muted-foreground">
|
||||
Send all (deducts estimated gas fee)
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<Button type="submit" disabled={!canSend} class="w-full">
|
||||
{sending ? 'Sending...' : 'Send'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{#if sendError}
|
||||
<p class="mt-3 text-xs text-red-500">{sendError}</p>
|
||||
{/if}
|
||||
|
||||
{#if sentResult}
|
||||
<div
|
||||
class="mt-4 space-y-2 rounded-md border border-green-500/30 bg-green-500/5 p-3">
|
||||
<p class="text-xs font-medium text-green-600">
|
||||
Transaction Sent
|
||||
</p>
|
||||
<p class="font-mono text-xs break-all">
|
||||
<a
|
||||
href="{w.explorerUrl}{sentResult.txHash}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-green-500">
|
||||
{sentResult.txHash}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { evmWallet as wallet } from '$lib/states/evm-wallet.svelte.js'
|
||||
import { electrobun } from '$lib/electrobun.js'
|
||||
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
|
||||
import { Button } from '$lib/components/ui/button/index.js'
|
||||
import { Input } from '$lib/components/ui/input/index.js'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -12,19 +10,13 @@
|
||||
} from '$lib/components/ui/card/index.js'
|
||||
import { navigate } from 'sv-router/generated'
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left'
|
||||
import Eye from '@lucide/svelte/icons/eye'
|
||||
import Fingerprint from '@lucide/svelte/icons/fingerprint'
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2'
|
||||
import WalletIcon from '@lucide/svelte/icons/wallet'
|
||||
|
||||
const w = wallet
|
||||
const w = xrpWallet
|
||||
let confirm = $state(false)
|
||||
let resetting = $state(false)
|
||||
let deletingWalletId = $state<string | null>(null)
|
||||
let flushing = $state(false)
|
||||
let seedRevealed = $state(false)
|
||||
let seedPassword = $state('')
|
||||
let seedError = $state('')
|
||||
|
||||
async function handleReset() {
|
||||
resetting = true
|
||||
@@ -37,20 +29,22 @@
|
||||
async function handleDeleteWallet(id: string) {
|
||||
await w.deleteWallet(id)
|
||||
deletingWalletId = null
|
||||
if (w.wallets.length === 0) navigate('/multicoin')
|
||||
}
|
||||
|
||||
async function handleFlushCache() {
|
||||
flushing = true
|
||||
await w.flushTxCache()
|
||||
flushing = false
|
||||
if (w.wallets.length === 0) navigate('/xrp')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto mt-16 max-w-md">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onclick={() => navigate(w.network === 'eth' ? '/multicoin' : `/multicoin/${w.network}`)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
w.currentWalletId
|
||||
? navigate('/xrp/wallet/:id', {
|
||||
params: { id: w.currentWalletId },
|
||||
})
|
||||
: navigate('/xrp')}>
|
||||
<ArrowLeft size={16} />
|
||||
Back
|
||||
</Button>
|
||||
@@ -59,7 +53,7 @@
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Wallets</CardTitle>
|
||||
<CardDescription>Manage your EVM wallets</CardDescription>
|
||||
<CardDescription>Manage your XRP wallets</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -68,8 +62,8 @@
|
||||
<WalletIcon size={18} class="shrink-0 text-muted-foreground" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm">{wal.name}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Created {new Date(wal.createdAt).toLocaleDateString()}
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">
|
||||
{wal.address}
|
||||
</p>
|
||||
</div>
|
||||
{#if deletingWalletId === wal.id}
|
||||
@@ -92,83 +86,17 @@
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
{#if w.seed}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Seed Phrase</CardTitle>
|
||||
<CardDescription>Reveal your wallet recovery phrase</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if seedRevealed}
|
||||
<div class="rounded-md border border-destructive/50 bg-destructive/5 p-3">
|
||||
<p class="text-xs text-destructive font-medium mb-2">
|
||||
Never share your seed phrase. Anyone with it can access your funds.
|
||||
</p>
|
||||
<p class="font-mono text-xs break-all">{w.seed}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="mt-3" onclick={() => { seedRevealed = false; seedPassword = ''; seedError = '' }}>
|
||||
<Eye size={14} /> Hide
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#if w.currentPasswordHash}
|
||||
<div class="flex gap-2 items-end">
|
||||
<Input type="password" placeholder="Enter password" bind:value={seedPassword} />
|
||||
<Button onclick={async () => {
|
||||
seedError = ''
|
||||
const { salt, hash } = JSON.parse(w.currentPasswordHash!)
|
||||
const { hash: check } = await w.hashPassword(seedPassword, salt)
|
||||
if (check === hash) {
|
||||
seedRevealed = true
|
||||
} else {
|
||||
seedError = 'Incorrect password'
|
||||
}
|
||||
seedPassword = ''
|
||||
}}>Reveal</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if w.biometricAvailable}
|
||||
<Button variant="outline" class="w-full" onclick={async () => {
|
||||
seedError = ''
|
||||
const ok = await electrobun.rpc?.request.biometricAuth({ reason: 'Reveal seed phrase' })
|
||||
if (ok) seedRevealed = true
|
||||
}}>
|
||||
<Fingerprint size={14} /> Reveal with Touch ID
|
||||
</Button>
|
||||
{/if}
|
||||
{#if !w.currentPasswordHash && !w.biometricAvailable}
|
||||
<Button onclick={() => (seedRevealed = true)}>Reveal Seed Phrase</Button>
|
||||
{/if}
|
||||
{#if seedError}
|
||||
<p class="text-xs text-destructive">{seedError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Settings</CardTitle>
|
||||
<CardDescription>App preferences and data management</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div>
|
||||
<p class="font-medium text-xs">Transaction Cache</p>
|
||||
<p class="text-muted-foreground text-xs mt-1">
|
||||
Clears all cached transaction history and sync status. Data will be re-fetched from Alchemy on next refresh.
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
<Button variant="outline" onclick={handleFlushCache} disabled={flushing}>
|
||||
{flushing ? 'Flushing...' : 'Flush Transaction Cache'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-xs">Reset App</p>
|
||||
<p class="text-muted-foreground text-xs mt-1">
|
||||
Removes your seed, API key, and all cached data from the keychain
|
||||
and database. You can then set up the wallet fresh.
|
||||
Removes all XRP wallets and seeds from the keychain. You
|
||||
can then set up the wallet fresh.
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
{#if confirm}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
electrobun,
|
||||
type TransactionDetails,
|
||||
} from '$lib/electrobun.js'
|
||||
import { evmWallet } from '$lib/states/evm-wallet.svelte.js'
|
||||
import { networks } from '$lib/states/evm-wallet.svelte.js'
|
||||
import { navigate, route } from 'sv-router/generated'
|
||||
import { Button } from '$lib/components/ui/button/index.js'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '$lib/components/ui/card/index.js'
|
||||
|
||||
let details = $state<TransactionDetails | null>(null)
|
||||
let loading = $state(true)
|
||||
let error = $state('')
|
||||
|
||||
const chainid = $derived(route.params.chainid)
|
||||
const hash = $derived(route.params.hash)
|
||||
|
||||
const network = $derived(
|
||||
networks.find((n) => n.chainid === chainid),
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
if (!hash || !chainid) return
|
||||
loading = true
|
||||
error = ''
|
||||
electrobun.rpc?.request
|
||||
.fetchTransactionDetails({
|
||||
hash,
|
||||
chainid,
|
||||
address: evmWallet.address || undefined,
|
||||
})
|
||||
.then((res) => {
|
||||
details = res
|
||||
loading = false
|
||||
})
|
||||
.catch((e) => {
|
||||
error =
|
||||
e instanceof Error ? e.message : 'Failed to fetch details'
|
||||
loading = false
|
||||
})
|
||||
})
|
||||
|
||||
function explorerUrl() {
|
||||
if (!network) return ''
|
||||
return network.explorerUrl + hash
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto mt-16 max-w-md">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => navigate('/multicoin')}
|
||||
class="mb-4">
|
||||
← Back
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle>Transaction Details</CardTitle>
|
||||
{#if network}
|
||||
<span
|
||||
class="rounded-md bg-muted px-2 py-0.5 text-xs font-medium">
|
||||
{network.name}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if loading}
|
||||
<p class="text-muted-foreground text-xs">Loading...</p>
|
||||
{:else if error}
|
||||
<p class="text-red-500 text-xs">{error}</p>
|
||||
{:else if details}
|
||||
<div class="space-y-2 font-mono text-xs">
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">Hash</p>
|
||||
<p class="break-all">{details.hash}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">Status</p>
|
||||
<p
|
||||
class={details.status === 'success'
|
||||
? 'text-green-500'
|
||||
: 'text-red-500'}>
|
||||
{details.status}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">Block</p>
|
||||
<p>{details.blockNumber ?? 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">From</p>
|
||||
<p class="break-all">{details.from}</p>
|
||||
</div>
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">To</p>
|
||||
<p class="break-all">{details.to ?? 'N/A'}</p>
|
||||
</div>
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">
|
||||
Value ({network?.symbol ?? 'ETH'})
|
||||
</p>
|
||||
<p>{details.value}</p>
|
||||
</div>
|
||||
{#if details.pairedValue}
|
||||
<div
|
||||
class="rounded-md border border-yellow-500/30 bg-yellow-500/5 p-2">
|
||||
<p class="mb-0.5 flex items-center gap-1 text-xs font-medium text-yellow-600">
|
||||
Swap
|
||||
</p>
|
||||
<p class="font-mono text-xs">
|
||||
{Number(details.value).toFixed(4)}
|
||||
{network?.symbol ?? 'ETH'}
|
||||
<span class="mx-1">→</span>
|
||||
{Number(details.pairedValue).toFixed(4)}
|
||||
{details.pairedSymbol ?? network?.symbol ?? 'ETH'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">
|
||||
Tx Fee ({network?.symbol ?? 'ETH'})
|
||||
</p>
|
||||
<p>{details.fee}</p>
|
||||
</div>
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">
|
||||
Gas Price (Gwei)
|
||||
</p>
|
||||
<p>{details.gasPrice}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">Nonce</p>
|
||||
<p>{details.nonce}</p>
|
||||
</div>
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">Type</p>
|
||||
<p>{details.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
onclick={() =>
|
||||
electrobun.rpc?.request.openExternal({
|
||||
url: explorerUrl(),
|
||||
})}>
|
||||
View on Explorer
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">
|
||||
No details available
|
||||
</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -0,0 +1,273 @@
|
||||
<script lang="ts">
|
||||
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
|
||||
import { Button } from '$lib/components/ui/button/index.js'
|
||||
import { Input } from '$lib/components/ui/input/index.js'
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '$lib/components/ui/card/index.js'
|
||||
import Fingerprint from '@lucide/svelte/icons/fingerprint'
|
||||
import LockKeyhole from '@lucide/svelte/icons/lock-keyhole'
|
||||
import Plus from '@lucide/svelte/icons/plus'
|
||||
import WalletIcon from '@lucide/svelte/icons/wallet'
|
||||
import Loader from '$lib/components/loader.svelte'
|
||||
import { navigate } from 'sv-router/generated'
|
||||
|
||||
const w = xrpWallet
|
||||
|
||||
let inputWalletName = $state('')
|
||||
let inputSeed = $state('')
|
||||
let inputPassword = $state('')
|
||||
let inputUnlockPassword = $state('')
|
||||
let showCreateForm = $state(false)
|
||||
let newSeed = $state<string | null>(null)
|
||||
|
||||
let initStarted = false
|
||||
|
||||
$effect(() => {
|
||||
if (!initStarted) {
|
||||
initStarted = true
|
||||
w.init().then(async () => {
|
||||
if (w.accountType === 'xrp') return
|
||||
await w.login()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (w.accountType === 'xrp' && w.currentWalletId && w.seed && !newSeed) {
|
||||
navigate('/xrp/wallet/:id', {
|
||||
params: { id: w.currentWalletId },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const handleCreate = async () => {
|
||||
const seed = await w.createWallet(
|
||||
inputWalletName,
|
||||
inputPassword || undefined,
|
||||
)
|
||||
newSeed = seed ?? null
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
await w.importWallet(
|
||||
inputWalletName,
|
||||
inputSeed.trim(),
|
||||
inputPassword || undefined,
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto mt-16 max-w-md">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if !w.ready}
|
||||
<p class="text-center text-muted-foreground text-sm mt-8">
|
||||
Loading...
|
||||
</p>
|
||||
{:else if !w.accountType}
|
||||
<p class="text-center text-muted-foreground text-sm mt-8">
|
||||
<a href="/" class="underline">Go to welcome page</a>
|
||||
to get started
|
||||
</p>
|
||||
{:else}
|
||||
<a
|
||||
href="/"
|
||||
class="text-xs text-muted-foreground underline mb-1 inline-block">
|
||||
← Back to home
|
||||
</a>
|
||||
{#if newSeed}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Wallet Created</CardTitle>
|
||||
<CardDescription>
|
||||
Save this seed phrase — it is the only way to recover
|
||||
your wallet
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="rounded-md border border-destructive/50 bg-destructive/5 p-3 mb-3">
|
||||
<p class="text-xs text-destructive font-medium mb-2">
|
||||
Never share your seed. Anyone with it can access your
|
||||
funds.
|
||||
</p>
|
||||
<p class="font-mono text-xs break-all">{newSeed}</p>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mb-3">
|
||||
Your XRP address becomes active after it receives at
|
||||
least 10 XRP (the base reserve).
|
||||
</p>
|
||||
<Button
|
||||
class="w-full"
|
||||
onclick={() => {
|
||||
newSeed = null
|
||||
inputWalletName = ''
|
||||
inputPassword = ''
|
||||
}}>
|
||||
I saved my seed
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if w.wallets.length === 0 || showCreateForm}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{showCreateForm ? 'Add Another Wallet' : 'Create / Import Wallet'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Generate a new XRP wallet or import an existing seed
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-3">
|
||||
<Input
|
||||
placeholder="Wallet name (e.g. Main, Savings)"
|
||||
bind:value={inputWalletName} />
|
||||
<Button
|
||||
onclick={handleCreate}
|
||||
disabled={w.loading || !inputWalletName.trim()}>
|
||||
{w.loading ? 'Creating...' : 'Generate New Wallet'}
|
||||
</Button>
|
||||
<hr class="border-muted" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Or import an existing wallet:
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder="XRP seed (s...) or 12/24-word phrase"
|
||||
bind:value={inputSeed} />
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Set a password (optional)"
|
||||
bind:value={inputPassword} />
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={handleImport}
|
||||
disabled={w.loading ||
|
||||
!inputSeed.trim() ||
|
||||
!inputWalletName.trim()}>
|
||||
{w.loading ? 'Importing...' : 'Import Wallet'}
|
||||
</Button>
|
||||
{#if showCreateForm}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (showCreateForm = false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if w.error}
|
||||
<p class="mt-3 text-xs text-red-500">{w.error}</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if !w.currentWalletId}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Wallets</CardTitle>
|
||||
<CardDescription>Select a wallet to use</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each w.wallets as wal}
|
||||
<button
|
||||
onclick={async () => {
|
||||
if (wal.hasPassword) {
|
||||
await w.selectWallet(wal.id)
|
||||
} else {
|
||||
await w.selectAndUnlockWallet(wal.id)
|
||||
}
|
||||
}}
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md border border-input p-3 text-left hover:bg-muted transition-colors">
|
||||
<WalletIcon
|
||||
size={20}
|
||||
class="shrink-0 text-muted-foreground" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm">{wal.name}</p>
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">
|
||||
{wal.address}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if wal.hasPassword}
|
||||
<LockKeyhole
|
||||
size={14}
|
||||
class="text-muted-foreground" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (showCreateForm = true)}
|
||||
class="mt-2">
|
||||
<Plus size={16} />
|
||||
Add Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if w.isLocked}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{w.currentWallet?.name ?? 'Wallet'} Locked
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Unlock to access your wallet
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if w.biometricAvailable}
|
||||
<Button
|
||||
onclick={() => w.unlockWithBiometrics()}
|
||||
disabled={w.loading}>
|
||||
<Fingerprint size={16} />
|
||||
{w.loading ? 'Unlocking...' : 'Unlock with Touch ID'}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if w.currentPasswordHash}
|
||||
<div class="flex gap-2 items-end">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
bind:value={inputUnlockPassword} />
|
||||
<Button
|
||||
onclick={async () => {
|
||||
const ok = await w.unlockWithPassword(
|
||||
inputUnlockPassword,
|
||||
)
|
||||
if (!ok) w.error = 'Wrong password'
|
||||
inputUnlockPassword = ''
|
||||
}}
|
||||
disabled={w.loading || !inputUnlockPassword}
|
||||
size="sm">
|
||||
{#if w.loading}
|
||||
<Loader />
|
||||
{:else}
|
||||
Unlock
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => w.clearSelection()}>
|
||||
Pick another wallet
|
||||
</Button>
|
||||
</div>
|
||||
{#if w.error}
|
||||
<p class="mt-3 text-xs text-red-500">{w.error}</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
|
||||
import { Button } from '$lib/components/ui/button/index.js'
|
||||
import { Input } from '$lib/components/ui/input/index.js'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '$lib/components/ui/card/index.js'
|
||||
import { navigate } from 'sv-router/generated'
|
||||
|
||||
const w = xrpWallet
|
||||
|
||||
let recipient = $state('')
|
||||
let destinationTag = $state('')
|
||||
let amountXrp = $state('')
|
||||
let sending = $state(false)
|
||||
let sentResult = $state<{ hash: string; fee: string } | null>(null)
|
||||
let sendError = $state('')
|
||||
|
||||
const isValidAddress = $derived(/^r[1-9A-HJ-NP-Za-km-z]{24,34}$/.test(recipient))
|
||||
|
||||
const amount = $derived(parseFloat(amountXrp))
|
||||
const amountValid = $derived(!isNaN(amount) && amount > 0)
|
||||
const exceedsBalance = $derived(amountValid && amount > parseFloat(w.balance))
|
||||
const tagValid = $derived(
|
||||
destinationTag === '' ||
|
||||
(/^\d+$/.test(destinationTag) && Number(destinationTag) <= 4294967295),
|
||||
)
|
||||
const canSend = $derived(
|
||||
isValidAddress &&
|
||||
amountValid &&
|
||||
!exceedsBalance &&
|
||||
tagValid &&
|
||||
!sending,
|
||||
)
|
||||
|
||||
const handleSend = async () => {
|
||||
sending = true
|
||||
sendError = ''
|
||||
sentResult = null
|
||||
try {
|
||||
const result = await w.send(
|
||||
recipient,
|
||||
amount.toString(),
|
||||
destinationTag === '' ? undefined : Number(destinationTag),
|
||||
)
|
||||
sentResult = result
|
||||
recipient = ''
|
||||
destinationTag = ''
|
||||
amountXrp = ''
|
||||
} catch (e) {
|
||||
sendError = e instanceof Error ? e.message : 'Send failed'
|
||||
} finally {
|
||||
sending = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto mt-16 max-w-md">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
navigate('/xrp/wallet/:id', {
|
||||
params: { id: w.currentWalletId ?? '' },
|
||||
})}
|
||||
class="mb-4">
|
||||
← Back
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Send XRP</CardTitle>
|
||||
<CardDescription>
|
||||
Balance: {w.balance} XRP
|
||||
{#if w.fee}
|
||||
(fee: ~{w.fee} XRP)
|
||||
{/if}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}}
|
||||
class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium">Recipient Address</label>
|
||||
<Input
|
||||
placeholder="XRP address starting with r..."
|
||||
bind:value={recipient} />
|
||||
{#if recipient && !isValidAddress}
|
||||
<p class="text-xs text-red-500">Invalid XRP address</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium">
|
||||
Destination Tag (optional)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="Required by some exchanges"
|
||||
bind:value={destinationTag} />
|
||||
{#if !tagValid}
|
||||
<p class="text-xs text-red-500">
|
||||
Destination tag must be a number between 0 and
|
||||
4294967295
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium">Amount (XRP)</label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
placeholder="0.0"
|
||||
bind:value={amountXrp} />
|
||||
<span
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
|
||||
XRP
|
||||
</span>
|
||||
</div>
|
||||
{#if exceedsBalance}
|
||||
<p class="text-xs text-red-500">
|
||||
Amount exceeds balance
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={!canSend} class="w-full">
|
||||
{sending ? 'Sending...' : 'Send'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{#if sendError}
|
||||
<p class="mt-3 text-xs text-red-500">{sendError}</p>
|
||||
{/if}
|
||||
|
||||
{#if sentResult}
|
||||
<div
|
||||
class="mt-4 space-y-2 rounded-md border border-green-500/30 bg-green-500/5 p-3">
|
||||
<p class="text-xs font-medium text-green-600">
|
||||
Transaction Sent
|
||||
</p>
|
||||
<p class="font-mono text-xs break-all">
|
||||
Hash: {sentResult.hash}
|
||||
</p>
|
||||
<p class="font-mono text-xs">
|
||||
Fee: {sentResult.fee} XRP
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { electrobun, type XrpTxDetails } from '$lib/electrobun.js'
|
||||
import { xrpWallet, xrpNetwork } from '$lib/states/xrp-wallet.svelte.js'
|
||||
import { navigate, route } from 'sv-router/generated'
|
||||
import { Button } from '$lib/components/ui/button/index.js'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '$lib/components/ui/card/index.js'
|
||||
import ArrowDown from '@lucide/svelte/icons/arrow-down'
|
||||
import ArrowUp from '@lucide/svelte/icons/arrow-up'
|
||||
|
||||
let details = $state<XrpTxDetails | null>(null)
|
||||
let loading = $state(true)
|
||||
let error = $state('')
|
||||
|
||||
const hash = $derived(route.params.hash)
|
||||
|
||||
$effect(() => {
|
||||
if (!hash) return
|
||||
loading = true
|
||||
error = ''
|
||||
electrobun.rpc?.request
|
||||
.xrpGetTxDetails({ hash })
|
||||
.then((res) => {
|
||||
details = res
|
||||
loading = false
|
||||
})
|
||||
.catch((e) => {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch details'
|
||||
loading = false
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="mx-auto mt-16 max-w-md">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
navigate('/xrp/wallet/:id', {
|
||||
params: { id: xrpWallet.currentWalletId ?? '' },
|
||||
})}
|
||||
class="mb-4">
|
||||
← Back
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transaction Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if loading}
|
||||
<p class="text-muted-foreground text-xs">Loading...</p>
|
||||
{:else if error}
|
||||
<p class="text-red-500 text-xs">{error}</p>
|
||||
{:else if details}
|
||||
<div class="space-y-2 font-mono text-xs">
|
||||
<div class="flex items-center gap-2 rounded-md bg-muted p-2">
|
||||
<div class="shrink-0 {details.to === xrpWallet.address ? 'text-green-500' : 'text-muted-foreground'}">
|
||||
{#if details.to === xrpWallet.address}
|
||||
<ArrowDown size={16} />
|
||||
{:else}
|
||||
<ArrowUp size={16} />
|
||||
{/if}
|
||||
</div>
|
||||
<p class="font-medium">
|
||||
{details.to === xrpWallet.address ? 'Received' : 'Sent'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">Transaction Hash</p>
|
||||
<p class="break-all">{details.hash}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">From</p>
|
||||
<p class="break-all">{details.from}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">To</p>
|
||||
<p class="break-all">{details.to}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">Amount</p>
|
||||
<p>{details.amount} XRP</p>
|
||||
</div>
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">Fee</p>
|
||||
<p>{details.fee} XRP</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">Ledger</p>
|
||||
<p>{details.ledgerIndex.toLocaleString()}</p>
|
||||
</div>
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">Validated</p>
|
||||
<p>{details.confirmed ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if details.destinationTag !== undefined}
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">Destination Tag</p>
|
||||
<p>{details.destinationTag}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if details.timestamp && details.timestamp !== '0'}
|
||||
<div class="rounded-md bg-muted p-2">
|
||||
<p class="text-muted-foreground mb-0.5">Date</p>
|
||||
<p>{new Date(Number(details.timestamp) * 1000).toLocaleString()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
onclick={() =>
|
||||
electrobun.rpc?.request.openExternal({
|
||||
url: `${xrpNetwork.explorerUrl}${details?.hash}`,
|
||||
})}>
|
||||
View on Explorer
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">No details available</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -0,0 +1,235 @@
|
||||
<script lang="ts">
|
||||
import { xrpWallet, xrpNetwork } from '$lib/states/xrp-wallet.svelte.js'
|
||||
import { electrobun } from '$lib/electrobun.js'
|
||||
import { Button } from '$lib/components/ui/button/index.js'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '$lib/components/ui/card/index.js'
|
||||
import ArrowDown from '@lucide/svelte/icons/arrow-down'
|
||||
import ArrowUp from '@lucide/svelte/icons/arrow-up'
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link'
|
||||
import Loader from '$lib/components/loader.svelte'
|
||||
import QrCode from '$lib/components/qr.svelte'
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js'
|
||||
import { CopyButton } from '$lib/components/ui/copy-button'
|
||||
import { navigate, route } from 'sv-router/generated'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
const w = xrpWallet
|
||||
|
||||
const walletId = $derived(route.params.id)
|
||||
|
||||
let qrDialogOpen = $state(false)
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined =
|
||||
$state()
|
||||
|
||||
$effect(() => {
|
||||
if (w.seed && !autoRefreshTimer) {
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
w.refresh()
|
||||
}, 30000)
|
||||
} else if (!w.seed && autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = undefined
|
||||
}
|
||||
return () => {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!w.seed || w.currentWalletId !== walletId) {
|
||||
navigate('/xrp')
|
||||
return
|
||||
}
|
||||
w.refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="mx-auto mt-16 max-w-md">
|
||||
<div class="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle>
|
||||
{w.currentWallet?.name ?? 'XRP Wallet'}
|
||||
</CardTitle>
|
||||
<img src="/icons/xrp.png" alt="" class="size-6 rounded-full" />
|
||||
</div>
|
||||
<CardDescription>XRP Ledger</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mb-4 space-y-1">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<p class="font-medium text-xs">Address</p>
|
||||
<p class="font-mono text-xs break-all">{w.address}</p>
|
||||
<div class="flex gap-1.5 mt-1">
|
||||
<Dialog.Root bind:open={qrDialogOpen}>
|
||||
<Dialog.Trigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="text-muted-foreground"
|
||||
aria-label="Show QR code">
|
||||
<QrCodeIcon size={16} />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 py-4">
|
||||
<QrCode text={w.address} size={256} />
|
||||
<p
|
||||
class="font-mono text-xs break-all text-center max-w-64">
|
||||
{w.address}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<CopyButton
|
||||
text={w.address}
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="text-muted-foreground">
|
||||
</CopyButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="text-muted-foreground"
|
||||
aria-label="View on explorer"
|
||||
onclick={() =>
|
||||
electrobun.rpc?.request.openExternal({
|
||||
url: w.explorerAddressUrl,
|
||||
})}>
|
||||
<ExternalLink size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 font-medium text-xs">Balance</p>
|
||||
{#if w.loadingBalance}
|
||||
<div class="py-1"><Loader /></div>
|
||||
{:else}
|
||||
<div class="flex items-baseline gap-2">
|
||||
<p class="font-mono text-lg">{w.balance} XRP</p>
|
||||
{#if w.price}
|
||||
<p class="font-mono text-sm text-muted-foreground">
|
||||
≈ ${(
|
||||
parseFloat(w.balance) * parseFloat(w.price)
|
||||
).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !w.funded}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Account not activated — it needs at least 10 XRP to
|
||||
meet the base reserve.
|
||||
</p>
|
||||
{/if}
|
||||
{#if w.fee}
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
Network fee: ~{w.fee} XRP
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => navigate('/xrp/send')}>
|
||||
Send
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => navigate('/settings')}>
|
||||
<SettingsIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={async () => {
|
||||
await w.logout()
|
||||
navigate('/')
|
||||
}}>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if w.loadingTransactions}
|
||||
<div class="flex justify-center py-4">
|
||||
<Loader />
|
||||
</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('/xrp/tx/:hash', {
|
||||
params: { hash: tx.hash },
|
||||
})}
|
||||
class="flex w-full items-start gap-2 rounded-md bg-muted px-3 py-2 text-xs text-left cursor-pointer hover:bg-muted/80 transition-colors">
|
||||
<div
|
||||
class="shrink-0 mt-0.5 {tx.direction === 'in'
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground'}">
|
||||
{#if tx.direction === 'in'}
|
||||
<ArrowDown size={16} />
|
||||
{:else}
|
||||
<ArrowUp size={16} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<p class="font-medium">
|
||||
{tx.direction === 'in' ? 'Received' : 'Sent'}
|
||||
{#if !tx.confirmed}
|
||||
<span class="text-muted-foreground">
|
||||
(unconfirmed)
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="font-mono text-muted-foreground">
|
||||
{tx.amount} XRP
|
||||
</p>
|
||||
{#if tx.timestamp && tx.timestamp !== '0'}
|
||||
<p class="text-muted-foreground">
|
||||
{new Date(
|
||||
Number(tx.timestamp) * 1000,
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
{/if}
|
||||
<p
|
||||
class="text-muted-foreground/50 truncate font-mono">
|
||||
{tx.hash.slice(0, 12)}...
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user