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
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user