Skip to content

Commit 2aabfd3

Browse files
committed
test: add a caching proxy for integration tests
1 parent 3d6eef7 commit 2aabfd3

File tree

5 files changed

+273
-8
lines changed

5 files changed

+273
-8
lines changed

src/integrationTestHelpers/anvilHarness.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import {
4646
refreshForkTime,
4747
setBalanceOnL1,
4848
} from './anvilHarnessHelpers';
49-
49+
import { type RpcCachingProxy, startRpcCachingProxy } from './rpcCachingProxy';
5050
import type { CustomTimingParams, PrivateKeyAccountWithPrivateKey } from '../testHelpers';
5151

5252
export type AnvilTestStack = {
@@ -75,6 +75,7 @@ let l1ContainerName: string | undefined;
7575
let l2ContainerName: string | undefined;
7676
let cleanupHookRegistered = false;
7777
let teardownStarted = false;
78+
let l1RpcCachingProxy: RpcCachingProxy | undefined;
7879

7980
export async function setupAnvilTestStack(): Promise<AnvilTestStack> {
8081
if (envPromise) {
@@ -84,7 +85,9 @@ export async function setupAnvilTestStack(): Promise<AnvilTestStack> {
8485
teardownStarted = false;
8586

8687
if (!cleanupHookRegistered) {
87-
process.once('exit', () => teardownAnvilTestStack());
88+
process.once('exit', () => {
89+
void teardownAnvilTestStack();
90+
});
8891
cleanupHookRegistered = true;
8992
}
9093

@@ -118,6 +121,14 @@ export async function setupAnvilTestStack(): Promise<AnvilTestStack> {
118121
},
119122
};
120123

124+
const cacheFilePath = join(process.cwd(), '.cache', 'anvil-rpc-cache.json');
125+
126+
l1RpcCachingProxy = await startRpcCachingProxy(anvilForkUrl, cacheFilePath, {
127+
forkBlockNumber: testConstants.DEFAULT_SEPOLIA_FORK_BLOCK_NUMBER,
128+
});
129+
130+
const l1RpcUrlWithCaching = l1RpcCachingProxy.proxyUrl;
131+
121132
const harnessDeployer = createAccount();
122133
const blockAdvancerAccount = createAccount();
123134

@@ -130,7 +141,7 @@ export async function setupAnvilTestStack(): Promise<AnvilTestStack> {
130141
networkName: dockerNetworkName,
131142
l1RpcPort,
132143
anvilImage,
133-
anvilForkUrl,
144+
anvilForkUrl: l1RpcUrlWithCaching,
134145
anvilForkBlockNumber: testConstants.DEFAULT_SEPOLIA_FORK_BLOCK_NUMBER,
135146
chainId: sepolia.id,
136147
});
@@ -367,7 +378,7 @@ export async function setupAnvilTestStack(): Promise<AnvilTestStack> {
367378

368379
return initializedEnv;
369380
})().catch((error) => {
370-
teardownAnvilTestStack();
381+
void teardownAnvilTestStack();
371382
throw error;
372383
});
373384

@@ -388,6 +399,14 @@ export function teardownAnvilTestStack() {
388399
}
389400
teardownStarted = true;
390401

402+
if (l1RpcCachingProxy) {
403+
for (const line of l1RpcCachingProxy.getSummaryLines()) {
404+
console.log(line);
405+
}
406+
407+
l1RpcCachingProxy.close();
408+
}
409+
391410
cleanupCurrentHarnessResources({
392411
l2ContainerName: l2ContainerName,
393412
l1ContainerName: l1ContainerName,
@@ -399,6 +418,7 @@ export function teardownAnvilTestStack() {
399418
l1ContainerName = undefined;
400419
dockerNetworkName = undefined;
401420
runtimeDir = undefined;
421+
l1RpcCachingProxy = undefined;
402422
envPromise = undefined;
403423
initializedEnv = undefined;
404424
}

src/integrationTestHelpers/anvilHarnessHelpers.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,6 @@ export async function fundL2Deployer(params: {
180180
return;
181181
}
182182

183-
const previousBalance = currentBalance;
184183
const { maxSubmissionCost, l2MaxFeePerGas } = await getRequiredRetryableFunding(
185184
l1Client,
186185
l2Client,
@@ -211,9 +210,10 @@ export async function fundL2Deployer(params: {
211210
await l1Client.waitForTransactionReceipt({ hash: txHash });
212211

213212
const startedAt = Date.now();
213+
214214
while (Date.now() - startedAt < 60_000) {
215215
currentBalance = await l2Client.getBalance({ address: deployer.address });
216-
if (currentBalance > previousBalance) {
216+
if (currentBalance >= fundAmount) {
217217
return currentBalance;
218218
}
219219

@@ -266,7 +266,7 @@ export async function setBalanceOnL1(params: {
266266
const publicClient = createPublicClient({ transport: http(params.rpcUrl) });
267267
await publicClient.request({
268268
method: 'anvil_setBalance' as never,
269-
params: [params.address, `0x${params.balance.toString(16)}`],
269+
params: [params.address, `0x${params.balance.toString(16)}`] as never,
270270
});
271271
}
272272

src/integrationTestHelpers/dockerHelpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ export function startSourceL1AnvilContainer(params: {
208208
params.containerName,
209209
'--network',
210210
params.networkName,
211+
'--add-host',
212+
'host.docker.internal:host-gateway',
211213
'--entrypoint',
212214
'anvil',
213215
'-p',
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1-
import { setupAnvilTestStack } from './anvilHarness.ts';
1+
import { afterAll } from 'vitest';
2+
3+
import { setupAnvilTestStack, teardownAnvilTestStack } from './anvilHarness.ts';
24

35
await setupAnvilTestStack();
6+
7+
afterAll(() => {
8+
teardownAnvilTestStack();
9+
});
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { createServer, IncomingHttpHeaders } from 'node:http';
2+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3+
import { dirname } from 'node:path';
4+
import { buffer } from 'node:stream/consumers';
5+
6+
type RpcRequest = {
7+
id?: unknown;
8+
jsonrpc?: string;
9+
method?: string;
10+
params?: unknown;
11+
};
12+
13+
type RpcResponse = {
14+
error?: unknown;
15+
id?: unknown;
16+
jsonrpc?: string;
17+
result?: unknown;
18+
};
19+
20+
type CacheEntry = {
21+
error?: unknown;
22+
jsonrpc: string;
23+
result?: unknown;
24+
};
25+
26+
type CacheData = Record<string, CacheEntry>;
27+
28+
type CacheMetadata = {
29+
forkBlockNumber: number;
30+
};
31+
32+
type CacheFile = {
33+
metadata: CacheMetadata;
34+
entries: CacheData;
35+
};
36+
37+
export type RpcCachingProxy = {
38+
proxyUrl: string;
39+
close: () => void;
40+
getSummaryLines: () => string[];
41+
};
42+
43+
const CACHEABLE_METHODS = new Set([
44+
'eth_chainId',
45+
'eth_gasPrice',
46+
'eth_getAccountInfo',
47+
'eth_getBalance',
48+
'eth_getBlockByNumber',
49+
'eth_getCode',
50+
'eth_getStorageAt',
51+
'eth_getTransactionCount',
52+
'eth_getTransactionReceipt',
53+
]);
54+
55+
function forwardHeaders(headers: IncomingHttpHeaders): Record<string, string> {
56+
const nextHeaders: Record<string, string> = {};
57+
58+
for (const [key, value] of Object.entries(headers)) {
59+
if (typeof value === 'undefined' || key === 'content-length' || key === 'host') {
60+
continue;
61+
}
62+
63+
nextHeaders[key] = Array.isArray(value) ? value.join(', ') : value;
64+
}
65+
66+
return nextHeaders;
67+
}
68+
69+
function getCacheKey(request: RpcRequest): string | undefined {
70+
if (typeof request.method !== 'string' || !CACHEABLE_METHODS.has(request.method)) {
71+
return undefined;
72+
}
73+
74+
return JSON.stringify([request.method, request.params ?? []]);
75+
}
76+
77+
function getIdKey(id: unknown): string {
78+
return JSON.stringify(id ?? null);
79+
}
80+
81+
function writeCacheFile(cacheFilePath: string, cacheFile: CacheFile) {
82+
writeFileSync(cacheFilePath, JSON.stringify(cacheFile, null, 2));
83+
}
84+
85+
function isCacheFile(value: unknown): value is CacheFile {
86+
if (!value || typeof value !== 'object') {
87+
return false;
88+
}
89+
90+
const maybeCacheFile = value as Partial<CacheFile>;
91+
return (
92+
!!maybeCacheFile.metadata &&
93+
typeof maybeCacheFile.metadata === 'object' &&
94+
typeof maybeCacheFile.metadata.forkBlockNumber === 'number' &&
95+
!!maybeCacheFile.entries &&
96+
typeof maybeCacheFile.entries === 'object'
97+
);
98+
}
99+
100+
function loadCacheData(params: { cacheFilePath: string; metadata: CacheMetadata }): {
101+
cache: CacheData;
102+
cacheInvalidated: boolean;
103+
} {
104+
const { cacheFilePath, metadata } = params;
105+
106+
if (!existsSync(cacheFilePath)) {
107+
writeCacheFile(cacheFilePath, { metadata, entries: {} });
108+
return { cache: {}, cacheInvalidated: false };
109+
}
110+
111+
try {
112+
const parsed = JSON.parse(readFileSync(cacheFilePath, 'utf8')) as unknown;
113+
if (isCacheFile(parsed) && parsed.metadata.forkBlockNumber === metadata.forkBlockNumber) {
114+
return { cache: parsed.entries, cacheInvalidated: false };
115+
}
116+
} catch {
117+
// Reset invalid cache files below.
118+
}
119+
120+
writeCacheFile(cacheFilePath, { metadata, entries: {} });
121+
return { cache: {}, cacheInvalidated: true };
122+
}
123+
124+
export async function startRpcCachingProxy(
125+
targetUrl: string,
126+
cacheFilePath: string,
127+
metadata: CacheMetadata,
128+
): Promise<RpcCachingProxy> {
129+
mkdirSync(dirname(cacheFilePath), { recursive: true });
130+
131+
const { cache, cacheInvalidated } = loadCacheData({ cacheFilePath, metadata });
132+
133+
const stats = {
134+
cacheInvalidated,
135+
cacheHits: 0,
136+
cacheMisses: 0,
137+
requests: 0,
138+
upstreamRequests: 0,
139+
};
140+
141+
const server = createServer(async (request, response) => {
142+
try {
143+
const requestBody = await buffer(request);
144+
const upstreamRequestBody = new Uint8Array(requestBody.byteLength);
145+
upstreamRequestBody.set(requestBody);
146+
147+
const parsed = JSON.parse(requestBody.toString('utf8')) as RpcRequest | RpcRequest[];
148+
const requests = Array.isArray(parsed) ? parsed : [parsed];
149+
150+
stats.requests += requests.length;
151+
152+
const cacheKeys = requests.map((item) => getCacheKey(item));
153+
const cachedResponses = cacheKeys.map((cacheKey) => (cacheKey ? cache[cacheKey] : undefined));
154+
155+
if (cachedResponses.every((entry) => entry)) {
156+
stats.cacheHits += requests.length;
157+
response.setHeader('content-type', 'application/json');
158+
response.statusCode = 200;
159+
response.end(
160+
JSON.stringify(
161+
Array.isArray(parsed)
162+
? requests.map((item, index) => ({ id: item.id ?? null, ...cachedResponses[index]! }))
163+
: { id: requests[0].id ?? null, ...cachedResponses[0]! },
164+
),
165+
);
166+
return;
167+
}
168+
169+
stats.cacheMisses += cacheKeys.filter(
170+
(cacheKey, index) => cacheKey && !cachedResponses[index],
171+
).length;
172+
stats.upstreamRequests += 1;
173+
174+
const upstreamResponse = await fetch(targetUrl, {
175+
method: request.method,
176+
headers: forwardHeaders(request.headers),
177+
body: upstreamRequestBody,
178+
});
179+
180+
const upstreamText = Buffer.from(await upstreamResponse.arrayBuffer()).toString('utf8');
181+
const upstreamResponses = [JSON.parse(upstreamText)].flat() as RpcResponse[];
182+
const upstreamById = new Map(
183+
upstreamResponses.map((item) => [getIdKey(item.id), item] as const),
184+
);
185+
186+
let cacheChanged = false;
187+
188+
for (const [index, item] of requests.entries()) {
189+
const cacheKey = cacheKeys[index];
190+
const upstreamItem = upstreamById.get(getIdKey(item.id));
191+
if (!cacheKey || !upstreamItem) {
192+
continue;
193+
}
194+
195+
cache[cacheKey] = {
196+
error: upstreamItem.error,
197+
jsonrpc: upstreamItem.jsonrpc ?? '2.0',
198+
result: upstreamItem.result,
199+
};
200+
cacheChanged = true;
201+
}
202+
203+
if (cacheChanged) {
204+
writeCacheFile(cacheFilePath, { metadata, entries: cache });
205+
}
206+
207+
const contentType = upstreamResponse.headers.get('content-type');
208+
if (contentType) {
209+
response.setHeader('content-type', contentType);
210+
}
211+
response.statusCode = upstreamResponse.status;
212+
response.end(upstreamText);
213+
} catch (error) {
214+
response.statusCode = 502;
215+
response.end(
216+
JSON.stringify({
217+
error: error instanceof Error ? error.message : String(error),
218+
}),
219+
);
220+
}
221+
});
222+
223+
server.listen(8449, '0.0.0.0');
224+
225+
return {
226+
proxyUrl: `http://host.docker.internal:8449`,
227+
getSummaryLines: () => [
228+
'RPC proxy cache summary',
229+
` invalidated on startup: ${stats.cacheInvalidated ? 'yes' : 'no'}`,
230+
` requests: ${stats.requests}`,
231+
` cache hits: ${stats.cacheHits}`,
232+
` cache misses: ${stats.cacheMisses}`,
233+
` upstream HTTP requests: ${stats.upstreamRequests}`,
234+
],
235+
close: () => server.close(),
236+
};
237+
}

0 commit comments

Comments
 (0)