Skip to content

Commit

Permalink
Refactor ClusterProvider to remove legacy web3js dependency (#267)
Browse files Browse the repository at this point in the history
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:
solana-labs/solana-web3.js#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.
  • Loading branch information
mcintyre94 authored Jul 14, 2023
1 parent 080f1d8 commit a292352
Show file tree
Hide file tree
Showing 14 changed files with 240 additions and 42 deletions.
4 changes: 3 additions & 1 deletion app/block/[slot]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = (
<>
Expand Down
2 changes: 1 addition & 1 deletion app/components/ClusterModalDeveloperSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { localStorageIsAvailable } from '@utils/index';
import { localStorageIsAvailable } from '@utils/local-storage';
import { ChangeEvent } from 'react';

export default function ClusterModalDeveloperSettings() {
Expand Down
5 changes: 3 additions & 2 deletions app/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];

Expand Down Expand Up @@ -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: [
Expand Down
2 changes: 1 addition & 1 deletion app/components/common/Slot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions app/epoch/[epoch]/page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -71,8 +73,8 @@ function EpochOverviewCard({ epoch }: OverviewProps) {
return <LoadingCard message="Loading epoch" />;
}

const firstSlot = epochSchedule.getFirstSlotInEpoch(epoch);
const lastSlot = epochSchedule.getLastSlotInEpoch(epoch);
const firstSlot = getFirstSlotInEpoch(epochSchedule, BigInt(epoch));
const lastSlot = getLastSlotInEpoch(epochSchedule, BigInt(epoch));

return (
<>
Expand Down
4 changes: 2 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,14 +165,14 @@ function StatsCardBody() {
<tr>
<td className="w-100">Slot</td>
<td className="text-lg-end font-monospace">
<Slot slot={Number(absoluteSlot)} link />
<Slot slot={absoluteSlot} link />
</td>
</tr>
{blockHeight !== undefined && (
<tr>
<td className="w-100">Block height</td>
<td className="text-lg-end font-monospace">
<Slot slot={Number(blockHeight)} />
<Slot slot={blockHeight} />
</td>
</tr>
)}
Expand Down
33 changes: 24 additions & 9 deletions app/providers/cluster.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 9 additions & 7 deletions app/providers/epoch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -63,7 +65,7 @@ export async function fetchEpoch(
url: string,
cluster: Cluster,
epochSchedule: EpochSchedule,
currentEpoch: number,
currentEpoch: bigint,
epoch: number
) {
dispatch({
Expand All @@ -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();
})(),
]);
Expand Down Expand Up @@ -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]
);
Expand Down
84 changes: 84 additions & 0 deletions app/utils/__tests__/epoch-schedule.ts
Original file line number Diff line number Diff line change
@@ -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);
})
})

8 changes: 3 additions & 5 deletions app/utils/cluster.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { clusterApiUrl } from '@solana/web3.js';

export enum ClusterStatus {
Connected,
Connecting,
Expand Down Expand Up @@ -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 => {
Expand Down
91 changes: 91 additions & 0 deletions app/utils/epoch-schedule.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

1 comment on commit a292352

@vercel
Copy link

@vercel vercel bot commented on a292352 Jul 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

explorer – ./

explorer-git-master-solana-labs.vercel.app
explorer-solana-labs.vercel.app
explorer.solana.com

Please sign in to comment.