🌳
pt0/serverF/ethpayF/libEtherscanF.mts
1import * as _ from 'lodash-es'
2import { DateTime } from 'luxon'
3import { EtherscanProvider, formatEther } from 'ethers'
5import { roundedGwei } from './gweiUtilsF.mts'
8const latestBlock = 'latest'
10// Normalize Alchemy alchemy_getAssetTransfers response to Etherscan-compatible format
11const normalizeAlchemyTx = (tx: {blockNum: string, hash: string, from: string, to: string, rawContract: {value: string}, metadata: {blockTimestamp: string}}) => {
12 const {blockNum, hash, from, to, rawContract, metadata} = tx
13 return {
14 blockNumber: String(parseInt(blockNum, 16)),
15 hash,
16 from,
17 to,
18 value: String(BigInt(rawContract.value)), // hex wei -> decimal wei string
19 timestamp: Math.floor(new Date(metadata.blockTimestamp).getTime() / 1000),
20 isError: '0',
21 confirmations: '1', // Alchemy only returns confirmed txs
22 }
25// Normalize Etherscan response format
26const normalizeEtherscanTx = (tx: Record<string, string>) => {
27 const normalized: Record<string, string | number> = {...tx}
28 _.each(['contractAddress', 'to'] as const, (key) => {
29 if (normalized[key] === '') delete normalized[key]
30 })
31 if (normalized.contractAddress || normalized.creates) return null // skip contract creation
32 delete normalized.type
33 normalized.nonce ||= '12345'
34 if (normalized.timeStamp) normalized.timestamp = parseInt(String(normalized.timeStamp))
35 return normalized
38// endblock default must exceed chain block height (Arbitrum ~445M) or V2 API returns empty results
39const fetchEtherscanTxs = async ({provider, address, startBlock, endBlock}: {provider: EtherscanProvider, address: string, startBlock?: number, endBlock?: number}) => {
40 const results = await Promise.all(_.map(['txlist', 'txlistinternal'] as const, async (action) => {
41 try {
42 return await provider.fetch('account', {
43 action, address,
44 startblock: startBlock ?? 0,
45 endblock: endBlock ?? 9999999999,
46 sort: 'asc',
47 })
48 } catch (err) {
49 (err as any).dumpDebugH = {...(err as any).dumpDebugH, address, startBlock, endBlock, action}
50 throw err
51 }
52 }))
53 return _.compact(_.flatten(results).map((tx) => normalizeEtherscanTx(tx as Record<string, string>)))
56const fetchAlchemyTxs = async ({provider, address, startBlock, endBlock}: {provider: {send: (method: string, params: unknown[]) => Promise<{transfers?: unknown[]}>}, address: string, startBlock?: number, endBlock?: number}) => {
57 // Note: 'internal' category only supported for ETH/MATIC, so we use 'external' only
58 // Use 'latest' for toBlock to support L2s with high block numbers (Arbitrum: 400M+)
59 // Query both toAddress and fromAddress to catch self-transfers (where from === to)
60 const baseParams = {
61 category: ['external'],
62 fromBlock: `0x${Number(startBlock ?? 0).toString(16)}`,
63 toBlock: endBlock ? `0x${Number(endBlock).toString(16)}` : latestBlock,
64 withMetadata: true
65 }
66 try {
67 const [toResult, fromResult] = await Promise.all([
68 provider.send('alchemy_getAssetTransfers', [{...baseParams, toAddress: address}]),
69 provider.send('alchemy_getAssetTransfers', [{...baseParams, fromAddress: address}]),
70 ])
71 const allTransfers = [...(toResult.transfers || []), ...(fromResult.transfers || [])] as Parameters<typeof normalizeAlchemyTx>[0][]
72 return _.uniqBy(_.map(allTransfers, normalizeAlchemyTx), 'hash')
73 } catch (err) {
74 (err as any).dumpDebugH = {...(err as any).dumpDebugH, address, startBlock, endBlock}
75 throw err
76 }
79type EthTx = {to?: string, isError: string, confirmations: string, from: string, value: string, timestamp: number, hash: string, blockNumber: string}
80export const getEtherTxs = async ({address, startBlock, endBlock, ethersProvider: provider}: {address: string, startBlock?: number, endBlock?: number, ethersProvider: EtherscanProvider | {send: (method: string, params: unknown[]) => Promise<{transfers?: unknown[]}>}}) => {
81 const isEtherscan = provider instanceof EtherscanProvider
82 let retA: EthTx[] = isEtherscan
83 ? await fetchEtherscanTxs({provider, address, startBlock, endBlock}) as EthTx[]
84 : await fetchAlchemyTxs({provider, address, startBlock, endBlock}) as EthTx[]
86 retA = _.filter(retA, (trans: EthTx) => {
87 const {to, isError, confirmations} = trans
88 const aaa = _.toLower(to) != _.toLower(address)
89 const bbb = isError != '0'
90 const ccc = _.parseInt(confirmations) == 0
92 if (aaa) return false // outgoing tx
94 if (bbb || ccc) {
95 noThrowNotifErr('weirdEthTransaction', {address, to}, {aaa, bbb, ccc})
96 return false
97 }
99 return true
100 })
102 return _.map(retA, decorEthTx)
105export const decorTsDate = <T extends {timestamp: number}>(tx: T) => {
106 const {timestamp} = tx
107 const tsDt = DateTime.fromSeconds(timestamp)
108 return {...tx, tsDt}
111export const decorEthTx = (tx: EthTx) => {
112 const {from, value, timestamp, hash, ...restH} = tx
113 const eth_gwei = roundedGwei({value})
114 const amtEthF = _.toNumber(formatEther(value))
115 return decorTsDate({...tx, amtEthF, eth_gwei})