From a292352b68406af75b321f6dbadc2b7fa4700f9c Mon Sep 17 00:00:00 2001 From: Callum McIntyre Date: Fri, 14 Jul 2023 15:17:06 +0100 Subject: [PATCH] Refactor ClusterProvider to remove legacy web3js dependency (#267) This provider makes 3 requests, and makes their responses available to child components: - `getFirstAvailableBlock` - `getEpochInfo` - `getEpochSchedule` The first 2 are reasonably straightforward, we have basically the same structure as the legacy web3js, with just number/bigint changes. `getEpochSchedule` is a bit more complex. The existing API exposes a class with a bunch of functionality for finding the epoch for a slot, and the first/last slot for an epoch. None of this is RPC functionality, it's all baked into the legacy web3js code. Since the experimental web3js doesn't do any of that, I've copied these functions into the Explorer codebase, as pure functions that take an `EpochSchedule` (pure data returned by the new RPC method) and a slot/epoch (bigint). See existing web3js code here: https://github.com/solana-labs/solana-web3.js/blob/9232d2b1019dc50f852ad70aa81624e751d76161/packages/library-legacy/src/epoch-schedule.ts Also note that I hit a bug in experimental web3js where some of these functions are incorrectly typed as unknown: https://github.com/solana-labs/solana-web3.js/issues/1389 This was easy enough to work around for now I've also moved `localStorageIsAvailable` from `utils/index.ts` to its own `utils/local-storage`. This lets us import it without pulling in the web3js dependency in `utils/index.ts` The result of this PR is that the `ClusterProvider` in the root layout no longer pulls in the legacy web3js dependency. --- app/block/[slot]/layout.tsx | 4 +- .../ClusterModalDeveloperSettings.tsx | 2 +- app/components/SearchBar.tsx | 5 +- app/components/common/Slot.tsx | 2 +- app/epoch/[epoch]/page-client.tsx | 6 +- app/page.tsx | 4 +- app/providers/cluster.tsx | 33 +++++-- app/providers/epoch.tsx | 16 ++-- app/utils/__tests__/epoch-schedule.ts | 84 +++++++++++++++++ app/utils/cluster.ts | 8 +- app/utils/epoch-schedule.ts | 91 +++++++++++++++++++ app/utils/index.ts | 11 --- app/utils/local-storage.ts | 14 +++ tsconfig.json | 2 +- 14 files changed, 240 insertions(+), 42 deletions(-) create mode 100644 app/utils/__tests__/epoch-schedule.ts create mode 100644 app/utils/epoch-schedule.ts create mode 100644 app/utils/local-storage.ts diff --git a/app/block/[slot]/layout.tsx b/app/block/[slot]/layout.tsx index fb5db3b8..1977317c 100644 --- a/app/block/[slot]/layout.tsx +++ b/app/block/[slot]/layout.tsx @@ -15,6 +15,8 @@ import Link from 'next/link'; import { notFound, useSelectedLayoutSegment } from 'next/navigation'; import React, { PropsWithChildren } from 'react'; +import { getEpochForSlot } from '@/app/utils/epoch-schedule'; + type Props = PropsWithChildren<{ params: { slot: string } }>; function BlockLayoutInner({ children, params: { slot } }: Props) { @@ -43,7 +45,7 @@ function BlockLayoutInner({ children, params: { slot } }: Props) { const { block, blockLeader, childSlot, childLeader, parentLeader } = confirmedBlock.data; const showSuccessfulCount = block.transactions.every(tx => tx.meta !== null); const successfulTxs = block.transactions.filter(tx => tx.meta?.err === null); - const epoch = clusterInfo?.epochSchedule.getEpoch(slotNumber); + const epoch = clusterInfo ? getEpochForSlot(clusterInfo.epochSchedule, BigInt(slotNumber)) : undefined; content = ( <> diff --git a/app/components/ClusterModalDeveloperSettings.tsx b/app/components/ClusterModalDeveloperSettings.tsx index 861deea6..b6ebc32a 100644 --- a/app/components/ClusterModalDeveloperSettings.tsx +++ b/app/components/ClusterModalDeveloperSettings.tsx @@ -1,4 +1,4 @@ -import { localStorageIsAvailable } from '@utils/index'; +import { localStorageIsAvailable } from '@utils/local-storage'; import { ChangeEvent } from 'react'; export default function ClusterModalDeveloperSettings() { diff --git a/app/components/SearchBar.tsx b/app/components/SearchBar.tsx index 9a0e3496..58e52fdf 100644 --- a/app/components/SearchBar.tsx +++ b/app/components/SearchBar.tsx @@ -242,7 +242,7 @@ async function buildDomainOptions(connection: Connection, search: string, option } // builds local search options -function buildOptions(rawSearch: string, cluster: Cluster, tokenRegistry: TokenInfoMap, currentEpoch?: number) { +function buildOptions(rawSearch: string, cluster: Cluster, tokenRegistry: TokenInfoMap, currentEpoch?: bigint) { const search = rawSearch.trim(); if (search.length === 0) return []; @@ -285,7 +285,8 @@ function buildOptions(rawSearch: string, cluster: Cluster, tokenRegistry: TokenI ], }); - if (currentEpoch !== undefined && Number(search) <= currentEpoch + 1) { + // Parse as BigInt but not if it starts eg 0x or 0b + if (currentEpoch !== undefined && !(/^0\w/.test(search)) && BigInt(search) <= currentEpoch + 1n) { options.push({ label: 'Epoch', options: [ diff --git a/app/components/common/Slot.tsx b/app/components/common/Slot.tsx index dad4604c..80a19bca 100644 --- a/app/components/common/Slot.tsx +++ b/app/components/common/Slot.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { Copyable } from './Copyable'; type Props = { - slot: number; + slot: number | bigint; link?: boolean; }; export function Slot({ slot, link }: Props) { diff --git a/app/epoch/[epoch]/page-client.tsx b/app/epoch/[epoch]/page-client.tsx index cc0e7e99..a13b896f 100644 --- a/app/epoch/[epoch]/page-client.tsx +++ b/app/epoch/[epoch]/page-client.tsx @@ -12,6 +12,8 @@ import { ClusterStatus } from '@utils/cluster'; import { displayTimestampUtc } from '@utils/date'; import React from 'react'; +import { getFirstSlotInEpoch, getLastSlotInEpoch } from '@/app/utils/epoch-schedule'; + type Props = { params: { epoch: string; @@ -71,8 +73,8 @@ function EpochOverviewCard({ epoch }: OverviewProps) { return ; } - const firstSlot = epochSchedule.getFirstSlotInEpoch(epoch); - const lastSlot = epochSchedule.getLastSlotInEpoch(epoch); + const firstSlot = getFirstSlotInEpoch(epochSchedule, BigInt(epoch)); + const lastSlot = getLastSlotInEpoch(epochSchedule, BigInt(epoch)); return ( <> diff --git a/app/page.tsx b/app/page.tsx index 16b1fec5..4a74b565 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -165,14 +165,14 @@ function StatsCardBody() { Slot - + {blockHeight !== undefined && ( Block height - + )} diff --git a/app/providers/cluster.tsx b/app/providers/cluster.tsx index 81a6112b..3b496772 100644 --- a/app/providers/cluster.tsx +++ b/app/providers/cluster.tsx @@ -1,16 +1,26 @@ 'use client'; -import { Connection, EpochInfo, EpochSchedule } from '@solana/web3.js'; import { Cluster, clusterName, ClusterStatus, clusterUrl, DEFAULT_CLUSTER } from '@utils/cluster'; -import { localStorageIsAvailable } from '@utils/index'; +import { localStorageIsAvailable } from '@utils/local-storage'; import { reportError } from '@utils/sentry'; import { ReadonlyURLSearchParams, usePathname, useRouter, useSearchParams } from 'next/navigation'; import React, { createContext, useContext, useEffect, useReducer, useState } from 'react'; +import { createDefaultRpcTransport, createSolanaRpc } from 'web3js-experimental'; + +import { EpochSchedule } from '../utils/epoch-schedule'; type Action = State; +interface EpochInfo { + absoluteSlot: bigint; + blockHeight: bigint; + epoch: bigint; + slotIndex: bigint; + slotsInEpoch: bigint; +} + interface ClusterInfo { - firstAvailableBlock: number; + firstAvailableBlock: bigint; epochSchedule: EpochSchedule; epochInfo: EpochInfo; } @@ -115,19 +125,24 @@ async function updateCluster(dispatch: Dispatch, cluster: Cluster, customUrl: st // validate url new URL(customUrl); - const connection = new Connection(clusterUrl(cluster, customUrl)); + const transportUrl = clusterUrl(cluster, customUrl); + const transport = createDefaultRpcTransport({ url: transportUrl }) + const rpc = createSolanaRpc({ transport }) + const [firstAvailableBlock, epochSchedule, epochInfo] = await Promise.all([ - connection.getFirstAvailableBlock(), - connection.getEpochSchedule(), - connection.getEpochInfo(), + rpc.getFirstAvailableBlock().send(), + rpc.getEpochSchedule().send(), + rpc.getEpochInfo().send(), ]); dispatch({ cluster, clusterInfo: { epochInfo, - epochSchedule, - firstAvailableBlock, + // These are incorrectly typed as unknown + // See https://github.com/solana-labs/solana-web3.js/issues/1389 + epochSchedule: epochSchedule as EpochSchedule, + firstAvailableBlock: firstAvailableBlock as bigint, }, customUrl, status: ClusterStatus.Connected, diff --git a/app/providers/epoch.tsx b/app/providers/epoch.tsx index 1ddff7b2..1f82b338 100644 --- a/app/providers/epoch.tsx +++ b/app/providers/epoch.tsx @@ -2,11 +2,13 @@ import * as Cache from '@providers/cache'; import { useCluster } from '@providers/cluster'; -import { Connection, EpochSchedule } from '@solana/web3.js'; +import { Connection } from '@solana/web3.js'; import { Cluster } from '@utils/cluster'; import { reportError } from '@utils/sentry'; import React from 'react'; +import { EpochSchedule, getFirstSlotInEpoch, getLastSlotInEpoch } from '../utils/epoch-schedule'; + export enum FetchStatus { Fetching, FetchFailed, @@ -63,7 +65,7 @@ export async function fetchEpoch( url: string, cluster: Cluster, epochSchedule: EpochSchedule, - currentEpoch: number, + currentEpoch: bigint, epoch: number ) { dispatch({ @@ -78,15 +80,15 @@ export async function fetchEpoch( try { const connection = new Connection(url, 'confirmed'); - const firstSlot = epochSchedule.getFirstSlotInEpoch(epoch); - const lastSlot = epochSchedule.getLastSlotInEpoch(epoch); + const firstSlot = getFirstSlotInEpoch(epochSchedule, BigInt(epoch)); + const lastSlot = getLastSlotInEpoch(epochSchedule, BigInt(epoch)); const [firstBlock, lastBlock] = await Promise.all([ (async () => { - const firstBlocks = await connection.getBlocks(firstSlot, firstSlot + 100); + const firstBlocks = await connection.getBlocks(Number(firstSlot), Number(firstSlot + 100n)); return firstBlocks.shift(); })(), (async () => { - const lastBlocks = await connection.getBlocks(Math.max(0, lastSlot - 100), lastSlot); + const lastBlocks = await connection.getBlocks(Math.max(0, Number(lastSlot - 100n)), Number(lastSlot)); return lastBlocks.pop(); })(), ]); @@ -133,7 +135,7 @@ export function useFetchEpoch() { const { cluster, url } = useCluster(); return React.useCallback( - (key: number, currentEpoch: number, epochSchedule: EpochSchedule) => + (key: number, currentEpoch: bigint, epochSchedule: EpochSchedule) => fetchEpoch(dispatch, url, cluster, epochSchedule, currentEpoch, key), [dispatch, cluster, url] ); diff --git a/app/utils/__tests__/epoch-schedule.ts b/app/utils/__tests__/epoch-schedule.ts new file mode 100644 index 00000000..b1d56a9b --- /dev/null +++ b/app/utils/__tests__/epoch-schedule.ts @@ -0,0 +1,84 @@ +import { EpochSchedule, getEpochForSlot, getFirstSlotInEpoch, getLastSlotInEpoch } from "../epoch-schedule" + +describe('getEpoch', () => { + it('returns the correct epoch for a slot after `firstNormalSlot`', () => { + const schedule: EpochSchedule = { + firstNormalEpoch: 0n, + firstNormalSlot: 0n, + slotsPerEpoch: 432_000n + } + + expect(getEpochForSlot(schedule, 1n)).toEqual(0n); + expect(getEpochForSlot(schedule, 431_999n)).toEqual(0n); + expect(getEpochForSlot(schedule, 432_000n)).toEqual(1n); + expect(getEpochForSlot(schedule, 500_000n)).toEqual(1n); + expect(getEpochForSlot(schedule, 228_605_332n)).toEqual(529n); + }) + + it('returns the correct epoch for a slot before `firstNormalSlot`', () => { + const schedule: EpochSchedule = { + firstNormalEpoch: 100n, + firstNormalSlot: 3_200n, + slotsPerEpoch: 432_000n + }; + + expect(getEpochForSlot(schedule, 1n)).toEqual(0n); + expect(getEpochForSlot(schedule, 31n)).toEqual(0n); + expect(getEpochForSlot(schedule, 32n)).toEqual(1n); + }) +}) + +describe('getFirstSlotInEpoch', () => { + it('returns the first slot for an epoch after `firstNormalEpoch`', () => { + const schedule: EpochSchedule = { + firstNormalEpoch: 0n, + firstNormalSlot: 0n, + slotsPerEpoch: 100n + } + + expect(getFirstSlotInEpoch(schedule, 1n)).toEqual(100n); + expect(getFirstSlotInEpoch(schedule, 2n)).toEqual(200n); + expect(getFirstSlotInEpoch(schedule, 10n)).toEqual(1000n); + }) + + it('returns the first slot for an epoch before `firstNormalEpoch`', () => { + const schedule: EpochSchedule = { + firstNormalEpoch: 100n, + firstNormalSlot: 100_000n, + slotsPerEpoch: 100n + }; + + expect(getFirstSlotInEpoch(schedule, 0n)).toEqual(0n); + expect(getFirstSlotInEpoch(schedule, 1n)).toEqual(32n); + expect(getFirstSlotInEpoch(schedule, 2n)).toEqual(96n); + expect(getFirstSlotInEpoch(schedule, 10n)).toEqual(32_736n); + }) +}) + +describe('getLastSlotInEpoch', () => { + it('returns the last slot for an epoch after `firstNormalEpoch`', () => { + const schedule: EpochSchedule = { + firstNormalEpoch: 0n, + firstNormalSlot: 0n, + slotsPerEpoch: 100n + } + + expect(getLastSlotInEpoch(schedule, 1n)).toEqual(199n); + expect(getLastSlotInEpoch(schedule, 2n)).toEqual(299n); + expect(getLastSlotInEpoch(schedule, 10n)).toEqual(1099n); + }) + + it('returns the first slot for an epoch before `firstNormalEpoch`', () => { + const schedule: EpochSchedule = { + firstNormalEpoch: 100n, + firstNormalSlot: 100_000n, + slotsPerEpoch: 100n + }; + + expect(getLastSlotInEpoch(schedule, 0n)).toEqual(31n); + expect(getLastSlotInEpoch(schedule, 1n)).toEqual(95n); + expect(getLastSlotInEpoch(schedule, 2n)).toEqual(223n); + expect(getLastSlotInEpoch(schedule, 10n)).toEqual(65_503n); + }) +}) + diff --git a/app/utils/cluster.ts b/app/utils/cluster.ts index 87dae56d..f30963fc 100644 --- a/app/utils/cluster.ts +++ b/app/utils/cluster.ts @@ -1,5 +1,3 @@ -import { clusterApiUrl } from '@solana/web3.js'; - export enum ClusterStatus { Connected, Connecting, @@ -41,9 +39,9 @@ export function clusterName(cluster: Cluster): string { } } -export const MAINNET_BETA_URL = clusterApiUrl('mainnet-beta'); -export const TESTNET_URL = clusterApiUrl('testnet'); -export const DEVNET_URL = clusterApiUrl('devnet'); +export const MAINNET_BETA_URL = 'https://api.mainnet-beta.solana.com'; +export const TESTNET_URL = 'https://api.testnet.solana.com'; +export const DEVNET_URL = 'https://api.devnet.solana.com'; export function clusterUrl(cluster: Cluster, customUrl: string): string { const modifyUrl = (url: string): string => { diff --git a/app/utils/epoch-schedule.ts b/app/utils/epoch-schedule.ts new file mode 100644 index 00000000..9cea623b --- /dev/null +++ b/app/utils/epoch-schedule.ts @@ -0,0 +1,91 @@ +const MINIMUM_SLOT_PER_EPOCH = BigInt(32); + +export interface EpochSchedule { + /** The maximum number of slots in each epoch */ + slotsPerEpoch: bigint, + /** The first epoch with `slotsPerEpoch` slots */ + firstNormalEpoch: bigint, + /** The first slot of `firstNormalEpoch` */ + firstNormalSlot: bigint +} + +// Returns the number of trailing zeros in the binary representation of n +function trailingZeros(n: bigint): number { + let trailingZeros = 0; + while (n > 1) { + n /= 2n; + trailingZeros++; + } + return trailingZeros; +} + +// Returns the smallest power of two greater than or equal to n +function nextPowerOfTwo(n: bigint): bigint { + if (n === 0n) return 1n; + n--; + n |= n >> 1n + n |= n >> 2n + n |= n >> 4n + n |= n >> 8n + n |= n >> 16n + n |= n >> 32n + return n + 1n +} + +/** + * Get the epoch number for a given slot + * @param epochSchedule Epoch schedule information + * @param slot The slot to get the epoch number for + * @returns The epoch number that contains or will contain the given slot + */ +export function getEpochForSlot( + epochSchedule: EpochSchedule, + slot: bigint, +): bigint { + if (slot < epochSchedule.firstNormalSlot) { + const epoch = + trailingZeros(nextPowerOfTwo(slot + MINIMUM_SLOT_PER_EPOCH + BigInt(1))) - + trailingZeros(MINIMUM_SLOT_PER_EPOCH) - + 1; + + return BigInt(epoch); + } else { + const normalSlotIndex = slot - epochSchedule.firstNormalSlot; + const normalEpochIndex = normalSlotIndex / epochSchedule.slotsPerEpoch; + const epoch = epochSchedule.firstNormalEpoch + normalEpochIndex; + return epoch; + } +} + +/** + * Get the first slot in a given epoch + * @param epochSchedule Epoch schedule information + * @param epoch Epoch to get the first slot for + * @returns First slot in the epoch + */ +export function getFirstSlotInEpoch( + epochSchedule: EpochSchedule, + epoch: bigint +): bigint { + if (epoch <= epochSchedule.firstNormalEpoch) { + return ((2n ** epoch) - 1n) * MINIMUM_SLOT_PER_EPOCH; + } else { + return ( + (epoch - epochSchedule.firstNormalEpoch) * epochSchedule.slotsPerEpoch + + epochSchedule.firstNormalSlot + ); + } +} + +/** + * Get the last slot in a given epoch + * @param epochSchedule Epoch schedule information + * @param epoch Epoch to get the last slot for + * @returns Last slot in the epoch + */ +export function getLastSlotInEpoch( + epochSchedule: EpochSchedule, + epoch: bigint +): bigint { + return getFirstSlotInEpoch(epochSchedule, epoch + 1n) - 1n; +} diff --git a/app/utils/index.ts b/app/utils/index.ts index 1b863871..bd3fe74d 100644 --- a/app/utils/index.ts +++ b/app/utils/index.ts @@ -103,17 +103,6 @@ export function wrap(input: string, length: number): string { return result.join('\n'); } -export function localStorageIsAvailable() { - const test = 'test'; - try { - localStorage.setItem(test, test); - localStorage.removeItem(test); - return true; - } catch (e) { - return false; - } -} - export function camelToTitleCase(str: string): string { const result = str.replace(/([A-Z])/g, ' $1'); return result.charAt(0).toUpperCase() + result.slice(1); diff --git a/app/utils/local-storage.ts b/app/utils/local-storage.ts new file mode 100644 index 00000000..2cb0195e --- /dev/null +++ b/app/utils/local-storage.ts @@ -0,0 +1,14 @@ +let localStorageIsAvailableDecision: boolean | undefined; +export function localStorageIsAvailable() { + if (localStorageIsAvailableDecision === undefined) { + const test = 'test'; + try { + localStorage.setItem(test, test); + localStorage.removeItem(test); + localStorageIsAvailableDecision = true; + } catch (e) { + localStorageIsAvailableDecision = false; + } + } + return localStorageIsAvailableDecision; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index b90f50b6..c207cc83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es2020", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true,