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:
2026-05-18 03:03:34 +08:00
parent 135d651622
commit 9e76bd54ce
10 changed files with 466 additions and 235 deletions
@@ -1,6 +1,3 @@
import { eq, and } from 'drizzle-orm'
import { db } from '../db'
import { tokenMetadata } from '../db/schema'
import { post } from './call'
import { getChainID, getURL } from './network'
@@ -12,36 +9,9 @@ export const getTokenMetadata = async (
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()
const url = getURL({ chainId, key })
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 url = getURL({
chainId,
key,
})
const result = await post<{
return await post<{
name: string | null
symbol: string | null
decimals: number | null
@@ -52,24 +22,4 @@ export const getTokenMetadata = async (
method: 'alchemy_getTokenMetadata',
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
}
+26 -1
View File
@@ -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(
'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
}
+1
View File
@@ -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,
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
}
import { isHex, type PublicClient } from 'viem'
export const getTransactionDetails = async (
client: PublicClient,
hash: string,
chainId: string,
): Promise<TransactionDetails | 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([
client.getTransaction({ 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 {
hash: tx.hash,
from: tx.from,
to: tx.to,
value: formatEther(tx.value),
blockNumber: receipt.blockNumber?.toString() ?? null,
fee: (
parseFloat(formatGwei(receipt.gasUsed)) *
parseFloat(formatGwei(tx.gasPrice || BigInt(0)))
).toString(),
gasPrice: formatGwei(tx.gasPrice || BigInt(0)),
status: receipt.status === 'success' ? 'success' : 'reverted',
type: tx.type,
nonce: tx.nonce,
input: tx.input,
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 -1
View File
@@ -1,2 +1,2 @@
export * from './getClient'
export * from './get-transaction-details'
export { getTransactionDetails } from './get-transaction-details'