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, 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) 20 targetCommitteeIdx = parseInt(c.index) 21 targetPositionInCommittee = pos 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 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 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} 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), 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}) 86 return await runMemoTempfile({cacheKeyA: ['val-duty', source, String(epoch), validatorIdx]}, 88 const r = await fetchDutyRaw({httpHostPort, epoch, validatorIdx}) 89 if (r && 'pruned' in r) throw prunedSentinel 93 if (e === prunedSentinel) return prunedSentinel 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 105 return currentEpoch - epoch < cacheFreshnessEpochs 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, 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, 123 const timestamp = slotToTimestamp(attestSlot) 125 ethValidatorNum, slot: attestSlot, epoch, inclusionSlot, 126 timestamp, iso: new Date(timestamp * 1000).toISOString(), source, 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 })) 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}) 160 const searchedSec = (currentEpoch - oldestQueriedEpoch) * slotsPerEpoch * slotDurationSec 161 const durStr = hitPruneLimit ? secToHumanAbs(searchedSec) || '0s' : maxSearchDurHuman 162 const errSuffix = fetchErrors ? ` (${fetchErrors} fetch errors)` : '' 164 ethValidatorNum, source, hitPruneLimit, 165 error: `no attestation in > ${durStr}${errSuffix}`, 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}`) 177 podName: podHealth.podName, 178 portNo: consClient.portNo, 179 checkerFn: ({httpHostPort}: {httpHostPort: string}) => queryBeaconForAttestation({httpHostPort, ethValidatorNum, source: cluster_name}), 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 ({ 192 beaconFallbackCfgs = [], 194 ethValidatorNum: number, 195 cluster_name?: string, 196 consClient?: {fuzzyPodName: string, portNo: number}, 197 beaconFallbackCfgs?: BeaconFallbackCfg[], 201 return await lastValidatedAtLocalBeacon({ethValidatorNum, cluster_name, consClient: consClient!}) 203 return {error: (err as Error).message, ethValidatorNum} 206 const failedSources: string[] = [] 207 for (const cfg of beaconFallbackCfgs) { 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 215 failedSources.push(cfg.cluster_name) 219 const result = await lastValidatedAtPublicBeacon({ethValidatorNum}) as any 220 result.failedSources = failedSources 223 failedSources.push('publicnode') 224 return {error: `all sources failed: ${failedSources.join(', ')}`, ethValidatorNum, failedSources} 228export { lastValidatedAtLocalBeacon }