Skip to content

Commit 69bd374

Browse files
committed
feat(e2e): add shiva client createShivaClient
1 parent 81ff7cf commit 69bd374

File tree

2 files changed

+308
-0
lines changed

2 files changed

+308
-0
lines changed
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import type { LitClientInstance } from '../types';
2+
3+
/**
4+
* Options used when Shiva spins up a brand-new testnet instance.
5+
* Values mirror the Rust manager contract; all fields are optional for our wrapper.
6+
*/
7+
type TestNetCreateRequest = {
8+
nodeCount: number;
9+
pollingInterval: string;
10+
epochLength: number;
11+
customBuildPath?: string | null;
12+
litActionServerCustomBuildPath?: string | null;
13+
existingConfigPath?: string | null;
14+
which?: string | null;
15+
ecdsaRoundTimeout?: string | null;
16+
enableRateLimiting?: string | null;
17+
};
18+
19+
type TestNetResponse<T> = {
20+
testnet_id: string;
21+
command: string;
22+
was_canceled: boolean;
23+
body: T | null;
24+
last_state_observed: string | null;
25+
messages: string[] | null;
26+
errors: string[] | null;
27+
};
28+
29+
type TestNetState = 'Busy' | 'Active' | 'Mutating' | 'Shutdown' | 'UNKNOWN';
30+
31+
/**
32+
* Configuration accepted by {@link createShivaClient}.
33+
*/
34+
type CreateShivaClientOptions = {
35+
baseUrl: string;
36+
testnetId?: string;
37+
createRequest?: TestNetCreateRequest;
38+
};
39+
40+
type FetchOptions = {
41+
method?: 'GET' | 'POST';
42+
body?: unknown;
43+
};
44+
45+
/**
46+
* Snapshot returned from {@link ShivaClient.inspectEpoch} and {@link ShivaClient.waitForEpochChange}.
47+
*/
48+
type EpochSnapshot = {
49+
epoch: number | undefined;
50+
nodeEpochs: Array<{ url: string; epoch: number | undefined }>;
51+
threshold: number | undefined;
52+
connectedCount: number | undefined;
53+
latestBlockhash: string | undefined;
54+
rawContext: any;
55+
};
56+
57+
/**
58+
* Options for {@link ShivaClient.waitForEpochChange}.
59+
*/
60+
type WaitForEpochOptions = {
61+
baselineEpoch: number | undefined;
62+
timeoutMs?: number;
63+
intervalMs?: number;
64+
};
65+
66+
/**
67+
* High-level interface surfaced by {@link createShivaClient}.
68+
*/
69+
export type ShivaClient = {
70+
baseUrl: string;
71+
testnetId: string;
72+
/** Fetch a one-off snapshot of the Lit context and per-node epochs. */
73+
inspectEpoch: () => Promise<EpochSnapshot>;
74+
/**
75+
* Poll the Lit client until it reports an epoch different from {@link WaitForEpochOptions.baselineEpoch}.
76+
* Useful immediately after triggering an epoch change via Shiva.
77+
*/
78+
waitForEpochChange: (options: WaitForEpochOptions) => Promise<EpochSnapshot>;
79+
/** Invoke Shiva's `/test/action/transition/epoch/wait/<id>` and wait for completion. */
80+
transitionEpochAndWait: () => Promise<boolean>;
81+
/** Stop a random node and wait for the subsequent epoch change. */
82+
stopRandomNodeAndWait: () => Promise<boolean>;
83+
/** Query the current state of the managed testnet (Busy, Active, etc.). */
84+
pollTestnetState: () => Promise<TestNetState>;
85+
/** Retrieve the full testnet configuration (contract ABIs, RPC URL, etc.). */
86+
getTestnetInfo: () => Promise<unknown>;
87+
/** Shut down the underlying testnet through the Shiva manager. */
88+
deleteTestnet: () => Promise<boolean>;
89+
};
90+
91+
const DEFAULT_POLL_INTERVAL = 2000;
92+
const DEFAULT_TIMEOUT = 60_000;
93+
94+
const normaliseBaseUrl = (baseUrl: string) => {
95+
return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
96+
};
97+
98+
const toJson = async <T>(response: Response): Promise<T> => {
99+
const text = await response.text();
100+
try {
101+
return JSON.parse(text) as T;
102+
} catch (error) {
103+
throw new Error(
104+
`Failed to parse Shiva response as JSON (status ${response.status}): ${text}`
105+
);
106+
}
107+
};
108+
109+
const fetchShiva = async <T>(
110+
baseUrl: string,
111+
path: string,
112+
options: FetchOptions = {}
113+
): Promise<TestNetResponse<T>> => {
114+
const url = `${normaliseBaseUrl(baseUrl)}${
115+
path.startsWith('/') ? '' : '/'
116+
}${path}`;
117+
118+
const response = await fetch(url, {
119+
method: options.method ?? 'GET',
120+
headers:
121+
options.method === 'POST'
122+
? {
123+
'Content-Type': 'application/json',
124+
}
125+
: undefined,
126+
body:
127+
options.method === 'POST' && options.body
128+
? JSON.stringify(options.body)
129+
: undefined,
130+
});
131+
132+
const parsed = await toJson<TestNetResponse<T>>(response);
133+
134+
if (!response.ok || (parsed.errors && parsed.errors.length > 0)) {
135+
const message =
136+
parsed.errors?.join('; ') ??
137+
`Shiva request failed with status ${response.status}`;
138+
throw new Error(message);
139+
}
140+
141+
return parsed;
142+
};
143+
144+
const getTestnetIds = async (baseUrl: string): Promise<string[]> => {
145+
const url = `${normaliseBaseUrl(baseUrl)}/test/get/testnets`;
146+
const response = await fetch(url);
147+
if (!response.ok) {
148+
const body = await response.text();
149+
throw new Error(
150+
`Failed to fetch testnets from Shiva (status ${response.status}): ${body}`
151+
);
152+
}
153+
return (await response.json()) as string[];
154+
};
155+
156+
const ensureTestnetId = async (
157+
baseUrl: string,
158+
providedId?: string,
159+
createRequest?: TestNetCreateRequest
160+
): Promise<string> => {
161+
if (providedId) {
162+
return providedId;
163+
}
164+
165+
const existing = await getTestnetIds(baseUrl);
166+
if (existing.length > 0) {
167+
return existing[0];
168+
}
169+
170+
if (!createRequest) {
171+
throw new Error(
172+
'No Shiva testnet is running. Provide a testnetId or a createRequest to start one.'
173+
);
174+
}
175+
176+
const response = await fetchShiva<void>(baseUrl, '/test/create/testnet', {
177+
method: 'POST',
178+
body: createRequest,
179+
});
180+
181+
if (!response.testnet_id) {
182+
throw new Error('Shiva create testnet response did not include testnet_id');
183+
}
184+
185+
return response.testnet_id;
186+
};
187+
188+
const buildEpochSnapshot = (ctx: any): EpochSnapshot => {
189+
const nodeEpochEntries = Object.entries(
190+
ctx?.handshakeResult?.serverKeys ?? {}
191+
);
192+
const nodeEpochs = nodeEpochEntries.map(([url, data]: [string, any]) => ({
193+
url,
194+
epoch: data?.epoch,
195+
}));
196+
197+
const connected = ctx?.handshakeResult?.connectedNodes;
198+
const connectedCount =
199+
typeof connected?.size === 'number'
200+
? connected.size
201+
: Array.isArray(connected)
202+
? connected.length
203+
: undefined;
204+
205+
return {
206+
epoch: ctx?.latestConnectionInfo?.epochInfo?.number,
207+
nodeEpochs,
208+
threshold: ctx?.handshakeResult?.threshold,
209+
connectedCount,
210+
latestBlockhash: ctx?.latestBlockhash,
211+
rawContext: ctx,
212+
};
213+
};
214+
215+
/**
216+
* Creates a Shiva client wrapper for the provided Lit client instance.
217+
* The wrapper talks to the Shiva manager REST endpoints, auto-discovers (or optionally creates) a testnet,
218+
* and exposes helpers for triggering and validating epoch transitions.
219+
*/
220+
export const createShivaClient = async (
221+
litClient: LitClientInstance,
222+
options: CreateShivaClientOptions
223+
): Promise<ShivaClient> => {
224+
const baseUrl = normaliseBaseUrl(options.baseUrl);
225+
const testnetId = await ensureTestnetId(
226+
baseUrl,
227+
options.testnetId,
228+
options.createRequest
229+
);
230+
231+
const inspectEpoch = async () => {
232+
const ctx = await litClient.getContext();
233+
return buildEpochSnapshot(ctx);
234+
};
235+
236+
const waitForEpochChange = async ({
237+
baselineEpoch,
238+
timeoutMs = DEFAULT_TIMEOUT,
239+
intervalMs = DEFAULT_POLL_INTERVAL,
240+
}: WaitForEpochOptions) => {
241+
const deadline = Date.now() + timeoutMs;
242+
243+
while (Date.now() < deadline) {
244+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
245+
const snapshot = await inspectEpoch();
246+
if (snapshot.epoch !== baselineEpoch) {
247+
return snapshot;
248+
}
249+
}
250+
251+
throw new Error(
252+
`Epoch did not change from ${baselineEpoch} within ${timeoutMs}ms`
253+
);
254+
};
255+
256+
const transitionEpochAndWait = async () => {
257+
const response = await fetchShiva<boolean>(
258+
baseUrl,
259+
`/test/action/transition/epoch/wait/${testnetId}`
260+
);
261+
return Boolean(response.body);
262+
};
263+
264+
const stopRandomNodeAndWait = async () => {
265+
const response = await fetchShiva<boolean>(
266+
baseUrl,
267+
`/test/action/stop/random/wait/${testnetId}`
268+
);
269+
return Boolean(response.body);
270+
};
271+
272+
const pollTestnetState = async () => {
273+
const response = await fetchShiva<string>(
274+
baseUrl,
275+
`/test/poll/testnet/${testnetId}`
276+
);
277+
return (response.body ?? 'UNKNOWN') as TestNetState;
278+
};
279+
280+
const getTestnetInfo = async () => {
281+
const response = await fetchShiva<unknown>(
282+
baseUrl,
283+
`/test/get/info/testnet/${testnetId}`
284+
);
285+
return response.body;
286+
};
287+
288+
const deleteTestnet = async () => {
289+
const response = await fetchShiva<boolean>(
290+
baseUrl,
291+
`/test/delete/testnet/${testnetId}`
292+
);
293+
return Boolean(response.body);
294+
};
295+
296+
return {
297+
baseUrl,
298+
testnetId,
299+
inspectEpoch,
300+
waitForEpochChange,
301+
transitionEpochAndWait,
302+
stopRandomNodeAndWait,
303+
pollTestnetState,
304+
getTestnetInfo,
305+
deleteTestnet,
306+
};
307+
};

packages/e2e/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export * from './helper/NetworkManager';
66

77
export { printAligned } from './helper/utils';
88
export { getOrCreatePkp } from './helper/pkp-utils';
9+
export { createShivaClient } from './helper/shiva-client';

0 commit comments

Comments
 (0)