2026-04-10 12:52:17 -07:00
|
|
|
import { ApiResponse } from './client'
|
|
|
|
|
import { fetchBlockscoutJson, normalizeTransaction, type BlockscoutInternalTransaction } from './blockscout'
|
|
|
|
|
import { resolveExplorerApiBase } from '../../../libs/frontend-api-client/api-base'
|
2026-02-10 18:43:37 -08:00
|
|
|
|
|
|
|
|
export interface Transaction {
|
|
|
|
|
chain_id: number
|
|
|
|
|
hash: string
|
|
|
|
|
block_number: number
|
|
|
|
|
block_hash: string
|
|
|
|
|
transaction_index: number
|
|
|
|
|
from_address: string
|
|
|
|
|
to_address?: string
|
|
|
|
|
value: string
|
|
|
|
|
gas_price?: number
|
|
|
|
|
max_fee_per_gas?: number
|
|
|
|
|
max_priority_fee_per_gas?: number
|
|
|
|
|
gas_limit: number
|
|
|
|
|
gas_used?: number
|
|
|
|
|
status?: number
|
|
|
|
|
input_data?: string
|
|
|
|
|
contract_address?: string
|
|
|
|
|
created_at: string
|
2026-04-10 12:52:17 -07:00
|
|
|
fee?: string
|
|
|
|
|
method?: string
|
|
|
|
|
revert_reason?: string
|
|
|
|
|
transaction_tag?: string
|
|
|
|
|
decoded_input?: {
|
|
|
|
|
method_call?: string
|
|
|
|
|
method_id?: string
|
|
|
|
|
parameters: Array<{
|
|
|
|
|
name?: string
|
|
|
|
|
type?: string
|
|
|
|
|
value?: unknown
|
|
|
|
|
}>
|
|
|
|
|
}
|
|
|
|
|
token_transfers?: TransactionTokenTransfer[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface TransactionTokenTransfer {
|
|
|
|
|
block_number?: number
|
|
|
|
|
from_address: string
|
|
|
|
|
from_label?: string
|
|
|
|
|
to_address: string
|
|
|
|
|
to_label?: string
|
|
|
|
|
token_address: string
|
|
|
|
|
token_name?: string
|
|
|
|
|
token_symbol?: string
|
|
|
|
|
token_decimals: number
|
|
|
|
|
amount: string
|
|
|
|
|
type?: string
|
|
|
|
|
timestamp?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface TransactionInternalCall {
|
|
|
|
|
from_address: string
|
|
|
|
|
from_label?: string
|
|
|
|
|
to_address?: string
|
|
|
|
|
to_label?: string
|
|
|
|
|
contract_address?: string
|
|
|
|
|
contract_label?: string
|
|
|
|
|
type?: string
|
|
|
|
|
value: string
|
|
|
|
|
success?: boolean
|
|
|
|
|
error?: string
|
|
|
|
|
result?: string
|
|
|
|
|
timestamp?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface TransactionLookupDiagnostic {
|
|
|
|
|
checked_hash: string
|
|
|
|
|
chain_id: number
|
|
|
|
|
explorer_indexed: boolean
|
|
|
|
|
rpc_transaction_found: boolean
|
|
|
|
|
rpc_receipt_found: boolean
|
|
|
|
|
latest_block_number?: number
|
|
|
|
|
rpc_url?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CHAIN_138_PUBLIC_RPC_URL = 'https://rpc-http-pub.d-bis.org'
|
|
|
|
|
|
|
|
|
|
function resolvePublicRpcUrl(chainId: number): string | null {
|
|
|
|
|
if (chainId !== 138) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const envValue = (process.env.NEXT_PUBLIC_CHAIN_138_RPC_URL || '').trim()
|
|
|
|
|
return envValue || CHAIN_138_PUBLIC_RPC_URL
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchJsonWithStatus<T>(input: RequestInfo | URL, init?: RequestInit): Promise<{ ok: boolean; status: number; data: T | null }> {
|
|
|
|
|
const response = await fetch(input, init)
|
|
|
|
|
let data: T | null = null
|
|
|
|
|
try {
|
|
|
|
|
data = (await response.json()) as T
|
|
|
|
|
} catch {
|
|
|
|
|
data = null
|
|
|
|
|
}
|
|
|
|
|
return { ok: response.ok, status: response.status, data }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchRpcResult<T>(rpcUrl: string, method: string, params: unknown[]): Promise<T | null> {
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const timeout = setTimeout(() => controller.abort(), 6000)
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(rpcUrl, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
jsonrpc: '2.0',
|
|
|
|
|
id: 1,
|
|
|
|
|
method,
|
|
|
|
|
params,
|
|
|
|
|
}),
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
const payload = (await response.json()) as { result?: T | null }
|
|
|
|
|
return payload.result ?? null
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
} finally {
|
|
|
|
|
clearTimeout(timeout)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function diagnoseMissingTransaction(chainId: number, hash: string): Promise<TransactionLookupDiagnostic> {
|
|
|
|
|
const diagnostic: TransactionLookupDiagnostic = {
|
|
|
|
|
checked_hash: hash,
|
|
|
|
|
chain_id: chainId,
|
|
|
|
|
explorer_indexed: false,
|
|
|
|
|
rpc_transaction_found: false,
|
|
|
|
|
rpc_receipt_found: false,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const explorerLookup = await fetchJsonWithStatus<unknown>(`${resolveExplorerApiBase()}/api/v2/transactions/${hash}`)
|
|
|
|
|
diagnostic.explorer_indexed = explorerLookup.ok
|
|
|
|
|
|
|
|
|
|
const rpcUrl = resolvePublicRpcUrl(chainId)
|
|
|
|
|
if (!rpcUrl) {
|
|
|
|
|
return diagnostic
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
diagnostic.rpc_url = rpcUrl
|
|
|
|
|
|
|
|
|
|
const [transactionResult, receiptResult, latestBlockHex] = await Promise.all([
|
|
|
|
|
fetchRpcResult<string | Record<string, unknown>>(rpcUrl, 'eth_getTransactionByHash', [hash]),
|
|
|
|
|
fetchRpcResult<string | Record<string, unknown>>(rpcUrl, 'eth_getTransactionReceipt', [hash]),
|
|
|
|
|
fetchRpcResult<string>(rpcUrl, 'eth_blockNumber', []),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
diagnostic.rpc_transaction_found = transactionResult != null
|
|
|
|
|
diagnostic.rpc_receipt_found = receiptResult != null
|
|
|
|
|
|
|
|
|
|
if (typeof latestBlockHex === 'string' && latestBlockHex.startsWith('0x')) {
|
|
|
|
|
diagnostic.latest_block_number = parseInt(latestBlockHex, 16)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return diagnostic
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeInternalTransactions(items: BlockscoutInternalTransaction[] | null | undefined): TransactionInternalCall[] {
|
|
|
|
|
if (!Array.isArray(items)) {
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return items.map((item) => ({
|
|
|
|
|
from_address: item.from?.hash || '',
|
|
|
|
|
from_label: item.from?.name || item.from?.label || undefined,
|
|
|
|
|
to_address: item.to?.hash || undefined,
|
|
|
|
|
to_label: item.to?.name || item.to?.label || undefined,
|
|
|
|
|
contract_address: item.created_contract?.hash || undefined,
|
|
|
|
|
contract_label: item.created_contract?.name || item.created_contract?.label || undefined,
|
|
|
|
|
type: item.type || undefined,
|
|
|
|
|
value: item.value || '0',
|
|
|
|
|
success: item.success ?? undefined,
|
|
|
|
|
error: item.error || undefined,
|
|
|
|
|
result: item.result || undefined,
|
|
|
|
|
timestamp: item.timestamp || undefined,
|
|
|
|
|
}))
|
2026-02-10 18:43:37 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const transactionsApi = {
|
|
|
|
|
get: async (chainId: number, hash: string): Promise<ApiResponse<Transaction>> => {
|
2026-03-28 00:21:18 -07:00
|
|
|
const raw = await fetchBlockscoutJson<unknown>(`/api/v2/transactions/${hash}`)
|
|
|
|
|
return { data: normalizeTransaction(raw as never, chainId) }
|
2026-02-10 18:43:37 -08:00
|
|
|
},
|
2026-03-02 12:14:13 -08:00
|
|
|
/** Use when you need to check response.ok before setting state (avoids treating 4xx/5xx body as data). */
|
|
|
|
|
getSafe: async (chainId: number, hash: string): Promise<{ ok: boolean; data: Transaction | null }> => {
|
2026-03-28 00:21:18 -07:00
|
|
|
try {
|
|
|
|
|
const { data } = await transactionsApi.get(chainId, hash)
|
|
|
|
|
return { ok: true, data }
|
|
|
|
|
} catch {
|
|
|
|
|
return { ok: false, data: null }
|
|
|
|
|
}
|
2026-03-02 12:14:13 -08:00
|
|
|
},
|
2026-04-10 12:52:17 -07:00
|
|
|
diagnoseMissing: async (chainId: number, hash: string): Promise<TransactionLookupDiagnostic> => {
|
|
|
|
|
return diagnoseMissingTransaction(chainId, hash)
|
|
|
|
|
},
|
|
|
|
|
getInternalTransactionsSafe: async (hash: string): Promise<{ ok: boolean; data: TransactionInternalCall[] }> => {
|
|
|
|
|
try {
|
|
|
|
|
const raw = await fetchBlockscoutJson<{ items?: BlockscoutInternalTransaction[] } | BlockscoutInternalTransaction[]>(
|
|
|
|
|
`/api/v2/transactions/${hash}/internal-transactions`
|
|
|
|
|
)
|
|
|
|
|
const items = Array.isArray(raw) ? raw : raw.items
|
|
|
|
|
return { ok: true, data: normalizeInternalTransactions(items) }
|
|
|
|
|
} catch {
|
|
|
|
|
return { ok: false, data: [] }
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-03-02 12:14:13 -08:00
|
|
|
list: async (chainId: number, page: number, pageSize: number): Promise<ApiResponse<Transaction[]>> => {
|
|
|
|
|
const params = new URLSearchParams({
|
|
|
|
|
page: page.toString(),
|
|
|
|
|
page_size: pageSize.toString(),
|
|
|
|
|
})
|
2026-03-28 00:21:18 -07:00
|
|
|
const raw = await fetchBlockscoutJson<{ items?: unknown[] }>(`/api/v2/transactions?${params.toString()}`)
|
|
|
|
|
const data = Array.isArray(raw?.items) ? raw.items.map((item) => normalizeTransaction(item as never, chainId)) : []
|
2026-03-02 12:14:13 -08:00
|
|
|
return { data }
|
|
|
|
|
},
|
|
|
|
|
/** Use when you need to check ok before setting state (avoids treating error body as list). */
|
|
|
|
|
listSafe: async (chainId: number, page: number, pageSize: number): Promise<{ ok: boolean; data: Transaction[] }> => {
|
|
|
|
|
try {
|
2026-03-28 00:21:18 -07:00
|
|
|
const { data } = await transactionsApi.list(chainId, page, pageSize)
|
2026-03-02 12:14:13 -08:00
|
|
|
return { ok: true, data }
|
|
|
|
|
} catch {
|
|
|
|
|
return { ok: false, data: [] }
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-02-10 18:43:37 -08:00
|
|
|
}
|