remove evm + add xrp

This commit is contained in:
2026-08-01 00:58:22 +08:00
parent f46e61ed5e
commit 0d3acef182
55 changed files with 1457 additions and 4148 deletions
+7 -31
View File
@@ -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...')
-31
View File
@@ -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
}
-35
View File
@@ -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
}
}
-5
View File
@@ -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 })
-4
View File
@@ -1,4 +0,0 @@
import { defineRelations } from 'drizzle-orm'
import * as schema from './schema'
export const relations = defineRelations(schema, () => ({}))
-115
View File
@@ -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),
}),
)
-16
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
}
}
-12
View File
@@ -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
}
-2
View File
@@ -1,2 +0,0 @@
export * from './getClient'
export { getTransactionDetails } from './get-transaction-details'
+188
View File
@@ -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
}
}
-641
View File
@@ -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
},
}
}
+153
View File
@@ -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
}
},
}
}