Skip to content

Commit 53cc081

Browse files
authored
refactor(providers): move epoch, block and transaction providers off web3.js Connection (#1243)
kit migration tasks 1.1 + 1.2 — the epoch, block and transaction providers no longer construct `new Connection`. all three now go through the central `getRpc(url)` accessor from #1199. | provider | calls moved to kit | | ------------------------ | ------------------------------------------------------------- | | `epoch.tsx` | `getBlocks` (epoch boundary discovery), `getBlockTime` | | `block.tsx` | `getBlocks` (child slot), `getSlotLeaders` | | `transactions/index.tsx` | `getSignatureStatus` → `getSignatureStatuses`, `getBlockTime` | kit upcasts every integer in an RPC response to a bigint so we map those where needed closes HOO-1220 closes HOO-1222
1 parent 7c40886 commit 53cc081

9 files changed

Lines changed: 522 additions & 63 deletions

File tree

app/entities/transaction-data/lib/adapt-parsed-transaction.ts

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
type TransactionError,
99
} from '@solana/web3.js';
1010

11+
import { withNumbersInsteadOfBigInts } from '@/app/shared/lib/bigint-to-number';
12+
1113
import type { TransactionWithMeta } from '../model/types';
1214

1315
type RpcAccountKey = Readonly<{
@@ -195,28 +197,3 @@ function toNullableNumber(value: number | bigint | null): number | null {
195197
// eslint-disable-next-line unicorn/no-null
196198
return value === null ? null : Number(value);
197199
}
198-
199-
/**
200-
* Recursively replaces bigints with numbers.
201-
*
202-
* kit upcasts every integral value in an RPC response to a bigint unless its key path is on an
203-
* allow-list, and that list covers nothing inside a parsed instruction or a transaction error.
204-
* web3.js delivered plain numbers throughout, and consumers `JSON.stringify` these payloads —
205-
* which throws on a bigint — and validate them against superstruct `number()` schemas.
206-
*
207-
* Precision above 2^53 is lost, exactly as it was when web3.js parsed the same response.
208-
*/
209-
function withNumbersInsteadOfBigInts<T>(value: T): T {
210-
if (typeof value === 'bigint') {
211-
return Number(value) as T;
212-
}
213-
if (Array.isArray(value)) {
214-
return value.map(withNumbersInsteadOfBigInts) as T;
215-
}
216-
if (typeof value === 'object' && value !== null) {
217-
return Object.fromEntries(
218-
Object.entries(value).map(([key, item]) => [key, withNumbersInsteadOfBigInts(item)]),
219-
) as T;
220-
}
221-
return value;
222-
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { gen } from '@__fixtures__/gen';
2+
import { Cluster } from '@utils/cluster';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { fetchBlock, FetchStatus } from '../block';
6+
7+
const MOCK_URL = 'https://api.mainnet-beta.solana.com';
8+
const SLOT = 100;
9+
const PARENT_SLOT = 99;
10+
11+
const getBlocks = vi.fn();
12+
const getSlotLeaders = vi.fn();
13+
const getRpc = vi.fn((_url: string) => ({
14+
getBlocks: (...args: unknown[]) => ({ send: () => getBlocks(...args) }),
15+
getSlotLeaders: (...args: unknown[]) => ({ send: () => getSlotLeaders(...args) }),
16+
}));
17+
18+
vi.mock('@entities/cluster', async importOriginal => ({
19+
...((await importOriginal()) as Record<string, unknown>),
20+
getRpc: (...args: [string]) => getRpc(...args),
21+
}));
22+
23+
const fetchBlockBySlot = vi.fn();
24+
vi.mock('@entities/block-data', () => ({
25+
fetchBlock: (...args: unknown[]) => fetchBlockBySlot(...args),
26+
}));
27+
28+
vi.mock('@/app/shared/lib/logger', () => ({ Logger: { error: vi.fn() } }));
29+
30+
const LEADERS = [gen.address(1), gen.address(2), gen.address(3)];
31+
32+
const dispatch = vi.fn();
33+
34+
function lastUpdate() {
35+
const calls = dispatch.mock.calls;
36+
return calls[calls.length - 1][0] as {
37+
data?: {
38+
blockLeader?: { toBase58(): string };
39+
childLeader?: { toBase58(): string };
40+
childSlot?: number;
41+
parentLeader?: { toBase58(): string };
42+
};
43+
status: FetchStatus;
44+
};
45+
}
46+
47+
beforeEach(() => {
48+
vi.resetAllMocks();
49+
getRpc.mockReturnValue({
50+
getBlocks: (...args: unknown[]) => ({ send: () => getBlocks(...args) }),
51+
getSlotLeaders: (...args: unknown[]) => ({ send: () => getSlotLeaders(...args) }),
52+
});
53+
fetchBlockBySlot.mockResolvedValue({ parentSlot: PARENT_SLOT });
54+
});
55+
56+
describe('fetchBlock', () => {
57+
it('should convert the child slot from a bigint and resolve leaders positionally', async () => {
58+
getBlocks.mockResolvedValue([101n, 102n]);
59+
getSlotLeaders.mockResolvedValue(LEADERS);
60+
61+
await fetchBlock(dispatch, MOCK_URL, Cluster.MainnetBeta, SLOT);
62+
63+
expect(getRpc).toHaveBeenCalledWith(MOCK_URL);
64+
expect(getBlocks).toHaveBeenCalledWith(101n, 200n);
65+
// parentSlot..childSlot inclusive
66+
expect(getSlotLeaders).toHaveBeenCalledWith(99n, 3);
67+
68+
const data = lastUpdate().data;
69+
expect(lastUpdate().status).toBe(FetchStatus.Fetched);
70+
expect(data?.childSlot).toBe(101);
71+
expect(data?.parentLeader?.toBase58()).toBe(LEADERS[0]);
72+
expect(data?.blockLeader?.toBase58()).toBe(LEADERS[1]);
73+
expect(data?.childLeader?.toBase58()).toBe(LEADERS[2]);
74+
});
75+
76+
it('should leave the child slot and child leader undefined when no later block exists', async () => {
77+
getBlocks.mockResolvedValue([]);
78+
getSlotLeaders.mockResolvedValue(LEADERS);
79+
80+
await fetchBlock(dispatch, MOCK_URL, Cluster.MainnetBeta, SLOT);
81+
82+
expect(getSlotLeaders).toHaveBeenCalledWith(99n, 2);
83+
const data = lastUpdate().data;
84+
expect(data?.childSlot).toBeUndefined();
85+
expect(data?.childLeader).toBeUndefined();
86+
expect(data?.blockLeader?.toBase58()).toBe(LEADERS[1]);
87+
});
88+
89+
it('should still report the block when the leader lookup fails', async () => {
90+
getBlocks.mockResolvedValue([101n]);
91+
getSlotLeaders.mockRejectedValue(new Error('leader schedule unavailable'));
92+
93+
await fetchBlock(dispatch, MOCK_URL, Cluster.MainnetBeta, SLOT);
94+
95+
expect(lastUpdate().status).toBe(FetchStatus.Fetched);
96+
expect(lastUpdate().data?.blockLeader).toBeUndefined();
97+
});
98+
99+
it('should report an empty block when the slot was skipped', async () => {
100+
fetchBlockBySlot.mockResolvedValue(null);
101+
102+
await fetchBlock(dispatch, MOCK_URL, Cluster.MainnetBeta, SLOT);
103+
104+
expect(getBlocks).not.toHaveBeenCalled();
105+
expect(lastUpdate()).toMatchObject({ data: {}, status: FetchStatus.Fetched });
106+
});
107+
108+
it('should dispatch FetchFailed when the block fetch throws', async () => {
109+
fetchBlockBySlot.mockRejectedValue(new Error('rpc boom'));
110+
111+
await fetchBlock(dispatch, MOCK_URL, Cluster.MainnetBeta, SLOT);
112+
113+
expect(lastUpdate()).toMatchObject({ data: undefined, status: FetchStatus.FetchFailed });
114+
});
115+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { Cluster } from '@utils/cluster';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import type { EpochSchedule } from '../../utils/epoch-schedule';
5+
import { fetchEpoch, FetchStatus } from '../epoch';
6+
7+
const MOCK_URL = 'https://api.mainnet-beta.solana.com';
8+
9+
const SCHEDULE: EpochSchedule = { firstNormalEpoch: 0n, firstNormalSlot: 0n, slotsPerEpoch: 432000n };
10+
const EPOCH = 10;
11+
const FIRST_SLOT = 4320000n;
12+
const LAST_SLOT = 4751999n;
13+
14+
const getBlocks = vi.fn();
15+
const getBlockTime = vi.fn();
16+
const getRpc = vi.fn((_url: string) => ({
17+
getBlockTime: (...args: unknown[]) => ({ send: () => getBlockTime(...args) }),
18+
getBlocks: (...args: unknown[]) => ({ send: () => getBlocks(...args) }),
19+
}));
20+
21+
vi.mock('@entities/cluster', async importOriginal => ({
22+
...((await importOriginal()) as Record<string, unknown>),
23+
getRpc: (...args: [string]) => getRpc(...args),
24+
}));
25+
26+
vi.mock('@/app/shared/lib/logger', () => ({ Logger: { error: vi.fn() } }));
27+
28+
const dispatch = vi.fn();
29+
30+
function lastUpdate() {
31+
const calls = dispatch.mock.calls;
32+
return calls[calls.length - 1][0] as {
33+
data?: { firstBlock: number; firstTimestamp: number | null; lastBlock?: number; lastTimestamp: number | null };
34+
status: FetchStatus;
35+
};
36+
}
37+
38+
beforeEach(() => {
39+
vi.resetAllMocks();
40+
getRpc.mockReturnValue({
41+
getBlockTime: (...args: unknown[]) => ({ send: () => getBlockTime(...args) }),
42+
getBlocks: (...args: unknown[]) => ({ send: () => getBlocks(...args) }),
43+
});
44+
});
45+
46+
describe('fetchEpoch', () => {
47+
it('should narrow the epoch boundary slots and timestamps to numbers', async () => {
48+
getBlocks
49+
.mockResolvedValueOnce([FIRST_SLOT, FIRST_SLOT + 1n])
50+
.mockResolvedValueOnce([LAST_SLOT - 1n, LAST_SLOT]);
51+
getBlockTime.mockResolvedValueOnce(1700000000n).mockResolvedValueOnce(1700100000n);
52+
53+
await fetchEpoch(dispatch, MOCK_URL, Cluster.MainnetBeta, SCHEDULE, 20n, EPOCH);
54+
55+
expect(getRpc).toHaveBeenCalledWith(MOCK_URL);
56+
expect(getBlocks).toHaveBeenNthCalledWith(1, FIRST_SLOT, FIRST_SLOT + 100n);
57+
expect(getBlocks).toHaveBeenNthCalledWith(2, LAST_SLOT - 100n, LAST_SLOT);
58+
expect(lastUpdate()).toMatchObject({
59+
data: {
60+
firstBlock: Number(FIRST_SLOT),
61+
firstTimestamp: 1700000000,
62+
lastBlock: Number(LAST_SLOT),
63+
lastTimestamp: 1700100000,
64+
},
65+
status: FetchStatus.Fetched,
66+
});
67+
});
68+
69+
it('should clamp the trailing getBlocks range at slot 0 for epoch 0', async () => {
70+
const tinySchedule: EpochSchedule = { firstNormalEpoch: 0n, firstNormalSlot: 0n, slotsPerEpoch: 32n };
71+
getBlocks.mockResolvedValueOnce([0n]).mockResolvedValueOnce([31n]);
72+
getBlockTime.mockResolvedValue(1700000000n);
73+
74+
await fetchEpoch(dispatch, MOCK_URL, Cluster.MainnetBeta, tinySchedule, 20n, 0);
75+
76+
expect(getBlocks).toHaveBeenNthCalledWith(2, 0n, 31n);
77+
expect(lastUpdate().status).toBe(FetchStatus.Fetched);
78+
});
79+
80+
it('should preserve a null timestamp for a block with no recorded time', async () => {
81+
getBlocks.mockResolvedValueOnce([FIRST_SLOT]).mockResolvedValueOnce([LAST_SLOT]);
82+
getBlockTime.mockResolvedValueOnce(null).mockResolvedValueOnce(null);
83+
84+
await fetchEpoch(dispatch, MOCK_URL, Cluster.MainnetBeta, SCHEDULE, 20n, EPOCH);
85+
86+
expect(lastUpdate().data).toMatchObject({ firstTimestamp: null, lastTimestamp: null });
87+
});
88+
89+
it('should request a timestamp for a last block at slot 0 rather than skipping it', async () => {
90+
const tinySchedule: EpochSchedule = { firstNormalEpoch: 0n, firstNormalSlot: 0n, slotsPerEpoch: 32n };
91+
getBlocks.mockResolvedValueOnce([0n]).mockResolvedValueOnce([0n]);
92+
getBlockTime.mockResolvedValue(1700000000n);
93+
94+
await fetchEpoch(dispatch, MOCK_URL, Cluster.MainnetBeta, tinySchedule, 20n, 0);
95+
96+
expect(getBlockTime).toHaveBeenCalledTimes(2);
97+
expect(lastUpdate().data).toMatchObject({ lastBlock: 0, lastTimestamp: 1700000000 });
98+
});
99+
100+
it('should dispatch FetchFailed when no block is found at the start of the epoch', async () => {
101+
getBlocks.mockResolvedValue([]);
102+
103+
await fetchEpoch(dispatch, MOCK_URL, Cluster.MainnetBeta, SCHEDULE, 20n, EPOCH);
104+
105+
expect(lastUpdate()).toMatchObject({ data: undefined, status: FetchStatus.FetchFailed });
106+
});
107+
});

app/providers/block.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
'use client';
22

33
import { type BlockWithV1, fetchBlock as fetchBlockBySlot } from '@entities/block-data';
4+
import { getRpc } from '@entities/cluster';
45
import * as Cache from '@providers/cache';
56
import { useCacheEntry } from '@providers/cache-entry';
67
import { useCluster } from '@providers/cluster';
7-
import { Connection, PublicKey } from '@solana/web3.js';
8+
import type { Address } from '@solana/kit';
9+
import type { PublicKey } from '@solana/web3.js';
810
import { Cluster } from '@utils/cluster';
911
import React from 'react';
1012

1113
import { Logger } from '@/app/shared/lib/logger';
14+
import { toLegacyPublicKey } from '@/app/shared/lib/web3js-compat';
1215

1316
export enum FetchStatus {
1417
Fetching,
@@ -74,26 +77,28 @@ export async function fetchBlock(dispatch: Dispatch, url: string, cluster: Clust
7477
let data: Block | undefined = undefined;
7578

7679
try {
77-
const connection = new Connection(url, 'confirmed');
80+
const rpc = getRpc(url);
7881
const block = await fetchBlockBySlot(url, slot);
7982
if (block === null) {
8083
data = {};
8184
status = FetchStatus.Fetched;
8285
} else {
83-
const childSlot = (await connection.getBlocks(slot + 1, slot + 100)).shift();
86+
const childSlotBigint = (await rpc.getBlocks(BigInt(slot + 1), BigInt(slot + 100)).send()).at(0);
87+
const childSlot = childSlotBigint === undefined ? undefined : Number(childSlotBigint);
8488
const firstLeaderSlot = block.parentSlot;
8589

86-
let leaders: PublicKey[] = [];
90+
let leaders: Address[] = [];
8791
try {
8892
const lastLeaderSlot = childSlot !== undefined ? childSlot : slot;
8993
const slotLeadersLimit = lastLeaderSlot - block.parentSlot + 1;
90-
leaders = await connection.getSlotLeaders(firstLeaderSlot, slotLeadersLimit);
94+
leaders = await rpc.getSlotLeaders(BigInt(firstLeaderSlot), slotLeadersLimit).send();
9195
} catch (_err) {
9296
// ignore errors
9397
}
9498

95-
const getLeader = (slot: number) => {
96-
return leaders.at(slot - firstLeaderSlot);
99+
const getLeader = (slot: number): PublicKey | undefined => {
100+
const leader = leaders.at(slot - firstLeaderSlot);
101+
return leader === undefined ? undefined : toLegacyPublicKey(leader);
97102
};
98103

99104
data = {

app/providers/epoch.tsx

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
'use client';
22

3+
import { getRpc } from '@entities/cluster';
34
import * as Cache from '@providers/cache';
45
import { useCacheEntry } from '@providers/cache-entry';
56
import { useCluster } from '@providers/cluster';
6-
import { Connection } from '@solana/web3.js';
7+
import type { UnixTimestamp } from '@solana/kit';
78
import { Cluster } from '@utils/cluster';
89
import React from 'react';
910

@@ -81,17 +82,17 @@ export async function fetchEpoch(
8182
let data: Epoch | undefined = undefined;
8283

8384
try {
84-
const connection = new Connection(url, 'confirmed');
85+
const rpc = getRpc(url);
8586
const firstSlot = getFirstSlotInEpoch(epochSchedule, BigInt(epoch));
8687
const lastSlot = getLastSlotInEpoch(epochSchedule, BigInt(epoch));
8788
const [firstBlock, lastBlock] = await Promise.all([
8889
(async () => {
89-
const firstBlocks = await connection.getBlocks(Number(firstSlot), Number(firstSlot + 100n));
90-
return firstBlocks.shift();
90+
const firstBlocks = await rpc.getBlocks(firstSlot, firstSlot + 100n).send();
91+
return firstBlocks.at(0);
9192
})(),
9293
(async () => {
93-
const lastBlocks = await connection.getBlocks(Math.max(0, Number(lastSlot - 100n)), Number(lastSlot));
94-
return lastBlocks.pop();
94+
const lastBlocks = await rpc.getBlocks(lastSlot > 100n ? lastSlot - 100n : 0n, lastSlot).send();
95+
return lastBlocks.at(-1);
9596
})(),
9697
]);
9798

@@ -101,16 +102,16 @@ export async function fetchEpoch(
101102
throw new Error(`failed to find confirmed block at end of epoch ${epoch}`);
102103
}
103104

104-
const [firstTimestamp, lastTimestamp] = await Promise.all([
105-
connection.getBlockTime(firstBlock),
106-
lastBlock ? connection.getBlockTime(lastBlock) : null,
105+
const [firstTimestamp, lastTimestamp] = await Promise.all<UnixTimestamp | null>([
106+
rpc.getBlockTime(firstBlock).send(),
107+
lastBlock === undefined ? null : rpc.getBlockTime(lastBlock).send(),
107108
]);
108109

109110
data = {
110-
firstBlock,
111-
firstTimestamp,
112-
lastBlock,
113-
lastTimestamp,
111+
firstBlock: Number(firstBlock),
112+
firstTimestamp: firstTimestamp === null ? null : Number(firstTimestamp),
113+
lastBlock: lastBlock === undefined ? undefined : Number(lastBlock),
114+
lastTimestamp: lastTimestamp === null ? null : Number(lastTimestamp),
114115
};
115116
status = FetchStatus.Fetched;
116117
} catch (err) {

0 commit comments

Comments
 (0)