split data access into API-only (alchemy/viem) and DB-first wrappers (tokens/transactions)
alchemy/get-token-metadata: stripped DB logic, now pure API call viem/get-transaction-details: stripped DB logic, now pure viem call tokens/get-metadata: new DB-first wrapper for token metadata transactions/get-details: new DB-first wrapper for tx details transactions/get-history: stores individual Alchemy transfers in tx_history table with blockNum, from, to columns for searchable ordering
This commit is contained in:
+100
-33
@@ -8,19 +8,18 @@ import {
|
|||||||
defineElectrobunRPC,
|
defineElectrobunRPC,
|
||||||
type RPCSchema,
|
type RPCSchema,
|
||||||
} from 'electrobun/bun'
|
} from 'electrobun/bun'
|
||||||
import { inspect } from 'util'
|
import { getTokenMetadata } from './lib/tokens'
|
||||||
import {
|
import { getTokensBalances, type Transaction } from './lib/alchemy'
|
||||||
getTokenMetadata,
|
|
||||||
getTokensBalances,
|
|
||||||
type Transaction,
|
|
||||||
} from './lib/alchemy'
|
|
||||||
import { tryCatch } from '@koins/utils'
|
import { tryCatch } from '@koins/utils'
|
||||||
import { getTransactionHistory } from './lib/alchemy/get-transaction-history'
|
import {
|
||||||
|
getTransactionHistory,
|
||||||
|
getTransactionDetails,
|
||||||
|
} from './lib/transactions'
|
||||||
import { migrate } from 'drizzle-orm/bun-sqlite/migrator'
|
import { migrate } from 'drizzle-orm/bun-sqlite/migrator'
|
||||||
import { db } from './lib/db'
|
import { db } from './lib/db'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { getENV } from './lib/get-env'
|
import { getENV } from './lib/get-env'
|
||||||
import { getClient, getTransactionDetails } from './lib/viem'
|
import { getClient } from './lib/viem'
|
||||||
import {
|
import {
|
||||||
isBinaryInstalled,
|
isBinaryInstalled,
|
||||||
downloadBinary,
|
downloadBinary,
|
||||||
@@ -168,7 +167,7 @@ type RPC = {
|
|||||||
hash: string
|
hash: string
|
||||||
chainid: string
|
chainid: string
|
||||||
}
|
}
|
||||||
response: TxDetailsResult | null
|
response: ReturnType<typeof getTransactionDetails>
|
||||||
}
|
}
|
||||||
openExternal: {
|
openExternal: {
|
||||||
params: {
|
params: {
|
||||||
@@ -206,7 +205,12 @@ type RPC = {
|
|||||||
response: { mnemonic: string; address: string }
|
response: { mnemonic: string; address: string }
|
||||||
}
|
}
|
||||||
moneroRestoreWallet: {
|
moneroRestoreWallet: {
|
||||||
params: { name: string; password: string; mnemonic: string; restoreHeight?: number }
|
params: {
|
||||||
|
name: string
|
||||||
|
password: string
|
||||||
|
mnemonic: string
|
||||||
|
restoreHeight?: number
|
||||||
|
}
|
||||||
response: { address: string }
|
response: { address: string }
|
||||||
}
|
}
|
||||||
moneroOpenWallet: {
|
moneroOpenWallet: {
|
||||||
@@ -509,7 +513,11 @@ const rpc = BrowserView.defineRPC<RPC>({
|
|||||||
if (!key) return null
|
if (!key) return null
|
||||||
try {
|
try {
|
||||||
const client = getClient(chainid, key)
|
const client = getClient(chainid, key)
|
||||||
const details = await getTransactionDetails(client, hash, chainid)
|
const details = await getTransactionDetails(
|
||||||
|
client,
|
||||||
|
hash,
|
||||||
|
chainid,
|
||||||
|
)
|
||||||
return details
|
return details
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
@@ -517,12 +525,17 @@ const rpc = BrowserView.defineRPC<RPC>({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
moneroBinaryStatus: async () => {
|
moneroBinaryStatus: async () => {
|
||||||
const status = { installed: isBinaryInstalled(), downloading: moneroDownloading }
|
const status = {
|
||||||
|
installed: isBinaryInstalled(),
|
||||||
|
downloading: moneroDownloading,
|
||||||
|
}
|
||||||
console.log('[rpc] moneroBinaryStatus:', status)
|
console.log('[rpc] moneroBinaryStatus:', status)
|
||||||
return status
|
return status
|
||||||
},
|
},
|
||||||
moneroDownloadBinary: async () => {
|
moneroDownloadBinary: async () => {
|
||||||
console.log('[rpc] moneroDownloadBinary: starting download...')
|
console.log(
|
||||||
|
'[rpc] moneroDownloadBinary: starting download...',
|
||||||
|
)
|
||||||
moneroDownloading = true
|
moneroDownloading = true
|
||||||
try {
|
try {
|
||||||
await downloadBinary()
|
await downloadBinary()
|
||||||
@@ -543,10 +556,17 @@ const rpc = BrowserView.defineRPC<RPC>({
|
|||||||
await moneroManager.start(daemonAddress)
|
await moneroManager.start(daemonAddress)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('[rpc] moneroStart error:', e)
|
console.log('[rpc] moneroStart error:', e)
|
||||||
return { running: false, walletOpen: false, connected: false }
|
return {
|
||||||
|
running: false,
|
||||||
|
walletOpen: false,
|
||||||
|
connected: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const connected = await moneroManager.isConnected()
|
const connected = await moneroManager.isConnected()
|
||||||
console.log('[rpc] moneroStart complete, connected:', connected)
|
console.log(
|
||||||
|
'[rpc] moneroStart complete, connected:',
|
||||||
|
connected,
|
||||||
|
)
|
||||||
return { running: true, walletOpen: false, connected }
|
return { running: true, walletOpen: false, connected }
|
||||||
},
|
},
|
||||||
moneroStop: async () => {
|
moneroStop: async () => {
|
||||||
@@ -558,32 +578,58 @@ const rpc = BrowserView.defineRPC<RPC>({
|
|||||||
},
|
},
|
||||||
moneroCreateWallet: async ({ name, password }) => {
|
moneroCreateWallet: async ({ name, password }) => {
|
||||||
console.log('[rpc] moneroCreateWallet:', name)
|
console.log('[rpc] moneroCreateWallet:', name)
|
||||||
if (!moneroManager) throw new Error('Monero wallet RPC not started')
|
if (!moneroManager)
|
||||||
const result = await moneroManager.createWallet(name, password)
|
throw new Error('Monero wallet RPC not started')
|
||||||
|
const result = await moneroManager.createWallet(
|
||||||
|
name,
|
||||||
|
password,
|
||||||
|
)
|
||||||
console.log('[rpc] moneroCreateWallet complete')
|
console.log('[rpc] moneroCreateWallet complete')
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
moneroRestoreWallet: async ({ name, password, mnemonic, restoreHeight }) => {
|
moneroRestoreWallet: async ({
|
||||||
|
name,
|
||||||
|
password,
|
||||||
|
mnemonic,
|
||||||
|
restoreHeight,
|
||||||
|
}) => {
|
||||||
console.log('[rpc] moneroRestoreWallet:', name)
|
console.log('[rpc] moneroRestoreWallet:', name)
|
||||||
if (!moneroManager) throw new Error('Monero wallet RPC not started')
|
if (!moneroManager)
|
||||||
await moneroManager.restoreWallet(name, password, mnemonic, restoreHeight)
|
throw new Error('Monero wallet RPC not started')
|
||||||
|
await moneroManager.restoreWallet(
|
||||||
|
name,
|
||||||
|
password,
|
||||||
|
mnemonic,
|
||||||
|
restoreHeight,
|
||||||
|
)
|
||||||
const address = await moneroManager.getAddress()
|
const address = await moneroManager.getAddress()
|
||||||
console.log('[rpc] moneroRestoreWallet complete, address:', address)
|
console.log(
|
||||||
|
'[rpc] moneroRestoreWallet complete, address:',
|
||||||
|
address,
|
||||||
|
)
|
||||||
return { address }
|
return { address }
|
||||||
},
|
},
|
||||||
moneroOpenWallet: async ({ name, password }) => {
|
moneroOpenWallet: async ({ name, password }) => {
|
||||||
console.log('[rpc] moneroOpenWallet:', name)
|
console.log('[rpc] moneroOpenWallet:', name)
|
||||||
if (!moneroManager) throw new Error('Monero wallet RPC not started')
|
if (!moneroManager)
|
||||||
|
throw new Error('Monero wallet RPC not started')
|
||||||
await moneroManager.openWallet(name, password)
|
await moneroManager.openWallet(name, password)
|
||||||
},
|
},
|
||||||
moneroGetBalance: async () => {
|
moneroGetBalance: async () => {
|
||||||
console.log('[rpc] moneroGetBalance')
|
console.log('[rpc] moneroGetBalance')
|
||||||
if (!moneroManager) throw new Error('Monero wallet RPC not started')
|
if (!moneroManager)
|
||||||
|
throw new Error('Monero wallet RPC not started')
|
||||||
const { balance, unlocked } = await moneroManager.getBalance()
|
const { balance, unlocked } = await moneroManager.getBalance()
|
||||||
const address = await moneroManager.getAddress()
|
const address = await moneroManager.getAddress()
|
||||||
const height = await moneroManager.getHeight()
|
const height = await moneroManager.getHeight()
|
||||||
const daemonHeight = await moneroManager.getDaemonHeight()
|
const daemonHeight = await moneroManager.getDaemonHeight()
|
||||||
console.log('[rpc] balance:', { balance: balance.toString(), unlocked: unlocked.toString(), address, height, daemonHeight })
|
console.log('[rpc] balance:', {
|
||||||
|
balance: balance.toString(),
|
||||||
|
unlocked: unlocked.toString(),
|
||||||
|
address,
|
||||||
|
height,
|
||||||
|
daemonHeight,
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
balance: balance.toString(),
|
balance: balance.toString(),
|
||||||
unlocked: unlocked.toString(),
|
unlocked: unlocked.toString(),
|
||||||
@@ -594,40 +640,61 @@ const rpc = BrowserView.defineRPC<RPC>({
|
|||||||
},
|
},
|
||||||
moneroGetTransactions: async () => {
|
moneroGetTransactions: async () => {
|
||||||
console.log('[rpc] moneroGetTransactions')
|
console.log('[rpc] moneroGetTransactions')
|
||||||
if (!moneroManager) throw new Error('Monero wallet RPC not started')
|
if (!moneroManager)
|
||||||
|
throw new Error('Monero wallet RPC not started')
|
||||||
const txs = await moneroManager.getTransactions()
|
const txs = await moneroManager.getTransactions()
|
||||||
const result = (txs || []).map((tx: any) => ({
|
const result = (txs || []).map((tx: any) => ({
|
||||||
hash: tx.hash,
|
hash: tx.hash,
|
||||||
amount: tx.amount?.toString() ?? '0',
|
amount: tx.amount?.toString() ?? '0',
|
||||||
timestamp: String(tx.timestamp ?? 0),
|
timestamp: String(tx.timestamp ?? 0),
|
||||||
direction: tx.direction === 'in' ? 'in' as const : 'out' as const,
|
direction:
|
||||||
|
tx.direction === 'in'
|
||||||
|
? ('in' as const)
|
||||||
|
: ('out' as const),
|
||||||
height: tx.height ?? 0,
|
height: tx.height ?? 0,
|
||||||
}))
|
}))
|
||||||
console.log(`[rpc] moneroGetTransactions: ${result.length} txs`)
|
console.log(
|
||||||
|
`[rpc] moneroGetTransactions: ${result.length} txs`,
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
moneroWalletStatus: async () => {
|
moneroWalletStatus: async () => {
|
||||||
if (!moneroManager) {
|
if (!moneroManager) {
|
||||||
console.log('[rpc] moneroWalletStatus: not running')
|
console.log('[rpc] moneroWalletStatus: not running')
|
||||||
return { running: false, walletOpen: false, connected: false }
|
return {
|
||||||
|
running: false,
|
||||||
|
walletOpen: false,
|
||||||
|
connected: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const connected = await moneroManager.isConnected()
|
const connected = await moneroManager.isConnected()
|
||||||
const walletOpen = await moneroManager.isWalletOpen()
|
const walletOpen = await moneroManager.isWalletOpen()
|
||||||
console.log('[rpc] moneroWalletStatus: running, walletOpen:', walletOpen, 'connected:', connected)
|
console.log(
|
||||||
|
'[rpc] moneroWalletStatus: running, walletOpen:',
|
||||||
|
walletOpen,
|
||||||
|
'connected:',
|
||||||
|
connected,
|
||||||
|
)
|
||||||
return { running: true, walletOpen, connected }
|
return { running: true, walletOpen, connected }
|
||||||
},
|
},
|
||||||
moneroGetAccounts: async () => {
|
moneroGetAccounts: async () => {
|
||||||
console.log('[rpc] moneroGetAccounts')
|
console.log('[rpc] moneroGetAccounts')
|
||||||
if (!moneroManager) throw new Error('Monero wallet RPC not started')
|
if (!moneroManager)
|
||||||
|
throw new Error('Monero wallet RPC not started')
|
||||||
const accounts = await moneroManager.getAccounts()
|
const accounts = await moneroManager.getAccounts()
|
||||||
console.log(`[rpc] moneroGetAccounts: ${accounts.length} accounts`)
|
console.log(
|
||||||
|
`[rpc] moneroGetAccounts: ${accounts.length} accounts`,
|
||||||
|
)
|
||||||
return accounts
|
return accounts
|
||||||
},
|
},
|
||||||
moneroListWallets: async () => {
|
moneroListWallets: async () => {
|
||||||
console.log('[rpc] moneroListWallets')
|
console.log('[rpc] moneroListWallets')
|
||||||
if (!moneroManager) throw new Error('Monero wallet RPC not started')
|
if (!moneroManager)
|
||||||
|
throw new Error('Monero wallet RPC not started')
|
||||||
const wallets = moneroManager.listWallets()
|
const wallets = moneroManager.listWallets()
|
||||||
console.log(`[rpc] moneroListWallets: ${wallets.join(', ') || 'none'}`)
|
console.log(
|
||||||
|
`[rpc] moneroListWallets: ${wallets.join(', ') || 'none'}`,
|
||||||
|
)
|
||||||
return wallets
|
return wallets
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import { eq, and } from 'drizzle-orm'
|
|
||||||
import { db } from '../db'
|
|
||||||
import { tokenMetadata } from '../db/schema'
|
|
||||||
import { post } from './call'
|
import { post } from './call'
|
||||||
import { getChainID, getURL } from './network'
|
import { getChainID, getURL } from './network'
|
||||||
|
|
||||||
@@ -12,36 +9,9 @@ export const getTokenMetadata = async (
|
|||||||
const chainId = getChainID(chainid)
|
const chainId = getChainID(chainid)
|
||||||
if (!chainId) return
|
if (!chainId) return
|
||||||
|
|
||||||
const cached = db
|
const url = getURL({ chainId, key })
|
||||||
.select()
|
|
||||||
.from(tokenMetadata)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(tokenMetadata.chainId, chainId),
|
|
||||||
eq(tokenMetadata.contractAddress, tokenAddress.toLowerCase()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.get()
|
|
||||||
|
|
||||||
if (cached) {
|
return await post<{
|
||||||
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 url = getURL({
|
|
||||||
chainId,
|
|
||||||
key,
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await post<{
|
|
||||||
name: string | null
|
name: string | null
|
||||||
symbol: string | null
|
symbol: string | null
|
||||||
decimals: number | null
|
decimals: number | null
|
||||||
@@ -52,24 +22,4 @@ export const getTokenMetadata = async (
|
|||||||
method: 'alchemy_getTokenMetadata',
|
method: 'alchemy_getTokenMetadata',
|
||||||
params: [tokenAddress],
|
params: [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,4 +1,4 @@
|
|||||||
import { int, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
|
import { int, sqliteTable, text, uniqueIndex, index } from 'drizzle-orm/sqlite-core'
|
||||||
|
|
||||||
export const tokenMetadata = sqliteTable(
|
export const tokenMetadata = sqliteTable(
|
||||||
'token_metadata',
|
'token_metadata',
|
||||||
@@ -71,3 +71,28 @@ export const transactionReceipts = sqliteTable(
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { getTokenMetadata } from './get-metadata'
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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'
|
||||||
|
import { getTransactionDetails as fetchFromViem } from '../viem/get-transaction-details'
|
||||||
|
|
||||||
|
export const getTransactionDetails = async (
|
||||||
|
client: PublicClient,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await fetchFromViem(client, hash)
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: raw.tx.hash,
|
||||||
|
from: raw.tx.from,
|
||||||
|
to: raw.tx.to,
|
||||||
|
value: formatEther(raw.tx.value),
|
||||||
|
blockNumber: raw.receipt.blockNumber?.toString() ?? null,
|
||||||
|
fee: (
|
||||||
|
parseFloat(formatGwei(raw.receipt.gasUsed)) *
|
||||||
|
parseFloat(formatGwei(raw.tx.gasPrice || BigInt(0)))
|
||||||
|
).toString(),
|
||||||
|
gasPrice: formatGwei(raw.tx.gasPrice || BigInt(0)),
|
||||||
|
status: raw.receipt.status === 'success' ? 'success' : 'reverted',
|
||||||
|
type: raw.tx.type,
|
||||||
|
nonce: raw.tx.nonce,
|
||||||
|
input: raw.tx.input,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { eq, and, or, desc } from 'drizzle-orm'
|
||||||
|
import { db } from '../db'
|
||||||
|
import { txHistory } from '../db/schema'
|
||||||
|
import { getTransactionHistory as fetchFromAlchemy } from '../alchemy/get-transaction-history'
|
||||||
|
import type { Transaction } from '../alchemy/types'
|
||||||
|
|
||||||
|
export const getTransactionHistory = async (
|
||||||
|
key: string,
|
||||||
|
chainid: string,
|
||||||
|
address: string,
|
||||||
|
opts: { maxCount: number },
|
||||||
|
) => {
|
||||||
|
const result = await fetchFromAlchemy(key, chainid, address, opts)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
for (const t of result) {
|
||||||
|
if (!t.hash) continue
|
||||||
|
const existing = await db.query.txHistory.findFirst({
|
||||||
|
where: {
|
||||||
|
hash: t.hash,
|
||||||
|
chainId: chainid,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await db.insert(txHistory).values({
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { getTransactionHistory } from './get-history'
|
||||||
|
export { getTransactionDetails } from './get-details'
|
||||||
@@ -1,162 +1,50 @@
|
|||||||
import { eq, and } from 'drizzle-orm'
|
import { isHex, type PublicClient } from 'viem'
|
||||||
import {
|
|
||||||
isHex,
|
|
||||||
formatEther,
|
|
||||||
type PublicClient,
|
|
||||||
formatGwei,
|
|
||||||
} from 'viem'
|
|
||||||
import { db } from '../db'
|
|
||||||
import { transactions, transactionReceipts } from '../db/schema'
|
|
||||||
import { getChainID } from '../alchemy/network'
|
|
||||||
|
|
||||||
export type TransactionDetails = {
|
|
||||||
hash: string
|
|
||||||
from: string
|
|
||||||
to: string | null
|
|
||||||
value: string
|
|
||||||
blockNumber: string | null
|
|
||||||
fee: string
|
|
||||||
gasPrice: string
|
|
||||||
status: 'success' | 'reverted'
|
|
||||||
type: string
|
|
||||||
nonce: number
|
|
||||||
input: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getTransactionDetails = async (
|
export const getTransactionDetails = async (
|
||||||
client: PublicClient,
|
client: PublicClient,
|
||||||
hash: string,
|
hash: string,
|
||||||
chainId: string,
|
) => {
|
||||||
): Promise<TransactionDetails | null> => {
|
|
||||||
if (!isHex(hash)) return null
|
if (!isHex(hash)) 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [tx, receipt] = await Promise.all([
|
const [tx, receipt] = await Promise.all([
|
||||||
client.getTransaction({ hash }),
|
client.getTransaction({ hash }),
|
||||||
client.getTransactionReceipt({ hash }),
|
client.getTransactionReceipt({ hash }),
|
||||||
])
|
])
|
||||||
|
|
||||||
try {
|
|
||||||
await db
|
|
||||||
.insert(transactions)
|
|
||||||
.values({
|
|
||||||
hash: hashLower,
|
|
||||||
chainId: id,
|
|
||||||
from: tx.from.toLowerCase(),
|
|
||||||
to: tx.to?.toLowerCase() ?? null,
|
|
||||||
value: tx.value.toString(),
|
|
||||||
blockNumber: tx.blockNumber?.toString() ?? null,
|
|
||||||
blockHash: tx.blockHash?.toLowerCase() ?? null,
|
|
||||||
gas: tx.gas.toString(),
|
|
||||||
gasPrice: tx.gasPrice?.toString() ?? null,
|
|
||||||
maxFeePerGas: tx.maxFeePerGas?.toString() ?? null,
|
|
||||||
maxPriorityFeePerGas:
|
|
||||||
tx.maxPriorityFeePerGas?.toString() ?? null,
|
|
||||||
input: tx.input,
|
|
||||||
nonce: tx.nonce,
|
|
||||||
transactionIndex: tx.transactionIndex ?? null,
|
|
||||||
type: tx.type ?? null,
|
|
||||||
v: tx.v?.toString() ?? null,
|
|
||||||
r: tx.r?.toLowerCase() ?? null,
|
|
||||||
s: tx.s?.toLowerCase() ?? null,
|
|
||||||
yParity: tx.yParity ?? null,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing()
|
|
||||||
} catch (e) {
|
|
||||||
console.log('Failed to cache transaction', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db
|
|
||||||
.insert(transactionReceipts)
|
|
||||||
.values({
|
|
||||||
transactionHash: hashLower,
|
|
||||||
chainId: id,
|
|
||||||
blockHash: receipt.blockHash.toLowerCase(),
|
|
||||||
blockNumber: receipt.blockNumber.toString(),
|
|
||||||
from: receipt.from.toLowerCase(),
|
|
||||||
to: receipt.to?.toLowerCase() ?? null,
|
|
||||||
cumulativeGasUsed: receipt.cumulativeGasUsed.toString(),
|
|
||||||
gasUsed: receipt.gasUsed.toString(),
|
|
||||||
status: receipt.status,
|
|
||||||
effectiveGasPrice:
|
|
||||||
receipt.effectiveGasPrice?.toString() ?? null,
|
|
||||||
contractAddress:
|
|
||||||
receipt.contractAddress?.toLowerCase() ?? null,
|
|
||||||
type: receipt.type,
|
|
||||||
transactionIndex: receipt.transactionIndex,
|
|
||||||
logsBloom: receipt.logsBloom?.toLowerCase() ?? null,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing()
|
|
||||||
} catch (e) {
|
|
||||||
console.log('Failed to cache transaction receipt', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hash: tx.hash,
|
tx: {
|
||||||
from: tx.from,
|
hash: tx.hash,
|
||||||
to: tx.to,
|
from: tx.from,
|
||||||
value: formatEther(tx.value),
|
to: tx.to ?? null,
|
||||||
blockNumber: receipt.blockNumber?.toString() ?? null,
|
value: tx.value,
|
||||||
fee: (
|
blockNumber: tx.blockNumber ?? null,
|
||||||
parseFloat(formatGwei(receipt.gasUsed)) *
|
gasPrice: tx.gasPrice ?? null,
|
||||||
parseFloat(formatGwei(tx.gasPrice || BigInt(0)))
|
gas: tx.gas,
|
||||||
).toString(),
|
input: tx.input,
|
||||||
gasPrice: formatGwei(tx.gasPrice || BigInt(0)),
|
nonce: tx.nonce,
|
||||||
status: receipt.status === 'success' ? 'success' : 'reverted',
|
type: tx.type ?? 'legacy',
|
||||||
type: tx.type,
|
blockHash: tx.blockHash ?? null,
|
||||||
nonce: tx.nonce,
|
maxFeePerGas: tx.maxFeePerGas ?? null,
|
||||||
input: tx.input,
|
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,2 +1,2 @@
|
|||||||
export * from './getClient'
|
export * from './getClient'
|
||||||
export * from './get-transaction-details'
|
export { getTransactionDetails } from './get-transaction-details'
|
||||||
|
|||||||
Reference in New Issue
Block a user