🌳
pt0/deployF/ethF/ethValidatorInfoAI.mts
6const mainnetGenesisSec = 1606824023, slotDurationSec = 12, slotsPerEpoch = 32
8const slotToTimestamp = (slot: number) => mainnetGenesisSec + (slot * slotDurationSec)
10const findAttestationInBlocks = async ({httpHostPort, attestSlot, headSlot, committees, targetValidatorIdx}: {
11 httpHostPort: string, attestSlot: number, headSlot: number, committees: any[], targetValidatorIdx: string,
12}) => {
13 const slotCommittees = committees.filter(c => parseInt(c.slot) === attestSlot)
14 .sort((a, b) => parseInt(a.index) - parseInt(b.index))
16 let targetCommitteeIdx = -1, targetPositionInCommittee = -1
17 for (const c of slotCommittees) {
18 const pos = c.validators.indexOf(targetValidatorIdx)
19 if (pos !== -1) {
20 targetCommitteeIdx = parseInt(c.index)
21 targetPositionInCommittee = pos
22 break
23 }
24 }
25 if (targetCommitteeIdx === -1) return null
27 for (let checkSlot = attestSlot + 1; checkSlot <= Math.min(attestSlot + 32, headSlot); checkSlot++) {
28 const blockResp = await fetch(`${httpHostPort}/eth/v2/beacon/blocks/${checkSlot}`, {signal: AbortSignal.timeout(5000)})
29 if (blockResp.status !== 200) continue
31 const blockJson = await blockResp.json()
32 const attestations = blockJson.data.message.body.attestations || []
34 for (const att of attestations) {
35 if (parseInt(att.data.slot) !== attestSlot) continue
37 const committeeBits = att.committee_bits?.slice(2) || ''
38 const aggBits = att.aggregation_bits?.slice(2) || ''
40 const cByteIdx = Math.floor(targetCommitteeIdx / 8), cBitIdx = targetCommitteeIdx % 8
41 const cByte = parseInt(committeeBits.slice(cByteIdx * 2, cByteIdx * 2 + 2), 16) || 0
42 if (!((cByte >> cBitIdx) & 1)) continue
43 let aggOffset = 0
44 for (let ci = 0; ci < targetCommitteeIdx; ci++) {
45 const byteIdx = Math.floor(ci / 8), bitIdx = ci % 8
46 const byte = parseInt(committeeBits.slice(byteIdx * 2, byteIdx * 2 + 2), 16) || 0
47 if ((byte >> bitIdx) & 1) aggOffset += slotCommittees[ci].validators.length
48 }
50 const ourBitPos = aggOffset + targetPositionInCommittee
51 const aggByteIdx = Math.floor(ourBitPos / 8), aggBitIdx = ourBitPos % 8
52 const aggByte = parseInt(aggBits.slice(aggByteIdx * 2, aggByteIdx * 2 + 2), 16) || 0
53 if ((aggByte >> aggBitIdx) & 1) {
54 return {inclusionSlot: checkSlot}
55 }
56 }
57 }
58 return null
61const publicBeaconUrl = 'https://ethereum-beacon-api.publicnode.com'
63const maxEpochsBack = 1575, dutyBatchSize = 16, cacheFreshnessEpochs = 3
64const maxSearchDurHuman = secToHumanAbs(maxEpochsBack * slotsPerEpoch * slotDurationSec)
66const prunedSentinel = {pruned: true}
68const fetchDutyRaw = async ({httpHostPort, epoch, validatorIdx}: {httpHostPort: string, epoch: number, validatorIdx: string}): Promise<typeof prunedSentinel | {epoch: number, duty: any} | null> => {
69 const resp = await fetch(`${httpHostPort}/eth/v1/validator/duties/attester/${epoch}`, {
70 method: 'POST', headers: {'Content-Type': 'application/json'},
71 body: JSON.stringify([validatorIdx]), signal: AbortSignal.timeout(5000),
72 })
73 if (resp.status === 404 || resp.status === 500) return prunedSentinel
74 if (resp.status !== 200) return null
75 const json = await resp.json()
76 const duty = json.data?.find((d: any) => d.validator_index === validatorIdx)
77 return duty ? {epoch, duty} : null
80const fetchDuty = async ({httpHostPort, epoch, validatorIdx, currentEpoch, source}: {
81 httpHostPort: string, epoch: number, validatorIdx: string, currentEpoch: number, source: string,
82}): Promise<typeof prunedSentinel | {epoch: number, duty: any} | null> => {
83 if (currentEpoch - epoch < cacheFreshnessEpochs)
84 return fetchDutyRaw({httpHostPort, epoch, validatorIdx})
85 try {
86 return await runMemoTempfile({cacheKeyA: ['val-duty', source, String(epoch), validatorIdx]},
87 async () => {
88 const r = await fetchDutyRaw({httpHostPort, epoch, validatorIdx})
89 if (r && 'pruned' in r) throw prunedSentinel
90 return r
91 })
92 } catch (e) {
93 if (e === prunedSentinel) return prunedSentinel
94 throw e
95 }
98const fetchCommittees = async ({httpHostPort, attestSlot, source, currentEpoch}: {httpHostPort: string, attestSlot: number, source: string, currentEpoch: number}) => {
99 const epoch = Math.floor(attestSlot / slotsPerEpoch)
100 const fetcher = async () => {
101 const resp = await fetch(`${httpHostPort}/eth/v1/beacon/states/${attestSlot}/committees?slot=${attestSlot}`, {signal: AbortSignal.timeout(10000)})
102 if (resp.status !== 200) return null
103 return (await resp.json()).data
104 }
105 return currentEpoch - epoch < cacheFreshnessEpochs
106 ? fetcher()
107 : runMemoTempfile({cacheKeyA: ['slot-committees', source, String(attestSlot)]}, fetcher)
110const cachedFindAttestation = ({httpHostPort, attestSlot, headSlot, committees, targetValidatorIdx, source, currentEpoch}: {
111 httpHostPort: string, attestSlot: number, headSlot: number, committees: any[], targetValidatorIdx: string, source: string, currentEpoch: number,
112}) => {
113 const epoch = Math.floor(attestSlot / slotsPerEpoch)
114 return currentEpoch - epoch < cacheFreshnessEpochs
115 ? findAttestationInBlocks({httpHostPort, attestSlot, headSlot, committees, targetValidatorIdx})
116 : runMemoTempfile({cacheKeyA: ['attest-incl', source, String(attestSlot), targetValidatorIdx]},
117 () => findAttestationInBlocks({httpHostPort, attestSlot, headSlot, committees, targetValidatorIdx}))
120const mkAttestResult = ({ethValidatorNum, attestSlot, epoch, inclusionSlot, source}: {
121 ethValidatorNum: number, attestSlot: number, epoch: number, inclusionSlot: number, source: string,
122}) => {
123 const timestamp = slotToTimestamp(attestSlot)
124 return {
125 ethValidatorNum, slot: attestSlot, epoch, inclusionSlot,
126 timestamp, iso: new Date(timestamp * 1000).toISOString(), source,
127 }
130const queryBeaconForAttestation = async ({httpHostPort, ethValidatorNum, source}: {httpHostPort: string, ethValidatorNum: number, source: string}) => {
131 const validatorIdx = String(ethValidatorNum)
132 const headResp = await fetch(`${httpHostPort}/eth/v1/beacon/headers/head`, {signal: AbortSignal.timeout(15000)})
133 const headJson = await headResp.json()
134 const headSlot = parseInt(headJson.data.header.message.slot)
135 const currentEpoch = Math.floor(headSlot / slotsPerEpoch)
137 let oldestQueriedEpoch = currentEpoch, hitPruneLimit = false, fetchErrors = 0
138 outer: for (let offset = 0; offset < maxEpochsBack; offset += dutyBatchSize) {
139 const batchLen = Math.min(dutyBatchSize, maxEpochsBack - offset)
140 const dutyResults = await Promise.all(
141 Array.from({length: batchLen}, (_, i) =>
142 fetchDuty({httpHostPort, epoch: currentEpoch - offset - i, validatorIdx, currentEpoch, source})
143 .catch(() => { fetchErrors++; return null }))
144 )
146 for (const dr of dutyResults) {
147 if (dr && 'pruned' in dr) { hitPruneLimit = true; break outer }
148 if (!dr || !('duty' in dr)) continue
149 oldestQueriedEpoch = dr.epoch
150 const attestSlot = parseInt(dr.duty.slot)
151 if (attestSlot > headSlot) continue
153 const committees = await fetchCommittees({httpHostPort, attestSlot, source, currentEpoch})
154 if (!committees) continue
155 const found = await cachedFindAttestation({httpHostPort, attestSlot, headSlot, committees, targetValidatorIdx: validatorIdx, source, currentEpoch})
156 if (found) return mkAttestResult({ethValidatorNum, attestSlot, epoch: dr.epoch, inclusionSlot: found.inclusionSlot, source})
157 }
158 }
160 const searchedSec = (currentEpoch - oldestQueriedEpoch) * slotsPerEpoch * slotDurationSec
161 const durStr = hitPruneLimit ? secToHumanAbs(searchedSec) || '0s' : maxSearchDurHuman
162 const errSuffix = fetchErrors ? ` (${fetchErrors} fetch errors)` : ''
163 return {
164 ethValidatorNum, source, hitPruneLimit,
165 error: `no attestation in > ${durStr}${errSuffix}`,
166 }
169const lastValidatedAtLocalBeacon = async ({ethValidatorNum, cluster_name, consClient}: {ethValidatorNum: number, cluster_name: string, consClient: {fuzzyPodName: string, portNo: number}}) => {
170 const podHealth = await getPodHealthInfo({cluster_name, fuzzyPodName: consClient.fuzzyPodName})
171 if (!podHealth.isRunning || !podHealth.podName) {
172 throw new Error(`beacon node not running: ${podHealth.status}`)
173 }
176 cluster_name,
177 podName: podHealth.podName,
178 portNo: consClient.portNo,
179 checkerFn: ({httpHostPort}: {httpHostPort: string}) => queryBeaconForAttestation({httpHostPort, ethValidatorNum, source: cluster_name}),
180 })
183const lastValidatedAtPublicBeacon = ({ethValidatorNum}: {ethValidatorNum: number}) =>
184 queryBeaconForAttestation({httpHostPort: publicBeaconUrl, ethValidatorNum, source: 'publicnode'})
186type BeaconFallbackCfg = {cluster_name: string, consClient: {fuzzyPodName: string, portNo: number}}
188export const lastValidatedAt = async ({
189 ethValidatorNum,
190 cluster_name,
191 consClient,
192 beaconFallbackCfgs = [],
193}: {
194 ethValidatorNum: number,
195 cluster_name?: string,
196 consClient?: {fuzzyPodName: string, portNo: number},
197 beaconFallbackCfgs?: BeaconFallbackCfg[],
198} = {} as any) => {
199 if (cluster_name) {
200 try {
201 return await lastValidatedAtLocalBeacon({ethValidatorNum, cluster_name, consClient: consClient!})
202 } catch (err) {
203 return {error: (err as Error).message, ethValidatorNum}
204 }
205 }
206 const failedSources: string[] = []
207 for (const cfg of beaconFallbackCfgs) {
208 if (getOfflineReason(cfg.cluster_name)) { failedSources.push(cfg.cluster_name); continue }
209 try {
210 const result = await lastValidatedAtLocalBeacon({ethValidatorNum, cluster_name: cfg.cluster_name, consClient: cfg.consClient}) as any
211 if (result?.error) { failedSources.push(cfg.cluster_name); continue }
212 if (failedSources.length) result.failedSources = failedSources
213 return result
214 } catch (err) {
215 failedSources.push(cfg.cluster_name)
216 }
217 }
218 try {
219 const result = await lastValidatedAtPublicBeacon({ethValidatorNum}) as any
220 result.failedSources = failedSources
221 return result
222 } catch (err) {
223 failedSources.push('publicnode')
224 return {error: `all sources failed: ${failedSources.join(', ')}`, ethValidatorNum, failedSources}
225 }
228export { lastValidatedAtLocalBeacon }