Skip to content

Commit 081fda2

Browse files
randygrokjgimenotac0turtle
authored
test: add e2e tests for the client for new tx type. (#118)
* create primitives * work until rpc * reorder paths * finish impl phase 1 * fix lint * fix linter * fix clippy * fix clippy 2 * fmt linter * fix e2e tests * remove error on payload builder when ev node tx * add e2e test for signature of sponsorship * fmt: format e2e tests * fix issue with fee payer * fix clippy * fmt * add case where invalid signature was included but did not allow to build block * fix lint problems * add case where the vector is empty. * fix max gax limits calculations * revert and create logic increases nonce * fix linter * improve panic messages and add docs for Compact trait implementations * address comments from PR * fix: address clippy warnings and formatting * add hash calc for payload * add pool validatios for ev node as eip1559 * remove comment * fix or add some context for special cases * fix comments for lint * make fmt * remove old tests * include workflow --------- Co-authored-by: Jonathan Gimeno <jgimeno@gmail.com> Co-authored-by: Marko <marko@baricevic.me>
1 parent d537587 commit 081fda2

8 files changed

Lines changed: 505 additions & 446 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,13 @@ jobs:
4242
--no-capture
4343
env:
4444
RUST_LOG: debug
45+
46+
- uses: actions/setup-node@v4
47+
with:
48+
node-version: '22'
49+
50+
- name: Run client E2E tests
51+
run: |
52+
cd clients
53+
npm install
54+
npm run test:e2e

clients/package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@
1919
"clean": "rm -rf dist",
2020
"prepublishOnly": "npm run clean && npm run build",
2121
"test:unit": "tsx --test tests/unit.test.ts",
22-
"test:basic": "tsx tests/basic.test.ts",
23-
"test:flows": "tsx tests/flows.test.ts",
24-
"test:sponsored": "tsx tests/sponsored.test.ts",
22+
"test:e2e": "tsx --test tests/e2e/flows.e2e.test.ts",
2523
"test": "npm run test:unit"
2624
},
2725
"keywords": [

clients/tests/basic.test.ts

Lines changed: 0 additions & 73 deletions
This file was deleted.

clients/tests/e2e/coordinator.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { createHmac, randomBytes } from 'node:crypto';
2+
import { keccak256, type Hex } from 'viem';
3+
4+
export interface CoordinatorOptions {
5+
rpcUrl: string;
6+
engineUrl: string;
7+
jwtSecret: string; // hex-encoded 32 bytes (no 0x prefix)
8+
pollIntervalMs?: number;
9+
feeRecipient?: Hex;
10+
gasLimit?: number;
11+
}
12+
13+
export class Coordinator {
14+
private rpcUrl: string;
15+
private engineUrl: string;
16+
private jwtSecret: Buffer;
17+
private pollIntervalMs: number;
18+
private feeRecipient: Hex;
19+
private gasLimit: number;
20+
21+
private parentHash: Hex = '0x0000000000000000000000000000000000000000000000000000000000000000';
22+
private blockNumber: bigint = 0n;
23+
private timestamp: bigint = 0n;
24+
25+
private running = false;
26+
private pollTimer: ReturnType<typeof setTimeout> | null = null;
27+
private seenTxs = new Set<string>();
28+
29+
constructor(opts: CoordinatorOptions) {
30+
this.rpcUrl = opts.rpcUrl;
31+
this.engineUrl = opts.engineUrl;
32+
this.jwtSecret = Buffer.from(opts.jwtSecret, 'hex');
33+
this.pollIntervalMs = opts.pollIntervalMs ?? 200;
34+
this.feeRecipient = opts.feeRecipient ?? '0x0000000000000000000000000000000000000000';
35+
this.gasLimit = opts.gasLimit ?? 30_000_000;
36+
}
37+
38+
async start(): Promise<void> {
39+
const latestBlock = await this.rpcCall(this.rpcUrl, 'eth_getBlockByNumber', ['latest', false]);
40+
this.parentHash = latestBlock.hash;
41+
this.blockNumber = BigInt(latestBlock.number);
42+
this.timestamp = BigInt(latestBlock.timestamp);
43+
this.running = true;
44+
this.poll();
45+
}
46+
47+
stop(): void {
48+
this.running = false;
49+
if (this.pollTimer) {
50+
clearTimeout(this.pollTimer);
51+
this.pollTimer = null;
52+
}
53+
}
54+
55+
getBlockNumber(): bigint {
56+
return this.blockNumber;
57+
}
58+
59+
private poll(): void {
60+
if (!this.running) return;
61+
62+
this.pollOnce()
63+
.catch((err) => {
64+
console.error('[coordinator] poll error:', err);
65+
})
66+
.finally(() => {
67+
if (this.running) {
68+
this.pollTimer = setTimeout(() => this.poll(), this.pollIntervalMs);
69+
}
70+
});
71+
}
72+
73+
private async pollOnce(): Promise<void> {
74+
const rawTxs: Hex[] = await this.rpcCall(this.rpcUrl, 'txpoolExt_getTxs', []);
75+
76+
const newTxs: Hex[] = [];
77+
for (const rawTx of rawTxs) {
78+
const txHash = keccak256(rawTx);
79+
if (!this.seenTxs.has(txHash)) {
80+
this.seenTxs.add(txHash);
81+
newTxs.push(rawTx);
82+
}
83+
}
84+
85+
if (newTxs.length > 0) {
86+
await this.mineBlock(newTxs);
87+
}
88+
}
89+
90+
private async mineBlock(txs: Hex[]): Promise<void> {
91+
const newTimestamp = this.timestamp + 12n;
92+
const prevRandao = '0x' + randomBytes(32).toString('hex') as Hex;
93+
94+
const forkchoiceState = {
95+
headBlockHash: this.parentHash,
96+
safeBlockHash: this.parentHash,
97+
finalizedBlockHash: this.parentHash,
98+
};
99+
100+
const payloadAttributes = {
101+
timestamp: '0x' + newTimestamp.toString(16),
102+
prevRandao,
103+
suggestedFeeRecipient: this.feeRecipient,
104+
withdrawals: [],
105+
parentBeaconBlockRoot: '0x0000000000000000000000000000000000000000000000000000000000000000',
106+
transactions: txs,
107+
gasLimit: this.gasLimit,
108+
};
109+
110+
// Step 1: FCU with payload attributes -> get payloadId
111+
const fcuResult = await this.engineCall('engine_forkchoiceUpdatedV3', [
112+
forkchoiceState,
113+
payloadAttributes,
114+
]);
115+
const payloadId = fcuResult.payloadId;
116+
if (!payloadId) {
117+
throw new Error('No payloadId returned from forkchoiceUpdated');
118+
}
119+
120+
// Step 2: getPayload -> get execution payload
121+
const payloadEnvelope = await this.engineCall('engine_getPayloadV3', [payloadId]);
122+
const executionPayload = payloadEnvelope.executionPayload;
123+
124+
// Step 3: newPayload -> validate
125+
const newPayloadStatus = await this.engineCall('engine_newPayloadV3', [
126+
executionPayload,
127+
[],
128+
'0x0000000000000000000000000000000000000000000000000000000000000000',
129+
]);
130+
if (newPayloadStatus.status !== 'VALID') {
131+
throw new Error(`newPayload returned status: ${newPayloadStatus.status}`);
132+
}
133+
134+
// Step 4: FCU to finalize new head
135+
const newBlockHash = executionPayload.blockHash;
136+
await this.engineCall('engine_forkchoiceUpdatedV3', [
137+
{
138+
headBlockHash: newBlockHash,
139+
safeBlockHash: newBlockHash,
140+
finalizedBlockHash: newBlockHash,
141+
},
142+
null,
143+
]);
144+
145+
// Update internal state
146+
this.parentHash = newBlockHash;
147+
this.blockNumber = BigInt(executionPayload.blockNumber);
148+
this.timestamp = BigInt(executionPayload.timestamp);
149+
}
150+
151+
private async rpcCall(url: string, method: string, params: unknown[]): Promise<any> {
152+
const res = await fetch(url, {
153+
method: 'POST',
154+
headers: { 'Content-Type': 'application/json' },
155+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
156+
});
157+
const json = await res.json();
158+
if (json.error) {
159+
throw new Error(`RPC ${method}: ${json.error.message ?? JSON.stringify(json.error)}`);
160+
}
161+
return json.result;
162+
}
163+
164+
private async engineCall(method: string, params: unknown[]): Promise<any> {
165+
const token = this.createJwt();
166+
const res = await fetch(this.engineUrl, {
167+
method: 'POST',
168+
headers: {
169+
'Content-Type': 'application/json',
170+
Authorization: `Bearer ${token}`,
171+
},
172+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
173+
});
174+
const json = await res.json();
175+
if (json.error) {
176+
throw new Error(`Engine ${method}: ${json.error.message ?? JSON.stringify(json.error)}`);
177+
}
178+
return json.result;
179+
}
180+
181+
private createJwt(): string {
182+
const header = { alg: 'HS256', typ: 'JWT' };
183+
const now = Math.floor(Date.now() / 1000);
184+
const payload = { iat: now, exp: now + 3600 };
185+
186+
const b64Header = base64url(JSON.stringify(header));
187+
const b64Payload = base64url(JSON.stringify(payload));
188+
const unsigned = `${b64Header}.${b64Payload}`;
189+
190+
const signature = createHmac('sha256', this.jwtSecret)
191+
.update(unsigned)
192+
.digest();
193+
194+
return `${unsigned}.${base64url(signature)}`;
195+
}
196+
}
197+
198+
function base64url(input: string | Buffer): string {
199+
const buf = typeof input === 'string' ? Buffer.from(input) : input;
200+
return buf.toString('base64url');
201+
}

0 commit comments

Comments
 (0)