From d12339d2994090569854dc79a09f81438d141515 Mon Sep 17 00:00:00 2001 From: anson Date: Mon, 3 Nov 2025 15:15:42 +0000 Subject: [PATCH 01/10] feat: add exports --- packages/e2e/src/index.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/e2e/src/index.ts b/packages/e2e/src/index.ts index 77331ae10..7e1b476ec 100644 --- a/packages/e2e/src/index.ts +++ b/packages/e2e/src/index.ts @@ -1,10 +1,17 @@ // re-export -export { init } from './init'; export * from './helper/auth-contexts'; -export * from './helper/tests'; export * from './helper/NetworkManager'; +export * from './helper/tests'; +export { init } from './init'; -export { printAligned } from './helper/utils'; export { getOrCreatePkp } from './helper/pkp-utils'; export { createShivaClient } from './helper/shiva-client'; +export { printAligned } from './helper/utils'; export type { AuthContext } from './types'; + +// re-export new helpers that should be used to refactor the `init.ts` proces +// see packages/e2e/src/tickets/delegation.suite.ts for usage examples +export { createEnvVars } from './helper/createEnvVars'; +export { createTestAccount } from './helper/createTestAccount'; +export { createTestEnv } from './helper/createTestEnv'; +export type { CreateTestAccountResult } from './helper/createTestAccount'; From 3ab3ad6bfcdd126fd97330f33d56f4d2d2aa0883 Mon Sep 17 00:00:00 2001 From: anson Date: Mon, 3 Nov 2025 16:16:02 +0000 Subject: [PATCH 02/10] feat(e2e): register payment delegation ticket suite in index export --- packages/e2e/src/e2e.spec.ts | 2 +- packages/e2e/src/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/e2e/src/e2e.spec.ts b/packages/e2e/src/e2e.spec.ts index b00707756..c2ea2f1d8 100644 --- a/packages/e2e/src/e2e.spec.ts +++ b/packages/e2e/src/e2e.spec.ts @@ -14,9 +14,9 @@ import { createViewPKPsByAddressTest, createViewPKPsByAuthDataTest, init, + registerPaymentDelegationTicketSuite, } from '@lit-protocol/e2e'; import type { AuthContext } from '@lit-protocol/e2e'; -import { registerPaymentDelegationTicketSuite } from './tickets/delegation.suite'; const RPC_OVERRIDE = process.env['LIT_YELLOWSTONE_PRIVATE_RPC_URL']; if (RPC_OVERRIDE) { diff --git a/packages/e2e/src/index.ts b/packages/e2e/src/index.ts index 7e1b476ec..eae8338e3 100644 --- a/packages/e2e/src/index.ts +++ b/packages/e2e/src/index.ts @@ -15,3 +15,4 @@ export { createEnvVars } from './helper/createEnvVars'; export { createTestAccount } from './helper/createTestAccount'; export { createTestEnv } from './helper/createTestEnv'; export type { CreateTestAccountResult } from './helper/createTestAccount'; +export { registerPaymentDelegationTicketSuite } from './tickets/delegation.suite'; From 0f1baecd6e0a8e9713b162ae63b707b63a393931 Mon Sep 17 00:00:00 2001 From: anson Date: Mon, 3 Nov 2025 17:57:20 +0000 Subject: [PATCH 03/10] refactor(WIP): making shiva work --- packages/e2e/src/helper/shiva-client.ts | 120 ++++++++++++------------ 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/packages/e2e/src/helper/shiva-client.ts b/packages/e2e/src/helper/shiva-client.ts index b8357531c..7e1cbfc7d 100644 --- a/packages/e2e/src/helper/shiva-client.ts +++ b/packages/e2e/src/helper/shiva-client.ts @@ -1,5 +1,3 @@ -import type { LitClientInstance } from '../types'; - /** * Options used when Shiva spins up a brand-new testnet instance. * Values mirror the Rust manager contract; all fields are optional for our wrapper. @@ -70,12 +68,12 @@ export type ShivaClient = { baseUrl: string; testnetId: string; /** Fetch a one-off snapshot of the Lit context and per-node epochs. */ - inspectEpoch: () => Promise; + // inspectEpoch: () => Promise; /** * Poll the Lit client until it reports an epoch different from {@link WaitForEpochOptions.baselineEpoch}. * Useful immediately after triggering an epoch change via Shiva. */ - waitForEpochChange: (options: WaitForEpochOptions) => Promise; + // waitForEpochChange: (options: WaitForEpochOptions) => Promise; /** Invoke Shiva's `/test/action/transition/epoch/wait/` and wait for completion. */ transitionEpochAndWait: () => Promise; /** Stop a random node and wait for the subsequent epoch change. */ @@ -153,7 +151,7 @@ const getTestnetIds = async (baseUrl: string): Promise => { return (await response.json()) as string[]; }; -const ensureTestnetId = async ( +const getOrCreateTestnetId = async ( baseUrl: string, providedId?: string, createRequest?: TestNetCreateRequest @@ -179,38 +177,41 @@ const ensureTestnetId = async ( }); if (!response.testnet_id) { - throw new Error('Shiva create testnet response did not include testnet_id'); + throw new Error( + 'Shiva create testnet response did not include testnet_id. Received: ' + + JSON.stringify(response) + ); } return response.testnet_id; }; -const buildEpochSnapshot = (ctx: any): EpochSnapshot => { - const nodeEpochEntries = Object.entries( - ctx?.handshakeResult?.serverKeys ?? {} - ); - const nodeEpochs = nodeEpochEntries.map(([url, data]: [string, any]) => ({ - url, - epoch: data?.epoch, - })); - - const connected = ctx?.handshakeResult?.connectedNodes; - const connectedCount = - typeof connected?.size === 'number' - ? connected.size - : Array.isArray(connected) - ? connected.length - : undefined; - - return { - epoch: ctx?.latestConnectionInfo?.epochInfo?.number, - nodeEpochs, - threshold: ctx?.handshakeResult?.threshold, - connectedCount, - latestBlockhash: ctx?.latestBlockhash, - rawContext: ctx, - }; -}; +// const buildEpochSnapshot = (ctx: any): EpochSnapshot => { +// const nodeEpochEntries = Object.entries( +// ctx?.handshakeResult?.serverKeys ?? {} +// ); +// const nodeEpochs = nodeEpochEntries.map(([url, data]: [string, any]) => ({ +// url, +// epoch: data?.epoch, +// })); + +// const connected = ctx?.handshakeResult?.connectedNodes; +// const connectedCount = +// typeof connected?.size === 'number' +// ? connected.size +// : Array.isArray(connected) +// ? connected.length +// : undefined; + +// return { +// epoch: ctx?.latestConnectionInfo?.epochInfo?.number, +// nodeEpochs, +// threshold: ctx?.handshakeResult?.threshold, +// connectedCount, +// latestBlockhash: ctx?.latestBlockhash, +// rawContext: ctx, +// }; +// }; /** * Creates a Shiva client wrapper for the provided Lit client instance. @@ -218,40 +219,39 @@ const buildEpochSnapshot = (ctx: any): EpochSnapshot => { * and exposes helpers for triggering and validating epoch transitions. */ export const createShivaClient = async ( - litClient: LitClientInstance, options: CreateShivaClientOptions ): Promise => { const baseUrl = normaliseBaseUrl(options.baseUrl); - const testnetId = await ensureTestnetId( + const testnetId = await getOrCreateTestnetId( baseUrl, options.testnetId, options.createRequest ); - const inspectEpoch = async () => { - const ctx = await litClient.getContext(); - return buildEpochSnapshot(ctx); - }; - - const waitForEpochChange = async ({ - baselineEpoch, - timeoutMs = DEFAULT_TIMEOUT, - intervalMs = DEFAULT_POLL_INTERVAL, - }: WaitForEpochOptions) => { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - const snapshot = await inspectEpoch(); - if (snapshot.epoch !== baselineEpoch) { - return snapshot; - } - } - - throw new Error( - `Epoch did not change from ${baselineEpoch} within ${timeoutMs}ms` - ); - }; + // const inspectEpoch = async () => { + // const ctx = await litClient.getContext(); + // return buildEpochSnapshot(ctx); + // }; + + // const waitForEpochChange = async ({ + // baselineEpoch, + // timeoutMs = DEFAULT_TIMEOUT, + // intervalMs = DEFAULT_POLL_INTERVAL, + // }: WaitForEpochOptions) => { + // const deadline = Date.now() + timeoutMs; + + // while (Date.now() < deadline) { + // await new Promise((resolve) => setTimeout(resolve, intervalMs)); + // const snapshot = await inspectEpoch(); + // if (snapshot.epoch !== baselineEpoch) { + // return snapshot; + // } + // } + + // throw new Error( + // `Epoch did not change from ${baselineEpoch} within ${timeoutMs}ms` + // ); + // }; const transitionEpochAndWait = async () => { const response = await fetchShiva( @@ -296,8 +296,8 @@ export const createShivaClient = async ( return { baseUrl, testnetId, - inspectEpoch, - waitForEpochChange, + // inspectEpoch, + // waitForEpochChange, transitionEpochAndWait, stopRandomNodeAndWait, pollTestnetState, From cbfc8cb3ebc1d239a1c3f511355737069f2cfee9 Mon Sep 17 00:00:00 2001 From: anson Date: Mon, 3 Nov 2025 18:11:49 +0000 Subject: [PATCH 04/10] refactor: standardize response property names in TestNetResponse --- packages/e2e/src/helper/shiva-client.ts | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/e2e/src/helper/shiva-client.ts b/packages/e2e/src/helper/shiva-client.ts index 7e1cbfc7d..f70f6dcde 100644 --- a/packages/e2e/src/helper/shiva-client.ts +++ b/packages/e2e/src/helper/shiva-client.ts @@ -15,11 +15,11 @@ type TestNetCreateRequest = { }; type TestNetResponse = { - testnet_id: string; + testnetId: string; command: string; - was_canceled: boolean; + wasCanceled: boolean; body: T | null; - last_state_observed: string | null; + lastStateObserved: string | null; messages: string[] | null; errors: string[] | null; }; @@ -43,14 +43,14 @@ type FetchOptions = { /** * Snapshot returned from {@link ShivaClient.inspectEpoch} and {@link ShivaClient.waitForEpochChange}. */ -type EpochSnapshot = { - epoch: number | undefined; - nodeEpochs: Array<{ url: string; epoch: number | undefined }>; - threshold: number | undefined; - connectedCount: number | undefined; - latestBlockhash: string | undefined; - rawContext: any; -}; +// type EpochSnapshot = { +// epoch: number | undefined; +// nodeEpochs: Array<{ url: string; epoch: number | undefined }>; +// threshold: number | undefined; +// connectedCount: number | undefined; +// latestBlockhash: string | undefined; +// rawContext: any; +// }; /** * Options for {@link ShivaClient.waitForEpochChange}. @@ -86,8 +86,8 @@ export type ShivaClient = { deleteTestnet: () => Promise; }; -const DEFAULT_POLL_INTERVAL = 2000; -const DEFAULT_TIMEOUT = 60_000; +// const DEFAULT_POLL_INTERVAL = 2000; +// const DEFAULT_TIMEOUT = 60_000; const normaliseBaseUrl = (baseUrl: string) => { return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; @@ -176,14 +176,14 @@ const getOrCreateTestnetId = async ( body: createRequest, }); - if (!response.testnet_id) { + if (!response.testnetId) { throw new Error( - 'Shiva create testnet response did not include testnet_id. Received: ' + + 'Shiva create testnet response did not include testnetId. Received: ' + JSON.stringify(response) ); } - return response.testnet_id; + return response.testnetId; }; // const buildEpochSnapshot = (ctx: any): EpochSnapshot => { From 3c27611390d07131f5706c9a8852b4eed53db60d Mon Sep 17 00:00:00 2001 From: anson Date: Wed, 5 Nov 2025 16:40:37 +0000 Subject: [PATCH 05/10] feat(lit-client, networks): handle edge cases --- .../createShivaClient.ts} | 200 ++++---- .../helpers/createEpochSnapshot.ts | 82 ++++ packages/e2e/src/index.ts | 4 +- .../src/lib/LitClient/createLitClient.ts | 435 ++++++++---------- .../LitClient/helpers/executeWithHandshake.ts | 149 ++++++ .../state-manager/createStateManager.ts | 126 ++--- 6 files changed, 621 insertions(+), 375 deletions(-) rename packages/e2e/src/helper/{shiva-client.ts => ShivaClient/createShivaClient.ts} (64%) create mode 100644 packages/e2e/src/helper/ShivaClient/helpers/createEpochSnapshot.ts create mode 100644 packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts diff --git a/packages/e2e/src/helper/shiva-client.ts b/packages/e2e/src/helper/ShivaClient/createShivaClient.ts similarity index 64% rename from packages/e2e/src/helper/shiva-client.ts rename to packages/e2e/src/helper/ShivaClient/createShivaClient.ts index f70f6dcde..739bd619c 100644 --- a/packages/e2e/src/helper/shiva-client.ts +++ b/packages/e2e/src/helper/ShivaClient/createShivaClient.ts @@ -1,3 +1,9 @@ +import { createLitClient } from '@lit-protocol/lit-client'; +import { + createEpochSnapshot, + EpochSnapshot, +} from './helpers/createEpochSnapshot'; + /** * Options used when Shiva spins up a brand-new testnet instance. * Values mirror the Rust manager contract; all fields are optional for our wrapper. @@ -40,23 +46,17 @@ type FetchOptions = { body?: unknown; }; -/** - * Snapshot returned from {@link ShivaClient.inspectEpoch} and {@link ShivaClient.waitForEpochChange}. - */ -// type EpochSnapshot = { -// epoch: number | undefined; -// nodeEpochs: Array<{ url: string; epoch: number | undefined }>; -// threshold: number | undefined; -// connectedCount: number | undefined; -// latestBlockhash: string | undefined; -// rawContext: any; -// }; - /** * Options for {@link ShivaClient.waitForEpochChange}. */ type WaitForEpochOptions = { - baselineEpoch: number | undefined; + expectedEpoch: number | undefined; + timeoutMs?: number; + intervalMs?: number; +}; + +type PollTestnetStateOptions = { + waitFor?: TestNetState | TestNetState[]; timeoutMs?: number; intervalMs?: number; }; @@ -68,26 +68,42 @@ export type ShivaClient = { baseUrl: string; testnetId: string; /** Fetch a one-off snapshot of the Lit context and per-node epochs. */ - // inspectEpoch: () => Promise; + inspectEpoch: () => Promise; /** * Poll the Lit client until it reports an epoch different from {@link WaitForEpochOptions.baselineEpoch}. * Useful immediately after triggering an epoch change via Shiva. */ - // waitForEpochChange: (options: WaitForEpochOptions) => Promise; + waitForEpochChange: (options: WaitForEpochOptions) => Promise; /** Invoke Shiva's `/test/action/transition/epoch/wait/` and wait for completion. */ transitionEpochAndWait: () => Promise; /** Stop a random node and wait for the subsequent epoch change. */ stopRandomNodeAndWait: () => Promise; /** Query the current state of the managed testnet (Busy, Active, etc.). */ - pollTestnetState: () => Promise; + /** + * @example + * ```ts + * // Wait up to two minutes for the testnet to become active. + * await client.pollTestnetState({ waitFor: 'Active', timeoutMs: 120_000 }); + * ``` + */ + pollTestnetState: ( + options?: PollTestnetStateOptions + ) => Promise; /** Retrieve the full testnet configuration (contract ABIs, RPC URL, etc.). */ getTestnetInfo: () => Promise; /** Shut down the underlying testnet through the Shiva manager. */ deleteTestnet: () => Promise; + + // Setters + setLitClient: ( + litClient: Awaited> + ) => void; }; -// const DEFAULT_POLL_INTERVAL = 2000; -// const DEFAULT_TIMEOUT = 60_000; +const DEFAULT_POLL_INTERVAL = 2000; +const DEFAULT_TIMEOUT = 60_000; +const DEFAULT_STATE_POLL_INTERVAL = 2000; +const DEFAULT_STATE_POLL_TIMEOUT = 60_000; const normaliseBaseUrl = (baseUrl: string) => { return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; @@ -186,33 +202,6 @@ const getOrCreateTestnetId = async ( return response.testnetId; }; -// const buildEpochSnapshot = (ctx: any): EpochSnapshot => { -// const nodeEpochEntries = Object.entries( -// ctx?.handshakeResult?.serverKeys ?? {} -// ); -// const nodeEpochs = nodeEpochEntries.map(([url, data]: [string, any]) => ({ -// url, -// epoch: data?.epoch, -// })); - -// const connected = ctx?.handshakeResult?.connectedNodes; -// const connectedCount = -// typeof connected?.size === 'number' -// ? connected.size -// : Array.isArray(connected) -// ? connected.length -// : undefined; - -// return { -// epoch: ctx?.latestConnectionInfo?.epochInfo?.number, -// nodeEpochs, -// threshold: ctx?.handshakeResult?.threshold, -// connectedCount, -// latestBlockhash: ctx?.latestBlockhash, -// rawContext: ctx, -// }; -// }; - /** * Creates a Shiva client wrapper for the provided Lit client instance. * The wrapper talks to the Shiva manager REST endpoints, auto-discovers (or optionally creates) a testnet, @@ -228,30 +217,47 @@ export const createShivaClient = async ( options.createRequest ); - // const inspectEpoch = async () => { - // const ctx = await litClient.getContext(); - // return buildEpochSnapshot(ctx); - // }; - - // const waitForEpochChange = async ({ - // baselineEpoch, - // timeoutMs = DEFAULT_TIMEOUT, - // intervalMs = DEFAULT_POLL_INTERVAL, - // }: WaitForEpochOptions) => { - // const deadline = Date.now() + timeoutMs; - - // while (Date.now() < deadline) { - // await new Promise((resolve) => setTimeout(resolve, intervalMs)); - // const snapshot = await inspectEpoch(); - // if (snapshot.epoch !== baselineEpoch) { - // return snapshot; - // } - // } - - // throw new Error( - // `Epoch did not change from ${baselineEpoch} within ${timeoutMs}ms` - // ); - // }; + let litClientInstance: + | Awaited> + | undefined; + + const setLitClient = ( + client: Awaited> + ) => { + litClientInstance = client; + }; + + const inspectEpoch = async () => { + if (!litClientInstance) { + throw new Error( + `Lit client not set. Please call setLitClient() before using inspectEpoch().` + ); + } + + return createEpochSnapshot(litClientInstance); + }; + + const waitForEpochChange = async ({ + expectedEpoch, + timeoutMs = DEFAULT_TIMEOUT, + intervalMs = DEFAULT_POLL_INTERVAL, + }: WaitForEpochOptions) => { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + const snapshot = await inspectEpoch(); + if ( + snapshot.latestConnectionInfo.epochState.currentNumber !== expectedEpoch + ) { + return snapshot; + } + } + + throw new Error( + `Epoch did not change from ${expectedEpoch} within ${timeoutMs}ms` + ); + }; const transitionEpochAndWait = async () => { const response = await fetchShiva( @@ -266,15 +272,52 @@ export const createShivaClient = async ( baseUrl, `/test/action/stop/random/wait/${testnetId}` ); + + // wait briefly to allow the node to drop from the network + await new Promise((resolve) => setTimeout(resolve, 5000)); + return Boolean(response.body); }; - const pollTestnetState = async () => { - const response = await fetchShiva( - baseUrl, - `/test/poll/testnet/${testnetId}` - ); - return (response.body ?? 'UNKNOWN') as TestNetState; + const pollTestnetState = async ( + options: PollTestnetStateOptions = {} + ): Promise => { + const { + waitFor, + timeoutMs = DEFAULT_STATE_POLL_TIMEOUT, + intervalMs = DEFAULT_STATE_POLL_INTERVAL, + } = options; + + const desiredStates = Array.isArray(waitFor) + ? waitFor + : waitFor + ? [waitFor] + : undefined; + const deadline = Date.now() + timeoutMs; + + // Continue polling until we hit a desired state or timeout. + // If no desired state is provided, return the first observation . + for (;;) { + const response = await fetchShiva( + baseUrl, + `/test/poll/testnet/${testnetId}` + ); + const state = (response.body ?? 'UNKNOWN') as TestNetState; + + if (!desiredStates || desiredStates.includes(state)) { + return state; + } + + if (Date.now() >= deadline) { + throw new Error( + `Timed out after ${timeoutMs}ms waiting for testnet ${testnetId} to reach state ${desiredStates.join( + ', ' + )}. Last observed state: ${state}.` + ); + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } }; const getTestnetInfo = async () => { @@ -296,12 +339,15 @@ export const createShivaClient = async ( return { baseUrl, testnetId, - // inspectEpoch, - // waitForEpochChange, + setLitClient, transitionEpochAndWait, stopRandomNodeAndWait, pollTestnetState, getTestnetInfo, deleteTestnet, + + // utils + inspectEpoch, + waitForEpochChange, }; }; diff --git a/packages/e2e/src/helper/ShivaClient/helpers/createEpochSnapshot.ts b/packages/e2e/src/helper/ShivaClient/helpers/createEpochSnapshot.ts new file mode 100644 index 000000000..18c9b4fb7 --- /dev/null +++ b/packages/e2e/src/helper/ShivaClient/helpers/createEpochSnapshot.ts @@ -0,0 +1,82 @@ +type EpochInfo = { + epochLength: number; + number: number; + endTime: number; + retries: number; + timeout: number; +}; + +type EpochState = { + currentNumber: number; + startTime: number; +}; + +type NetworkPrice = { + url: string; + prices: Array; +}; + +type PriceFeedInfo = { + epochId: number; + minNodeCount: number; + networkPrices: NetworkPrice[]; +}; + +type LatestConnectionInfo = { + epochInfo: EpochInfo; + epochState: EpochState; + minNodeCount: number; + bootstrapUrls: string[]; + priceFeedInfo: PriceFeedInfo; +}; + +type ServerKeyDetails = { + serverPublicKey: string; + subnetPublicKey: string; + networkPublicKey: string; + networkPublicKeySet: string; + clientSdkVersion: string; + hdRootPubkeys: string[]; + attestation?: string | null; + latestBlockhash: string; + nodeIdentityKey: string; + nodeVersion: string; + epoch: number; +}; + +type CoreNodeConfig = { + subnetPubKey: string; + networkPubKey: string; + networkPubKeySet: string; + hdRootPubkeys: string[]; + latestBlockhash: string; +}; + +type HandshakeResult = { + serverKeys: Record; + connectedNodes: Record | Set; + coreNodeConfig: CoreNodeConfig | null; + threshold: number; +}; + +type EpochSnapshotSource = { + latestConnectionInfo?: LatestConnectionInfo | null; + handshakeResult?: HandshakeResult | null; +}; + +export type EpochSnapshot = EpochSnapshotSource; + +export const createEpochSnapshot = async ( + litClient: Awaited< + ReturnType + > +): Promise => { + const ctx = await litClient.getContext(); + + const snapshot = { + latestConnectionInfo: ctx?.latestConnectionInfo, + handshakeResult: ctx?.handshakeResult, + }; + + return snapshot; +}; diff --git a/packages/e2e/src/index.ts b/packages/e2e/src/index.ts index eae8338e3..642edf82c 100644 --- a/packages/e2e/src/index.ts +++ b/packages/e2e/src/index.ts @@ -5,7 +5,6 @@ export * from './helper/tests'; export { init } from './init'; export { getOrCreatePkp } from './helper/pkp-utils'; -export { createShivaClient } from './helper/shiva-client'; export { printAligned } from './helper/utils'; export type { AuthContext } from './types'; @@ -16,3 +15,6 @@ export { createTestAccount } from './helper/createTestAccount'; export { createTestEnv } from './helper/createTestEnv'; export type { CreateTestAccountResult } from './helper/createTestAccount'; export { registerPaymentDelegationTicketSuite } from './tickets/delegation.suite'; + +// -- Shiva +export { createShivaClient } from './helper/ShivaClient/createShivaClient'; diff --git a/packages/lit-client/src/lib/LitClient/createLitClient.ts b/packages/lit-client/src/lib/LitClient/createLitClient.ts index f497d1a27..79f7afb28 100644 --- a/packages/lit-client/src/lib/LitClient/createLitClient.ts +++ b/packages/lit-client/src/lib/LitClient/createLitClient.ts @@ -60,6 +60,7 @@ import { extractFileMetadata, inferDataType, } from './helpers/convertDecryptedData'; +import { executeWithHandshake } from './helpers/executeWithHandshake'; import { createPKPViemAccount } from './intergrations/createPkpViemAccount'; import { orchestrateHandshake } from './orchestrateHandshake'; import { @@ -200,148 +201,155 @@ export const _createNagaLitClient = async ( ); } - async function _pkpSign( - params: z.infer & { - bypassAutoHashing?: boolean; - } - ): Promise { - _logger.info( - `πŸ”₯ signing on ${params.chain} with ${params.signingScheme} (bypass: ${ - params.bypassAutoHashing || false - })` - ); - - // 🟩 get the fresh handshake results - const currentHandshakeResult = _stateManager.getCallbackResult(); - const currentConnectionInfo = _stateManager.getLatestConnectionInfo(); + const buildHandshakeExecutionContext = async () => { + const handshakeResult = _stateManager.getCallbackResult(); + const connectionInfo = _stateManager.getLatestConnectionInfo(); - if (!currentHandshakeResult || !currentConnectionInfo) { + if (!handshakeResult || !connectionInfo) { throw new LitNodeClientNotReadyError( { - cause: new Error('Handshake result unavailable for pkpSign'), + cause: new Error( + 'Handshake result unavailable while building execution context' + ), info: { - operation: 'pkpSign', + operation: 'buildHandshakeExecutionContext', }, }, - 'Handshake result is not available from state manager at the time of pkpSign.' + 'Handshake result is not available from state manager.' ); } const jitContext = await networkModule.api.createJitContext( - currentConnectionInfo, - currentHandshakeResult + connectionInfo, + handshakeResult ); - // πŸŸͺ Create requests - // 1. This is where the orchestration begins β€” we delegate the creation of the - // request array to the `networkModule`. It encapsulates logic specific to the - // active network (e.g., pricing, thresholds, metadata) and returns a set of - // structured requests ready to be dispatched to the nodes. - - // Create signing context with optional bypass flag - const signingContext: any = { - pubKey: params.pubKey, - toSign: params.toSign, - signingScheme: params.signingScheme, - }; + return { handshakeResult, connectionInfo, jitContext }; + }; - // Add bypass flag if provided - if (params.bypassAutoHashing) { - signingContext.bypassAutoHashing = true; + const refreshHandshakeExecutionContext = async (reason: string) => { + if (typeof _stateManager.refreshHandshake === 'function') { + _logger.info({ reason }, 'Refreshing handshake via state manager'); + await _stateManager.refreshHandshake(reason); + } else { + _logger.warn( + { reason }, + 'State manager does not expose refreshHandshake; proceeding without manual refresh.' + ); } + return await buildHandshakeExecutionContext(); + }; - const requestArray = (await networkModule.api.pkpSign.createRequest({ - // add chain context (btc, eth, cosmos, solana) - serverKeys: currentHandshakeResult.serverKeys, - pricingContext: { - product: 'SIGN', - userMaxPrice: params.userMaxPrice, - nodePrices: jitContext.nodePrices, - threshold: currentHandshakeResult.threshold, - }, - authContext: params.authContext, - signingContext, - connectionInfo: currentConnectionInfo, - version: networkModule.version, - chain: params.chain, - jitContext, - })) as RequestItem>[]; - - const requestId = requestArray[0].requestId; - - // 🟩 Dispatch requests - // 2. With the request array prepared, we now coordinate the parallel execution - // across multiple nodes. This step handles batching, minimum threshold success - // tracking, and error tolerance. The orchestration layer ensures enough valid - // responses are collected before proceeding. - const result = await dispatchRequests< - z.infer, - z.infer - >(requestArray, requestId, currentHandshakeResult.threshold); + async function _pkpSign( + params: z.infer & { + bypassAutoHashing?: boolean; + } + ): Promise { + _logger.info( + `πŸ”₯ signing on ${params.chain} with ${params.signingScheme} (bypass: ${ + params.bypassAutoHashing || false + })` + ); - // πŸŸͺ Handle response - // 3. Once node responses are received and validated, we delegate final - // interpretation and formatting of the result back to the `networkModule`. - // This allows the module to apply network-specific logic such as decoding, - // formatting, or transforming the response into a usable signature object. + return await executeWithHandshake({ + operation: 'pkpSign', + buildContext: buildHandshakeExecutionContext, + refreshContext: refreshHandshakeExecutionContext, + runner: async ({ handshakeResult, connectionInfo, jitContext }) => { + // πŸŸͺ Create requests + // 1. This is where the orchestration begins β€” we delegate the creation of the + // request array to the `networkModule`. It encapsulates logic specific to the + // active network (e.g., pricing, thresholds, metadata) and returns a set of + // structured requests ready to be dispatched to the nodes. + + const signingContext: any = { + pubKey: params.pubKey, + toSign: params.toSign, + signingScheme: params.signingScheme, + }; + + if (params.bypassAutoHashing) { + signingContext.bypassAutoHashing = true; + } - // Pass the success result to handleResponse - the result structure matches GenericEncryptedPayloadSchema - return await networkModule.api.pkpSign.handleResponse( - result as any, - requestId, - jitContext - ); + const requestArray = (await networkModule.api.pkpSign.createRequest({ + serverKeys: handshakeResult.serverKeys, + pricingContext: { + product: 'SIGN', + userMaxPrice: params.userMaxPrice, + nodePrices: jitContext.nodePrices, + threshold: handshakeResult.threshold, + }, + authContext: params.authContext, + signingContext, + connectionInfo, + version: networkModule.version, + chain: params.chain, + jitContext, + })) as RequestItem>[]; + + const requestId = requestArray[0].requestId; + + // 🟩 Dispatch requests + // 2. With the request array prepared, we now coordinate the parallel execution + // across multiple nodes. This step handles batching, minimum threshold success + // tracking, and error tolerance. The orchestration layer ensures enough valid + // responses are collected before proceeding. + const result = await dispatchRequests< + z.infer, + z.infer + >(requestArray, requestId, handshakeResult.threshold); + + // πŸŸͺ Handle response + // 3. Once node responses are received and validated, we delegate final + // interpretation and formatting of the result back to the `networkModule`. + // This allows the module to apply network-specific logic such as decoding, + // formatting, or transforming the response into a usable signature object. + return await networkModule.api.pkpSign.handleResponse( + result as any, + requestId, + jitContext + ); + }, + }); } async function _signSessionKey(params: { nodeUrls: string[]; requestBody: z.infer; }) { - // 1. 🟩 get the fresh handshake results - const currentHandshakeResult = _stateManager.getCallbackResult(); - const currentConnectionInfo = _stateManager.getLatestConnectionInfo(); - - if (!currentHandshakeResult || !currentConnectionInfo) { - throw new LitNodeClientNotReadyError( - { - cause: new Error('Handshake result unavailable for signSessionKey'), - info: { - operation: 'signSessionKey', - }, - }, - 'Handshake result is not available from state manager at the time of pkpSign.' - ); - } - - const jitContext = await networkModule.api.createJitContext( - currentConnectionInfo, - currentHandshakeResult - ); - - // 2. πŸŸͺ Create requests - const requestArray = await networkModule.api.signSessionKey.createRequest( - params.requestBody, - networkModule.config.httpProtocol, - networkModule.version, - jitContext - ); - - const requestId = requestArray[0].requestId; - - // 3. 🟩 Dispatch requests - const result = await dispatchRequests( - requestArray, - requestId, - currentHandshakeResult.threshold - ); + return await executeWithHandshake({ + operation: 'signSessionKey', + buildContext: buildHandshakeExecutionContext, + refreshContext: refreshHandshakeExecutionContext, + runner: async ({ handshakeResult, connectionInfo, jitContext }) => { + // 2. πŸŸͺ Create requests + const requestArray = + await networkModule.api.signSessionKey.createRequest( + params.requestBody, + networkModule.config.httpProtocol, + networkModule.version, + jitContext + ); - // 4. πŸŸͺ Handle response - return await networkModule.api.signSessionKey.handleResponse( - result as any, - params.requestBody.pkpPublicKey, - jitContext, - requestId - ); + const requestId = requestArray[0].requestId; + + // 3. 🟩 Dispatch requests + const result = await dispatchRequests( + requestArray, + requestId, + handshakeResult.threshold + ); + + // 4. πŸŸͺ Handle response + return await networkModule.api.signSessionKey.handleResponse( + result as any, + params.requestBody.pkpPublicKey, + jitContext, + requestId + ); + }, + }); } async function _signCustomSessionKey(params: { @@ -350,68 +358,35 @@ export const _createNagaLitClient = async ( typeof JsonSignCustomSessionKeyRequestForPkpReturnSchema >; }) { - // 1. 🟩 get the fresh handshake results - const currentHandshakeResult = _stateManager.getCallbackResult(); - const currentConnectionInfo = _stateManager.getLatestConnectionInfo(); - - if (!currentHandshakeResult || !currentConnectionInfo) { - throw new LitNodeClientNotReadyError( - { - cause: new Error( - 'Handshake result unavailable for signCustomSessionKey' - ), - info: { - operation: 'signCustomSessionKey', - }, - }, - 'Handshake result is not available from state manager at the time of pkpSign.' - ); - } - - const jitContext = await networkModule.api.createJitContext( - currentConnectionInfo, - currentHandshakeResult - ); - - if (!currentHandshakeResult || !currentConnectionInfo) { - throw new LitNodeClientNotReadyError( - { - cause: new Error( - 'Handshake result unavailable for signCustomSessionKey' - ), - info: { - operation: 'signCustomSessionKey', - }, - }, - 'Handshake result is not available from state manager at the time of pkpSign.' - ); - } + return await executeWithHandshake({ + operation: 'signCustomSessionKey', + buildContext: buildHandshakeExecutionContext, + refreshContext: refreshHandshakeExecutionContext, + runner: async ({ handshakeResult, jitContext }) => { + const requestArray = + await networkModule.api.signCustomSessionKey.createRequest( + params.requestBody, + networkModule.config.httpProtocol, + networkModule.version, + jitContext + ); - // 2. πŸŸͺ Create requests - const requestArray = - await networkModule.api.signCustomSessionKey.createRequest( - params.requestBody, - networkModule.config.httpProtocol, - networkModule.version, - jitContext - ); + const requestId = requestArray[0].requestId; - const requestId = requestArray[0].requestId; - - // 3. 🟩 Dispatch requests - const result = await dispatchRequests( - requestArray, - requestId, - currentHandshakeResult.threshold - ); + const result = await dispatchRequests( + requestArray, + requestId, + handshakeResult.threshold + ); - // 4. πŸŸͺ Handle response - return await networkModule.api.signCustomSessionKey.handleResponse( - result as any, - params.requestBody.pkpPublicKey, - jitContext, - requestId - ); + return await networkModule.api.signCustomSessionKey.handleResponse( + result as any, + params.requestBody.pkpPublicKey, + jitContext, + requestId + ); + }, + }); } async function _executeJs( @@ -419,75 +394,45 @@ export const _createNagaLitClient = async ( ) { _logger.info(`πŸ”₯ executing JS with ${params.code ? 'code' : 'ipfsId'}`); - // 🟩 get the fresh handshake results - const currentHandshakeResult = _stateManager.getCallbackResult(); - const currentConnectionInfo = _stateManager.getLatestConnectionInfo(); - - if (!currentHandshakeResult || !currentConnectionInfo) { - throw new LitNodeClientNotReadyError( - { - cause: new Error('Handshake result unavailable for executeJs'), - info: { - operation: 'executeJs', + return await executeWithHandshake({ + operation: 'executeJs', + buildContext: buildHandshakeExecutionContext, + refreshContext: refreshHandshakeExecutionContext, + runner: async ({ handshakeResult, connectionInfo, jitContext }) => { + const requestArray = (await networkModule.api.executeJs.createRequest({ + pricingContext: { + product: 'LIT_ACTION', + userMaxPrice: params.userMaxPrice, + nodePrices: jitContext.nodePrices, + threshold: handshakeResult.threshold, }, - }, - 'Handshake result is not available from state manager at the time of executeJs.' - ); - } - - const jitContext = await networkModule.api.createJitContext( - currentConnectionInfo, - currentHandshakeResult - ); - - // πŸŸͺ Create requests - // 1. This is where the orchestration begins β€” we delegate the creation of the - // request array to the `networkModule`. It encapsulates logic specific to the - // active network (e.g., pricing, thresholds, metadata) and returns a set of - // structured requests ready to be dispatched to the nodes. - const requestArray = (await networkModule.api.executeJs.createRequest({ - // add pricing context for Lit Actions - pricingContext: { - product: 'LIT_ACTION', - userMaxPrice: params.userMaxPrice, - nodePrices: jitContext.nodePrices, - threshold: currentHandshakeResult.threshold, - }, - authContext: params.authContext, - executionContext: { - code: params.code, - ipfsId: params.ipfsId, - jsParams: params.jsParams, + authContext: params.authContext, + executionContext: { + code: params.code, + ipfsId: params.ipfsId, + jsParams: params.jsParams, + }, + connectionInfo, + version: networkModule.version, + useSingleNode: params.useSingleNode, + responseStrategy: params.responseStrategy, + jitContext, + })) as RequestItem>[]; + + const requestId = requestArray[0].requestId; + + const result = await dispatchRequests< + z.infer, + z.infer + >(requestArray, requestId, handshakeResult.threshold); + + return await networkModule.api.executeJs.handleResponse( + result as any, + requestId, + jitContext + ); }, - connectionInfo: currentConnectionInfo, - version: networkModule.version, - useSingleNode: params.useSingleNode, - responseStrategy: params.responseStrategy, - jitContext, - })) as RequestItem>[]; - - const requestId = requestArray[0].requestId; - - // 🟩 Dispatch requests - // 2. With the request array prepared, we now coordinate the parallel execution - // across multiple nodes. This step handles batching, minimum threshold success - // tracking, and error tolerance. The orchestration layer ensures enough valid - // responses are collected before proceeding. - const result = await dispatchRequests< - z.infer, - z.infer - >(requestArray, requestId, currentHandshakeResult.threshold); - - // πŸŸͺ Handle response - // 3. Once node responses are received and validated, we delegate final - // interpretation and formatting of the result back to the `networkModule`. - // This allows the module to apply network-specific logic such as decoding, - // formatting, or transforming the response into a usable executeJs result. - return await networkModule.api.executeJs.handleResponse( - result as any, - requestId, - jitContext - ); + }); } /** @@ -636,7 +581,7 @@ export const _createNagaLitClient = async ( // ========== Hash Private Data ========== const hashOfPrivateData = await crypto.subtle.digest( 'SHA-256', - dataAsUint8Array + dataAsUint8Array as BufferSource ); const hashOfPrivateDataStr = uint8arrayToString( new Uint8Array(hashOfPrivateData), diff --git a/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts b/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts new file mode 100644 index 000000000..e75b6a31a --- /dev/null +++ b/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts @@ -0,0 +1,149 @@ +import { getChildLogger } from '@lit-protocol/logger'; +import type { OrchestrateHandshakeResponse } from '../orchestrateHandshake'; + +const _logger = getChildLogger({ + module: 'executeWithHandshake', +}); + +export interface HandshakeExecutionContext { + handshakeResult: OrchestrateHandshakeResponse; + connectionInfo: any; + jitContext: any; +} + +export interface ExecuteWithHandshakeOptions { + operation: string; + buildContext: () => Promise; + refreshContext: (reason: string) => Promise; + runner: (context: HandshakeExecutionContext) => Promise; +} + +type RetryMetadata = { + shouldRetry: boolean; + reason: string; +}; + +export namespace EdgeCase { + export const isMissingVerificationKeyError = (error: unknown): boolean => { + if (!error || typeof error !== 'object') { + return false; + } + + const message = (error as any).message; + const causeMessage = (error as any).cause?.message; + const infoVerificationKey = (error as any).info?.verificationKey; + + const messages = [message, causeMessage].filter( + (text): text is string => typeof text === 'string' + ); + + if ( + messages.some((text) => + text.includes('No secret key found for verification key') + ) + ) { + return true; + } + + return ( + typeof infoVerificationKey === 'string' && + messages.some((text) => + text.includes('No secret key found for verification key') + ) + ); + }; + + export const isNetworkFetchError = (error: unknown): boolean => { + if (!error || typeof error !== 'object') { + return false; + } + + const name = (error as any).name; + const code = (error as any).code; + const infoFullPath = (error as any).info?.fullPath; + const messages = [ + (error as any).message, + (error as any).cause?.message, + ].filter((text): text is string => typeof text === 'string'); + + if (name === 'NetworkError' || code === 'network_error') { + return true; + } + + if ( + messages.some((text) => text.toLowerCase().includes('fetch failed')) || + (typeof infoFullPath === 'string' && + infoFullPath.toLowerCase().includes('execute')) + ) { + return true; + } + + return false; + }; + + export const isNoValidSharesError = (error: unknown): boolean => { + if (!error || typeof error !== 'object') { + return false; + } + + const name = (error as any).name; + const message = (error as any).message as string | undefined; + + if (name === 'NoValidShares') { + return true; + } + + return ( + typeof message === 'string' && + message.toLowerCase().includes('no valid lit action shares to combine') + ); + }; +} + +const deriveRetryMetadata = (error: unknown): RetryMetadata => { + if (EdgeCase.isMissingVerificationKeyError(error)) { + return { shouldRetry: true, reason: 'missing-verification-key' }; + } + + if (EdgeCase.isNetworkFetchError(error)) { + return { shouldRetry: true, reason: 'network-fetch-error' }; + } + + if (EdgeCase.isNoValidSharesError(error)) { + return { shouldRetry: true, reason: 'no-valid-shares' }; + } + + return { shouldRetry: false, reason: '' }; +}; + +export const executeWithHandshake = async ( + options: ExecuteWithHandshakeOptions +): Promise => { + const { operation, buildContext, refreshContext, runner } = options; + + let context = await buildContext(); + + try { + return await runner(context); + } catch (error) { + const retryMetadata = deriveRetryMetadata(error); + + if (retryMetadata.shouldRetry) { + const reason = retryMetadata.reason || 'retry'; + const refreshLabel = `${operation}-${reason}`.replace(/-+/g, '-'); + + _logger.warn( + { + error, + operation, + retryReason: reason, + }, + `${operation} failed; refreshing handshake (${refreshLabel}) and retrying once.` + ); + context = await refreshContext(refreshLabel); + return await runner(context); + } + + throw error; + } +}; diff --git a/packages/networks/src/networks/vNaga/shared/managers/state-manager/createStateManager.ts b/packages/networks/src/networks/vNaga/shared/managers/state-manager/createStateManager.ts index 9ab10a449..908b466fd 100644 --- a/packages/networks/src/networks/vNaga/shared/managers/state-manager/createStateManager.ts +++ b/packages/networks/src/networks/vNaga/shared/managers/state-manager/createStateManager.ts @@ -92,6 +92,75 @@ export const createStateManager = async (params: { throw err; } + const refreshState = async (reason?: string) => { + try { + _logger.info({ reason }, 'Refreshing connection info and handshake callback'); + const newConnectionInfo = + await readOnlyChainManager.api.connection.getConnectionInfo(); + const newBootstrapUrls = newConnectionInfo.bootstrapUrls; + const newEpochInfo = newConnectionInfo.epochInfo; + + latestConnectionInfo = newConnectionInfo; + + const bootstrapUrlsChanged = areStringArraysDifferent( + latestBootstrapUrls, + newBootstrapUrls + ); + + if (bootstrapUrlsChanged) { + _logger.warn( + { + reason, + oldUrls: latestBootstrapUrls, + newUrls: newBootstrapUrls, + }, + 'Bootstrap URLs changed. Updating internal state.' + ); + latestBootstrapUrls = newBootstrapUrls; + } else { + _logger.info( + { reason }, + 'Bootstrap URLs remain unchanged during refresh.' + ); + } + + if (!latestEpochInfo || latestEpochInfo.number !== newEpochInfo.number) { + _logger.info( + { + reason, + previousEpoch: latestEpochInfo?.number, + newEpoch: newEpochInfo.number, + }, + 'Epoch number updated during refresh.' + ); + latestEpochInfo = newEpochInfo; + } else { + _logger.info( + { reason, epoch: newEpochInfo.number }, + 'Epoch number unchanged during refresh.' + ); + } + + callbackResult = await params.callback({ + bootstrapUrls: latestBootstrapUrls, + currentEpoch: latestEpochInfo?.number || 0, + version: params.networkModule.version, + requiredAttestation: params.networkModule.config.requiredAttestation, + minimumThreshold: params.networkModule.config.minimumThreshold, + abortTimeout: params.networkModule.config.abortTimeout, + endpoints: params.networkModule.getEndpoints(), + releaseVerificationConfig: null, + networkModule: params.networkModule, + }); + } catch (error) { + _logger.error( + { error, reason }, + 'Failed to refresh connection info for state manager' + ); + throw error; + } + }; + // --- Setup Staking Event Listener --- const stakingContract = new ethers.Contract( contractManager.stakingContract.address, @@ -116,58 +185,7 @@ export const createStateManager = async (params: { // 2. If state is Active, refresh connection info if (newState === (STAKING_STATES.Active as STAKING_STATES_VALUES)) { try { - _logger.info( - 'πŸ– Staking state is Active. Fetching latest connection info...' - ); - const newConnectionInfo = - await readOnlyChainManager.api.connection.getConnectionInfo(); - const newBootstrapUrls = newConnectionInfo.bootstrapUrls; - const newEpochInfo = newConnectionInfo.epochInfo; // Get new epoch info - latestConnectionInfo = newConnectionInfo; // Update internal state for connection info - - const bootstrapUrlsChanged = areStringArraysDifferent( - latestBootstrapUrls, - newBootstrapUrls - ); - - if (bootstrapUrlsChanged) { - _logger.warn( - { - oldUrls: latestBootstrapUrls, - newUrls: newBootstrapUrls, - }, - 'Bootstrap URLs changed. Updating internal state.' - ); - latestBootstrapUrls = newBootstrapUrls; // Update internal state - } else { - _logger.info('BootstrapUrls remain unchanged.'); - } - - // Always update epoch info when Active state is processed - if (latestEpochInfo?.number !== newEpochInfo.number) { - _logger.info( - `Epoch number updated from ${latestEpochInfo?.number} to ${newEpochInfo.number}` - ); - latestEpochInfo = newEpochInfo; - } else { - _logger.info( - `Epoch number ${newEpochInfo.number} remains the same.` - ); - } - - // -- callback - callbackResult = await params.callback({ - bootstrapUrls: latestBootstrapUrls, - currentEpoch: latestEpochInfo!.number, - version: params.networkModule.version, - requiredAttestation: - params.networkModule.config.requiredAttestation, - minimumThreshold: params.networkModule.config.minimumThreshold, - abortTimeout: params.networkModule.config.abortTimeout, - endpoints: params.networkModule.getEndpoints(), - releaseVerificationConfig: null, - networkModule: params.networkModule, - }); + await refreshState('staking-state-change'); } catch (error) { _logger.error( { error }, @@ -226,6 +244,10 @@ export const createStateManager = async (params: { return latestConnectionInfo ? { ...latestConnectionInfo } : null; }, + refreshHandshake: async (reason?: string) => { + await refreshState(reason); + }, + /** * Stops the background listeners (blockhash refresh, event listening). */ From 81109399d2ba96ffcaea5c7bd5365bbe87702e16 Mon Sep 17 00:00:00 2001 From: anson Date: Wed, 5 Nov 2025 17:29:22 +0000 Subject: [PATCH 06/10] refactor(lit-client): centralise retry reasons as const --- .../LitClient/helpers/executeWithHandshake.ts | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts b/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts index e75b6a31a..5528f32d3 100644 --- a/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts +++ b/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts @@ -23,6 +23,16 @@ type RetryMetadata = { reason: string; }; +// Pause briefly before retrying so dropped nodes have time to deregister and surviving owners rebroadcast shares. +const RETRY_BACKOFF_MS = 1_000; + +export const RETRY_REASONS = { + missingVerificationKey: 'missing-verification-key', + networkFetch: 'network-fetch-error', + noValidShares: 'no-valid-shares', + generic: 'retry', +} as const; + export namespace EdgeCase { export const isMissingVerificationKeyError = (error: unknown): boolean => { if (!error || typeof error !== 'object') { @@ -102,15 +112,18 @@ export namespace EdgeCase { const deriveRetryMetadata = (error: unknown): RetryMetadata => { if (EdgeCase.isMissingVerificationKeyError(error)) { - return { shouldRetry: true, reason: 'missing-verification-key' }; + return { + shouldRetry: true, + reason: RETRY_REASONS.missingVerificationKey, + }; } if (EdgeCase.isNetworkFetchError(error)) { - return { shouldRetry: true, reason: 'network-fetch-error' }; + return { shouldRetry: true, reason: RETRY_REASONS.networkFetch }; } if (EdgeCase.isNoValidSharesError(error)) { - return { shouldRetry: true, reason: 'no-valid-shares' }; + return { shouldRetry: true, reason: RETRY_REASONS.noValidShares }; } return { shouldRetry: false, reason: '' }; @@ -129,9 +142,25 @@ export const executeWithHandshake = async ( const retryMetadata = deriveRetryMetadata(error); if (retryMetadata.shouldRetry) { - const reason = retryMetadata.reason || 'retry'; + const reason = retryMetadata.reason || RETRY_REASONS.generic; const refreshLabel = `${operation}-${reason}`.replace(/-+/g, '-'); + if ( + reason === 'no-valid-shares' || + reason === 'network-fetch-error' + ) { + await new Promise((resolve) => setTimeout(resolve, RETRY_BACKOFF_MS)); + } + + console.log( + '[executeWithHandshake] retrying operation', + operation, + 'reason:', + reason, + 'refreshLabel:', + refreshLabel + ); + _logger.warn( { error, From f39927d8dd637ed35b9f0d87f9836f55c41c2dfe Mon Sep 17 00:00:00 2001 From: anson Date: Wed, 5 Nov 2025 17:51:32 +0000 Subject: [PATCH 07/10] fmt --- tsconfig.base.json | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tsconfig.base.json b/tsconfig.base.json index bbbf0fe05..3e4cc5d31 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -10,7 +10,11 @@ "importHelpers": true, "target": "ES2022", "module": "ES2022", - "lib": ["ES2022", "dom", "ES2021.String"], + "lib": [ + "ES2022", + "dom", + "ES2021.String" + ], "skipLibCheck": true, "skipDefaultLibCheck": true, "baseUrl": ".", @@ -18,10 +22,20 @@ "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "paths": { - "@lit-protocol/*": ["packages/*/src"], - "@lit-protocol/contracts": ["packages/contracts/dist/index"], - "@lit-protocol/contracts/*": ["packages/contracts/dist/*"] + "@lit-protocol/*": [ + "packages/*/src" + ], + "@lit-protocol/contracts": [ + "packages/contracts/dist/index" + ], + "@lit-protocol/contracts/*": [ + "packages/contracts/dist/*" + ] } }, - "exclude": ["node_modules", "tmp", "dist"] -} + "exclude": [ + "node_modules", + "tmp", + "dist" + ] +} \ No newline at end of file From 115c194fb2aae4e16588b692405ab6b786f1e2a5 Mon Sep 17 00:00:00 2001 From: anson Date: Wed, 5 Nov 2025 18:11:06 +0000 Subject: [PATCH 08/10] refactor(executeWithHandshake): simplify retry logic and remove redundant logging --- .../lib/LitClient/helpers/executeWithHandshake.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts b/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts index 5528f32d3..042a68def 100644 --- a/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts +++ b/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts @@ -145,22 +145,10 @@ export const executeWithHandshake = async ( const reason = retryMetadata.reason || RETRY_REASONS.generic; const refreshLabel = `${operation}-${reason}`.replace(/-+/g, '-'); - if ( - reason === 'no-valid-shares' || - reason === 'network-fetch-error' - ) { + if (reason === 'no-valid-shares' || reason === 'network-fetch-error') { await new Promise((resolve) => setTimeout(resolve, RETRY_BACKOFF_MS)); } - console.log( - '[executeWithHandshake] retrying operation', - operation, - 'reason:', - reason, - 'refreshLabel:', - refreshLabel - ); - _logger.warn( { error, From bf1a89a8395604c1c667c0f9a7e8eeee472c8048 Mon Sep 17 00:00:00 2001 From: anson Date: Fri, 7 Nov 2025 14:41:34 +0000 Subject: [PATCH 09/10] feat(e2e, lit-client, networks): add shiva env helpers and improve resilience --- .../helper/ShivaClient/createShivaClient.ts | 57 +++- .../src/helper/ShivaClient/createShivaEnv.ts | 46 +++ packages/e2e/src/helper/createEnvVars.ts | 27 +- packages/e2e/src/helper/createTestEnv.ts | 64 ++-- packages/e2e/src/index.ts | 6 +- .../src/tickets/executeJs/pkp-authsig.spec.ts | 93 ++++++ .../pkp-decrypt-null-authsig.spec.ts | 153 ++++++++++ .../LitClient/helpers/executeWithHandshake.ts | 94 ++++-- .../intergrations/createPkpViemAccount.ts | 43 ++- .../src/lib/LitClient/orchestrateHandshake.ts | 285 ++++++++---------- .../api-manager/helper/get-signatures.ts | 124 +++++--- .../state-manager/createStateManager.ts | 5 +- tsconfig.base.json | 26 +- 13 files changed, 736 insertions(+), 287 deletions(-) create mode 100644 packages/e2e/src/helper/ShivaClient/createShivaEnv.ts create mode 100644 packages/e2e/src/tickets/executeJs/pkp-authsig.spec.ts create mode 100644 packages/e2e/src/tickets/executeJs/pkp-decrypt-null-authsig.spec.ts diff --git a/packages/e2e/src/helper/ShivaClient/createShivaClient.ts b/packages/e2e/src/helper/ShivaClient/createShivaClient.ts index 739bd619c..2208b3d90 100644 --- a/packages/e2e/src/helper/ShivaClient/createShivaClient.ts +++ b/packages/e2e/src/helper/ShivaClient/createShivaClient.ts @@ -61,6 +61,16 @@ type PollTestnetStateOptions = { intervalMs?: number; }; +type WaitForTestnetInfoOptions = { + timeoutMs?: number; + intervalMs?: number; +}; + +export type ShivaTestnetInfo = { + rpc_url?: string; + [key: string]: unknown; +}; + /** * High-level interface surfaced by {@link createShivaClient}. */ @@ -90,7 +100,11 @@ export type ShivaClient = { options?: PollTestnetStateOptions ) => Promise; /** Retrieve the full testnet configuration (contract ABIs, RPC URL, etc.). */ - getTestnetInfo: () => Promise; + getTestnetInfo: () => Promise; + /** Poll the manager until `/test/get/info/testnet/` returns a payload. */ + waitForTestnetInfo: ( + options?: WaitForTestnetInfoOptions + ) => Promise; /** Shut down the underlying testnet through the Shiva manager. */ deleteTestnet: () => Promise; @@ -321,11 +335,47 @@ export const createShivaClient = async ( }; const getTestnetInfo = async () => { - const response = await fetchShiva( + const response = await fetchShiva( baseUrl, `/test/get/info/testnet/${testnetId}` ); - return response.body; + return response.body ?? null; + }; + + const waitForTestnetInfo = async ( + options: WaitForTestnetInfoOptions = {} + ): Promise => { + const { + timeoutMs = DEFAULT_STATE_POLL_TIMEOUT, + intervalMs = DEFAULT_STATE_POLL_INTERVAL, + } = options; + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + + for (;;) { + try { + const info = await getTestnetInfo(); + if (info) { + return info; + } + } catch (error) { + lastError = error; + } + + if (Date.now() >= deadline) { + const lastErrorMessage = + lastError instanceof Error + ? lastError.message + : lastError + ? String(lastError) + : 'No response body received.'; + throw new Error( + `Timed out after ${timeoutMs}ms waiting for testnet info for ${testnetId}. Last error: ${lastErrorMessage}` + ); + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } }; const deleteTestnet = async () => { @@ -344,6 +394,7 @@ export const createShivaClient = async ( stopRandomNodeAndWait, pollTestnetState, getTestnetInfo, + waitForTestnetInfo, deleteTestnet, // utils diff --git a/packages/e2e/src/helper/ShivaClient/createShivaEnv.ts b/packages/e2e/src/helper/ShivaClient/createShivaEnv.ts new file mode 100644 index 000000000..d197e49b0 --- /dev/null +++ b/packages/e2e/src/helper/ShivaClient/createShivaEnv.ts @@ -0,0 +1,46 @@ +import { SUPPORTED_NETWORKS, type SupportedNetwork } from '../createEnvVars'; + +export type ShivaEnvVars = { + network: SupportedNetwork; + shivaBaseUrl: string; + litNodeBin: string; + litActionsBin: string; +}; + +export const createShivaEnvVars = (): ShivaEnvVars => { + const networkEnv = process.env['NETWORK']; + if ( + !networkEnv || + !SUPPORTED_NETWORKS.includes(networkEnv as SupportedNetwork) + ) { + throw new Error( + `Unsupported or missing NETWORK env var. Supported values: ${SUPPORTED_NETWORKS.join( + ', ' + )}` + ); + } + + const network = networkEnv as SupportedNetwork; + + const shivaBaseUrl = process.env['SHIVA_BASE_URL']; + if (!shivaBaseUrl) { + throw new Error('Missing SHIVA_BASE_URL env var.'); + } + + const litNodeBin = process.env['LIT_NODE_BIN']; + if (!litNodeBin) { + throw new Error('Missing LIT_NODE_BIN env var.'); + } + + const litActionsBin = process.env['LIT_ACTIONS_BIN']; + if (!litActionsBin) { + throw new Error('Missing LIT_ACTIONS_BIN env var.'); + } + + return { + network, + shivaBaseUrl, + litNodeBin, + litActionsBin, + }; +}; diff --git a/packages/e2e/src/helper/createEnvVars.ts b/packages/e2e/src/helper/createEnvVars.ts index 1e2c0bb38..711b00e19 100644 --- a/packages/e2e/src/helper/createEnvVars.ts +++ b/packages/e2e/src/helper/createEnvVars.ts @@ -1,8 +1,14 @@ -const supportedNetworks = ['naga-local', 'naga-test', 'naga-dev'] as const; +export const SUPPORTED_NETWORKS = [ + 'naga-local', + 'naga-test', + 'naga-dev', + 'naga-staging', +] as const; +export type SupportedNetwork = (typeof SUPPORTED_NETWORKS)[number]; type EnvName = 'local' | 'live'; export type EnvVars = { - network: string; + network: SupportedNetwork; privateKey: `0x${string}`; rpcUrl?: string | undefined; localContextPath?: string; @@ -19,14 +25,19 @@ const testEnv: Record< export function createEnvVars(): EnvVars { // 1. Get network string - const network = process.env['NETWORK']!!; + const networkEnv = process.env['NETWORK']; - if (!network || !supportedNetworks.includes(network as any)) { + if ( + !networkEnv || + !SUPPORTED_NETWORKS.includes(networkEnv as SupportedNetwork) + ) { throw new Error( - `❌ NETWORK env var is not set or not supported. Found. ${network}` + `❌ NETWORK env var is not set or not supported. Found. ${networkEnv}` ); } + const network = networkEnv as SupportedNetwork; + const selectedNetwork = network.includes('local') ? 'local' : 'live'; // 2. Get private key @@ -71,7 +82,11 @@ export function createEnvVars(): EnvVars { } // -- live networks - if (network === 'naga-dev' || network === 'naga-test') { + if ( + network === 'naga-dev' || + network === 'naga-test' || + network === 'naga-staging' + ) { const liveRpcUrl = process.env['LIT_YELLOWSTONE_PRIVATE_RPC_URL']; if (liveRpcUrl) { diff --git a/packages/e2e/src/helper/createTestEnv.ts b/packages/e2e/src/helper/createTestEnv.ts index ea04c75d0..e7e865be9 100644 --- a/packages/e2e/src/helper/createTestEnv.ts +++ b/packages/e2e/src/helper/createTestEnv.ts @@ -2,6 +2,7 @@ import { LitNetworkModule, nagaDev, nagaLocal, + nagaStaging, nagaTest, PaymentManager, } from '@lit-protocol/networks'; @@ -46,36 +47,45 @@ export const createTestEnv = async (envVars: EnvVars): Promise => { ledgerDepositAmount: '', }; - if (envVars.network === 'naga-local') { - networkModule = nagaLocal - .withLocalContext({ - networkContextPath: envVars.localContextPath, - networkName: 'naga-local', - }) - .withOverrides({ - rpcUrl: envVars.rpcUrl, - }); - config = CONFIG.LOCAL; - } else if ( - envVars.network === 'naga-dev' || - envVars.network === 'naga-test' - ) { - if (envVars.network === 'naga-dev') { - networkModule = nagaDev; - } else if (envVars.network === 'naga-test') { - networkModule = nagaTest; + switch (envVars.network) { + case 'naga-local': { + networkModule = nagaLocal + .withLocalContext({ + networkContextPath: envVars.localContextPath, + networkName: 'naga-local', + }) + .withOverrides({ + rpcUrl: envVars.rpcUrl, + }); + config = CONFIG.LOCAL; + break; } + case 'naga-dev': + case 'naga-test': + case 'naga-staging': { + if (envVars.network === 'naga-dev') { + networkModule = nagaDev; + } else if (envVars.network === 'naga-test') { + networkModule = nagaTest; + } else { + networkModule = nagaStaging; + } - if (envVars.rpcUrl) { - console.log( - `πŸ”§ Overriding RPC URL for ${envVars.network} to ${envVars.rpcUrl}` - ); - networkModule = networkModule.withOverrides({ - rpcUrl: envVars.rpcUrl, - }); - } + if (envVars.rpcUrl) { + console.log( + `πŸ”§ Overriding RPC URL for ${envVars.network} to ${envVars.rpcUrl}` + ); + networkModule = networkModule.withOverrides({ + rpcUrl: envVars.rpcUrl, + }); + } - config = CONFIG.LIVE; + config = CONFIG.LIVE; + break; + } + default: { + throw new Error(`Unsupported network: ${envVars.network}`); + } } // 2. Create Lit Client diff --git a/packages/e2e/src/index.ts b/packages/e2e/src/index.ts index 642edf82c..24024dc36 100644 --- a/packages/e2e/src/index.ts +++ b/packages/e2e/src/index.ts @@ -10,7 +10,8 @@ export type { AuthContext } from './types'; // re-export new helpers that should be used to refactor the `init.ts` proces // see packages/e2e/src/tickets/delegation.suite.ts for usage examples -export { createEnvVars } from './helper/createEnvVars'; +export { createEnvVars, SUPPORTED_NETWORKS } from './helper/createEnvVars'; +export type { SupportedNetwork } from './helper/createEnvVars'; export { createTestAccount } from './helper/createTestAccount'; export { createTestEnv } from './helper/createTestEnv'; export type { CreateTestAccountResult } from './helper/createTestAccount'; @@ -18,3 +19,6 @@ export { registerPaymentDelegationTicketSuite } from './tickets/delegation.suite // -- Shiva export { createShivaClient } from './helper/ShivaClient/createShivaClient'; +export { createShivaEnvVars } from './helper/ShivaClient/createShivaEnv'; +export type { ShivaTestnetInfo } from './helper/ShivaClient/createShivaClient'; +export type { ShivaEnvVars } from './helper/ShivaClient/createShivaEnv'; diff --git a/packages/e2e/src/tickets/executeJs/pkp-authsig.spec.ts b/packages/e2e/src/tickets/executeJs/pkp-authsig.spec.ts new file mode 100644 index 000000000..67bed95ad --- /dev/null +++ b/packages/e2e/src/tickets/executeJs/pkp-authsig.spec.ts @@ -0,0 +1,93 @@ +import { createAccBuilder } from '@lit-protocol/access-control-conditions'; +import { ViemAccountAuthenticator } from '@lit-protocol/auth'; +import { createSiweMessage } from '@lit-protocol/auth-helpers'; +import { createEnvVars } from '../../helper/createEnvVars'; +import { createTestAccount } from '../../helper/createTestAccount'; +import { createTestEnv } from '../../helper/createTestEnv'; + +const CHECK_CONDITIONS_LIT_ACTION = ` +(async () => { + const { conditions, authSig } = jsParams; + const isAuthorized = await Lit.Actions.checkConditions({ + conditions, + authSig, + chain: 'ethereum', + }); + + Lit.Actions.setResponse({ response: isAuthorized ? 'true' : 'false' }); +})(); +`; + +describe('PKP AuthSig Access Control', () => { + let testEnv: Awaited>; + + beforeAll(async () => { + const envVars = createEnvVars(); + testEnv = await createTestEnv(envVars); + }); + + it('allows a PKP to satisfy wallet-ownership ACCs via a PKP-generated authSig', async () => { + const pkpOwner = await createTestAccount(testEnv, { + label: 'PKP ACC Owner', + fundAccount: true, + fundLedger: true, + hasEoaAuthContext: true, + hasPKP: true, + fundPKP: true, + hasPKPAuthContext: true, + fundPKPLedger: true, + }); + + const { pkp, pkpAuthContext, pkpViemAccount } = pkpOwner; + + if (!pkp || !pkp.ethAddress) { + throw new Error( + 'PKP data with ethereum address is required for this test' + ); + } + + if (!pkpAuthContext) { + throw new Error('PKP auth context was not created'); + } + + if (!pkpViemAccount) { + throw new Error('PKP viem account was not initialized'); + } + + // Ensure the PKP ledger has enough balance to pay for executeJs + await testEnv.masterPaymentManager.depositForUser({ + userAddress: pkp.ethAddress as `0x${string}`, + amountInEth: '0.2', + }); + + const accessControlConditions = createAccBuilder() + .requireWalletOwnership(pkp.ethAddress) + .on('ethereum') + .build(); + + const siweMessage = await createSiweMessage({ + walletAddress: pkpViemAccount.address, + nonce: (await testEnv.litClient.getContext()).latestBlockhash, + }); + + const pkpAuthSig = await ViemAccountAuthenticator.createAuthSig( + pkpViemAccount, + siweMessage + ); + + expect(pkpAuthSig.address?.toLowerCase()).toBe( + pkp.ethAddress.toLowerCase() + ); + + const executionResult = await testEnv.litClient.executeJs({ + code: CHECK_CONDITIONS_LIT_ACTION, + authContext: pkpAuthContext, + jsParams: { + conditions: accessControlConditions, + // authSig: pkpAuthSig, + }, + }); + + expect(executionResult.response).toBe('true'); + }); +}); diff --git a/packages/e2e/src/tickets/executeJs/pkp-decrypt-null-authsig.spec.ts b/packages/e2e/src/tickets/executeJs/pkp-decrypt-null-authsig.spec.ts new file mode 100644 index 000000000..ac3b1b692 --- /dev/null +++ b/packages/e2e/src/tickets/executeJs/pkp-decrypt-null-authsig.spec.ts @@ -0,0 +1,153 @@ +import { createAccBuilder } from '@lit-protocol/access-control-conditions'; +import { ViemAccountAuthenticator } from '@lit-protocol/auth'; +import { createSiweMessage } from '@lit-protocol/auth-helpers'; +import { createEnvVars } from '../../helper/createEnvVars'; +import { createTestAccount } from '../../helper/createTestAccount'; +import { createTestEnv } from '../../helper/createTestEnv'; + +const DECRYPT_LIT_ACTION = ` +(async () => { + const { accessControlConditions, authSig, ciphertext, dataToEncryptHash } = jsParams; + const resp = await Lit.Actions.decryptAndCombine({ + accessControlConditions, + ciphertext, + dataToEncryptHash, + authSig, + chain: 'ethereum', + }); + + Lit.Actions.setResponse({ response: resp }); +})();`; + +describe('PKP decrypt with null authSig', () => { + let testEnv: Awaited>; + + beforeAll(async () => { + const envVars = createEnvVars(); + testEnv = await createTestEnv(envVars); + }); + + it('decrypts successfully when authSig is null and PKP authContext matches ACC', async () => { + const pkpOwner = await createTestAccount(testEnv, { + label: 'PKP Decrypt Owner', + fundAccount: true, + fundLedger: true, + hasEoaAuthContext: true, + hasPKP: true, + fundPKP: true, + hasPKPAuthContext: true, + fundPKPLedger: true, + }); + + const { pkp, pkpAuthContext } = pkpOwner; + + if (!pkp || !pkp.ethAddress) { + throw new Error( + 'PKP data with ethereum address is required for this test' + ); + } + + if (!pkpAuthContext) { + throw new Error('PKP auth context was not created'); + } + + await testEnv.masterPaymentManager.depositForUser({ + userAddress: pkp.ethAddress as `0x${string}`, + amountInEth: '0.2', + }); + + const accessControlConditions = createAccBuilder() + .requireWalletOwnership(pkp.ethAddress) + .on('ethereum') + .build(); + + const secret = 'Hello from PKP decrypt test!'; + const encryptedData = await testEnv.litClient.encrypt({ + dataToEncrypt: secret, + unifiedAccessControlConditions: accessControlConditions, + chain: 'ethereum', + }); + + const executionResult = await testEnv.litClient.executeJs({ + code: DECRYPT_LIT_ACTION, + authContext: pkpAuthContext, + jsParams: { + accessControlConditions, + authSig: null, + ciphertext: encryptedData.ciphertext, + dataToEncryptHash: encryptedData.dataToEncryptHash, + }, + }); + + expect(executionResult.response).toBe(secret); + }); + + it('decrypts successfully when authSig is a PKP-generated SIWE signature', async () => { + const pkpOwner = await createTestAccount(testEnv, { + label: 'PKP Decrypt Owner (authSig)', + fundAccount: true, + fundLedger: true, + hasEoaAuthContext: true, + hasPKP: true, + fundPKP: true, + hasPKPAuthContext: true, + fundPKPLedger: true, + }); + + const { pkp, pkpAuthContext, pkpViemAccount } = pkpOwner; + + if (!pkp || !pkp.ethAddress) { + throw new Error( + 'PKP data with ethereum address is required for this test' + ); + } + + if (!pkpAuthContext) { + throw new Error('PKP auth context was not created'); + } + + if (!pkpViemAccount) { + throw new Error('PKP viem account was not initialized'); + } + + await testEnv.masterPaymentManager.depositForUser({ + userAddress: pkp.ethAddress as `0x${string}`, + amountInEth: '0.2', + }); + + const accessControlConditions = createAccBuilder() + .requireWalletOwnership(pkp.ethAddress) + .on('ethereum') + .build(); + + const secret = 'Hello from PKP decrypt test with authSig!'; + const encryptedData = await testEnv.litClient.encrypt({ + dataToEncrypt: secret, + unifiedAccessControlConditions: accessControlConditions, + chain: 'ethereum', + }); + + const siweMessage = await createSiweMessage({ + walletAddress: pkpViemAccount.address, + nonce: (await testEnv.litClient.getContext()).latestBlockhash, + }); + + const pkpAuthSig = await ViemAccountAuthenticator.createAuthSig( + pkpViemAccount, + siweMessage + ); + + const executionResult = await testEnv.litClient.executeJs({ + code: DECRYPT_LIT_ACTION, + authContext: pkpAuthContext, + jsParams: { + accessControlConditions, + authSig: pkpAuthSig, + ciphertext: encryptedData.ciphertext, + dataToEncryptHash: encryptedData.dataToEncryptHash, + }, + }); + + expect(executionResult.response).toBe(secret); + }); +}); diff --git a/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts b/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts index 042a68def..36cc78523 100644 --- a/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts +++ b/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts @@ -20,7 +20,7 @@ export interface ExecuteWithHandshakeOptions { type RetryMetadata = { shouldRetry: boolean; - reason: string; + reason: RetryReason | ''; }; // Pause briefly before retrying so dropped nodes have time to deregister and surviving owners rebroadcast shares. @@ -33,6 +33,25 @@ export const RETRY_REASONS = { generic: 'retry', } as const; +type RetryReason = (typeof RETRY_REASONS)[keyof typeof RETRY_REASONS]; + +const DEFAULT_RETRY_BUDGET: Record = { + [RETRY_REASONS.missingVerificationKey]: 1, + [RETRY_REASONS.networkFetch]: 3, + // Allow a longer grace period for the cluster to re-aggregate shares after node churn. + [RETRY_REASONS.noValidShares]: 6, + [RETRY_REASONS.generic]: 0, +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const computeBackoffDelay = (reason: RetryReason, attempt: number): number => { + if (reason === RETRY_REASONS.missingVerificationKey) { + return 0; + } + return RETRY_BACKOFF_MS * Math.max(1, attempt); +}; + export namespace EdgeCase { export const isMissingVerificationKeyError = (error: unknown): boolean => { if (!error || typeof error !== 'object') { @@ -98,14 +117,22 @@ export namespace EdgeCase { const name = (error as any).name; const message = (error as any).message as string | undefined; + const causeMessage = (error as any).cause?.message as string | undefined; if (name === 'NoValidShares') { return true; } - return ( - typeof message === 'string' && - message.toLowerCase().includes('no valid lit action shares to combine') + const errorMessages = [message, causeMessage] + .filter((text): text is string => typeof text === 'string') + .map((text) => text.toLowerCase()); + + return errorMessages.some( + (text) => + text.includes('no valid lit action shares to combine') || + text.includes('could not read key share') || + text.includes('unable to insert into key cache') || + text.includes('ecdsa signing failed') ); }; } @@ -134,33 +161,64 @@ export const executeWithHandshake = async ( ): Promise => { const { operation, buildContext, refreshContext, runner } = options; + const initialRetryBudget: Record = { + ...DEFAULT_RETRY_BUDGET, + }; + const retryBudget: Record = { + ...DEFAULT_RETRY_BUDGET, + }; + let context = await buildContext(); - try { - return await runner(context); - } catch (error) { - const retryMetadata = deriveRetryMetadata(error); + // Retry loop: continue until the runner succeeds or we exhaust retry budgets. + for (;;) { + try { + _logger.warn( + { operation, retryBudget }, + `[executeWithHandshake] running ${operation} with remaining budget` + ); + return await runner(context); + } catch (error: unknown) { + const retryMetadata = deriveRetryMetadata(error); + if (!retryMetadata.shouldRetry) { + throw error; + } - if (retryMetadata.shouldRetry) { - const reason = retryMetadata.reason || RETRY_REASONS.generic; - const refreshLabel = `${operation}-${reason}`.replace(/-+/g, '-'); + const reason = (retryMetadata.reason || + RETRY_REASONS.generic) as RetryReason; - if (reason === 'no-valid-shares' || reason === 'network-fetch-error') { - await new Promise((resolve) => setTimeout(resolve, RETRY_BACKOFF_MS)); + const remainingBudget = retryBudget[reason] ?? 0; + if (remainingBudget <= 0) { + throw error; + } + + retryBudget[reason] = remainingBudget - 1; + const attemptIndex = + (initialRetryBudget[reason] ?? 0) - remainingBudget + 1; + const refreshLabel = + `${operation}-${reason}-retry-${attemptIndex}`.replace(/-+/g, '-'); + + const retryDelayMs = computeBackoffDelay(reason, attemptIndex); + + if (retryDelayMs > 0) { + _logger.warn( + { operation, retryDelayMs, attempt: attemptIndex }, + `[executeWithHandshake] backing off ${retryDelayMs}ms before retry ${attemptIndex} for ${operation}` + ); + await sleep(retryDelayMs); } _logger.warn( { - error, operation, retryReason: reason, + attempt: attemptIndex, + remainingAttempts: retryBudget[reason] ?? 0, }, - `${operation} failed; refreshing handshake (${refreshLabel}) and retrying once.` + `[executeWithHandshake] retrying ${operation} due to ${reason} (attempt ${attemptIndex}, remaining ${retryBudget[reason]})` ); + context = await refreshContext(refreshLabel); - return await runner(context); } - - throw error; } }; diff --git a/packages/lit-client/src/lib/LitClient/intergrations/createPkpViemAccount.ts b/packages/lit-client/src/lib/LitClient/intergrations/createPkpViemAccount.ts index ebec88ae2..37451c451 100644 --- a/packages/lit-client/src/lib/LitClient/intergrations/createPkpViemAccount.ts +++ b/packages/lit-client/src/lib/LitClient/intergrations/createPkpViemAccount.ts @@ -3,8 +3,10 @@ import { SigResponse } from '@lit-protocol/types'; import { concatHex, createPublicClient, + hashMessage, hashTypedData, Hex, + hexToBytes, http, keccak256, recoverAddress, @@ -59,15 +61,21 @@ export async function createPKPViemAccount({ */ const signAndRecover = async ( bytesToSign: Uint8Array, - expectedAddress: `0x${string}` + expectedAddress: `0x${string}`, + options?: { + bypassAutoHashing?: boolean; + hashForRecovery?: Hex; + } ): Promise<{ r: Hex; s: Hex; recoveryId: number; signature: Hex; }> => { - // Pass raw bytes to PKP - PKP will apply keccak256 internally - const signature = await sign(bytesToSign); + const signature = await sign( + bytesToSign, + options?.bypassAutoHashing ? { bypassAutoHashing: true } : undefined + ); _logger.info({ signature }, 'πŸ” Raw signature from PKP:'); // Parse signature components @@ -79,8 +87,7 @@ export async function createPKPViemAccount({ let recovered: string | undefined; let recoveryId: number | undefined; - // PKP applies keccak256 to raw bytes, so we recover using the same hash - const hashForRecovery = keccak256(bytesToSign); + const hashForRecovery = options?.hashForRecovery ?? keccak256(bytesToSign); _logger.info( { hashForRecovery }, 'πŸ” Hash for recovery (keccak256 of bytes):' @@ -203,24 +210,28 @@ export async function createPKPViemAccount({ return toAccount({ address, async signMessage({ message }) { - // Pass raw message bytes to PKP - let the LitMessageSchema handle keccak256 hashing - let messageBytes: Uint8Array; + let normalizedMessage: string | Uint8Array; if (typeof message === 'string') { - messageBytes = new TextEncoder().encode(message); + normalizedMessage = message; } else { - // For non-string messages, convert to bytes - const messageStr = + normalizedMessage = typeof message === 'object' && 'raw' in message - ? message.raw - : message; - messageBytes = toBytes(messageStr as any); + ? (message.raw as string | Uint8Array) + : (message as string | Uint8Array); } + const digestHex = hashMessage(normalizedMessage as any); + const digestBytes = hexToBytes(digestHex); + const expectedAddress = publicKeyToAddress(uncompressedPubKey); const { r, s, recoveryId, signature } = await signAndRecover( - messageBytes, - expectedAddress + digestBytes, + expectedAddress, + { + bypassAutoHashing: true, + hashForRecovery: digestHex, + } ); // Construct SigResponse object @@ -230,7 +241,7 @@ export async function createPKPViemAccount({ recid: recoveryId, signature: signature, publicKey: uncompressedPubKey.slice(2), // Remove 0x prefix - dataSigned: toHex(messageBytes), // Raw message bytes that were signed + dataSigned: digestHex, }; return formatSignature(sigResponse); diff --git a/packages/lit-client/src/lib/LitClient/orchestrateHandshake.ts b/packages/lit-client/src/lib/LitClient/orchestrateHandshake.ts index 9cbf7c426..bb209a2b7 100644 --- a/packages/lit-client/src/lib/LitClient/orchestrateHandshake.ts +++ b/packages/lit-client/src/lib/LitClient/orchestrateHandshake.ts @@ -63,191 +63,154 @@ export const orchestrateHandshake = async (params: { }), // Use Promise.all for fail-fast behavior - if any node fails quickly, we know immediately - Promise.all( - params.bootstrapUrls.map(async (url: string) => { - try { - const fullPath = composeLitUrl({ - url: url, - endpoint: params.endpoints.HANDSHAKE, - }); + (async () => { + const handshakeResults = await Promise.all( + params.bootstrapUrls.map(async (url: string) => { + try { + const fullPath = composeLitUrl({ + url: url, + endpoint: params.endpoints.HANDSHAKE, + }); - // Create the challenge once and use it for both handshake request and attestation verification - const challenge = createRandomHexString(64); + // Create the challenge once and use it for both handshake request and attestation verification + const challenge = createRandomHexString(64); - const _data = { - fullPath: fullPath, - data: { - clientPublicKey: 'test', - challenge: challenge, - }, - requestId: requestId, - epoch: params.currentEpoch, - version: params.version, - networkModule: params.networkModule, - }; + const _data = { + fullPath: fullPath, + data: { + clientPublicKey: 'test', + challenge: challenge, + }, + requestId: requestId, + epoch: params.currentEpoch, + version: params.version, + networkModule: params.networkModule, + }; - // Debug logging before handshake - _logger.info({}, `πŸ” About to make handshake request to: ${url}`); - _logger.info({ _data }, `πŸ” Handshake request data:`); - _logger.info( - { - version: params.networkModule?.version, - hasApiHandshakeSchemas: - !!params.networkModule?.api?.handshake?.schemas, - endpointHandshake: params.endpoints.HANDSHAKE, - }, - `πŸ” Network module details:` - ); + // Debug logging before handshake + _logger.info({}, `πŸ” About to make handshake request to: ${url}`); + _logger.info({ _data }, `πŸ” Handshake request data:`); + _logger.info( + { + version: params.networkModule?.version, + hasApiHandshakeSchemas: + !!params.networkModule?.api?.handshake?.schemas, + endpointHandshake: params.endpoints.HANDSHAKE, + }, + `πŸ” Network module details:` + ); - // 1. Call the thin API - const retrievedServerKeys = await LitNodeApi.handshake(_data); + // 1. Call the thin API + const retrievedServerKeys = await LitNodeApi.handshake(_data); - _logger.info( - { retrievedServerKeys }, - 'πŸ” Retrieved server keys from handshake:' - ); - _logger.info( - { type: typeof retrievedServerKeys }, - 'πŸ” Type of retrieved server keys:' - ); - _logger.info( - { keys: Object.keys(retrievedServerKeys || {}) }, - 'πŸ” Keys in retrieved server keys:' - ); + _logger.info( + { retrievedServerKeys }, + 'πŸ” Retrieved server keys from handshake:' + ); + _logger.info( + { type: typeof retrievedServerKeys }, + 'πŸ” Type of retrieved server keys:' + ); + _logger.info( + { keys: Object.keys(retrievedServerKeys || {}) }, + 'πŸ” Keys in retrieved server keys:' + ); - // 2. Process the response (verify attestation etc.) - if (params.requiredAttestation) { - if (!retrievedServerKeys.attestation) { - throw new InvalidNodeAttestation( - {}, - `Missing attestation in handshake response from ${url}, received ${JSON.stringify( - retrievedServerKeys, - null, - 2 - )}. "attestation" field should not be null.` - ); - } + // 2. Process the response (verify attestation etc.) + if (params.requiredAttestation) { + if (!retrievedServerKeys.attestation) { + throw new InvalidNodeAttestation( + {}, + `Missing attestation in handshake response from ${url}, received ${JSON.stringify( + retrievedServerKeys, + null, + 2 + )}. "attestation" field should not be null.` + ); + } - // Verify the attestation by checking the signature against AMD certs - // Use the same challenge that was sent to the node - try { - const releaseVerificationFn = - params.networkModule?.getVerifyReleaseId?.(); - await checkSevSnpAttestation( - retrievedServerKeys.attestation, - challenge, - url, - params.releaseVerificationConfig, - releaseVerificationFn - ); - // 3. Store results if successful + // Verify the attestation by checking the signature against AMD certs + // Use the same challenge that was sent to the node + try { + const releaseVerificationFn = + params.networkModule?.getVerifyReleaseId?.(); + await checkSevSnpAttestation( + retrievedServerKeys.attestation, + challenge, + url, + params.releaseVerificationConfig, + releaseVerificationFn + ); + // 3. Store results if successful + serverKeys[url] = retrievedServerKeys; + connectedNodes.add(url); + _logger.info(`βœ… 1 Handshake successful for node: ${url}`); + } catch (error: any) { + throw new InvalidNodeAttestation( + { + cause: error, + }, + `Lit Node Attestation failed verification for ${url} - ${error.message}` + ); + } + } else { serverKeys[url] = retrievedServerKeys; connectedNodes.add(url); - _logger.info(`βœ… 1 Handshake successful for node: ${url}`); - } catch (error: any) { - throw new InvalidNodeAttestation( - { - cause: error, - }, - `Lit Node Attestation failed verification for ${url} - ${error.message}` - ); + _logger.info(`βœ… 2 Handshake successful for node: ${url}`); } - } else { - serverKeys[url] = retrievedServerKeys; - connectedNodes.add(url); - _logger.info(`βœ… 2 Handshake successful for node: ${url}`); - } - - return { url, success: true }; - } catch (error: any) { - _logger.error( - { - error: error.message, - stack: error.stack, - url, - }, - `❌ Handshake failed for node: ${url}` - ); - - // With Promise.all, any failure will cause immediate rejection - // But we still want to check if we have enough successful connections so far - const currentSuccessful = connectedNodes.size; - const minimumRequired = Math.max( - params.minimumThreshold, - Math.floor((params.bootstrapUrls.length * 2) / 3) - ); - if (currentSuccessful >= minimumRequired) { - _logger.warn( - `⚠️ Node ${url} failed, but we already have ${currentSuccessful} successful connections (threshold: ${minimumRequired}). Continuing...` + return { url, success: true as const }; + } catch (error: any) { + _logger.error( + { + error: error.message, + stack: error.stack, + url, + }, + `❌ Handshake failed for node: ${url}` ); - // Return success to not fail the Promise.all if we already have enough + return { url, - success: false, + success: false as const, error, - ignoredDueToThreshold: true, }; } + }) + ); - // If we don't have enough successful connections yet, let this failure propagate - throw error; - } - }) - ) - .then((results) => { - // Process results - this will only run if Promise.all succeeds - const successful = results.filter((r) => r.success).map((r) => r.url); - const failed = results.filter((r) => !r.success); + const successful = handshakeResults + .filter((result) => result.success) + .map((result) => result.url); + const failed = handshakeResults.filter((result) => !result.success); - _logger.info( - `πŸ“Š Handshake results: ${successful.length} successful, ${failed.length} failed out of ${params.bootstrapUrls.length} total nodes` - ); - _logger.info(`βœ… Successful nodes: ${successful.join(', ')}`); + _logger.info( + `πŸ“Š Handshake results: ${successful.length} successful, ${failed.length} failed out of ${params.bootstrapUrls.length} total nodes` + ); + _logger.info(`βœ… Successful nodes: ${successful.join(', ')}`); - if (failed.length > 0) { - _logger.warn( - `❌ Failed nodes (ignored due to threshold): ${failed - .map((f) => f.url) - .join(', ')}` - ); - } - - const minimumRequired = Math.max( - params.minimumThreshold, - Math.floor((params.bootstrapUrls.length * 2) / 3) + if (failed.length > 0) { + _logger.warn( + `❌ Failed nodes (ignored due to threshold): ${failed + .map((entry) => entry.url) + .join(', ')}` ); + } - if (successful.length < minimumRequired) { - const msg = `Error: Insufficient successful handshakes. Got ${successful.length} successful connections but need at least ${minimumRequired}.`; - throw new InitError( - { info: { requestId, successful, failed } }, - msg - ); - } - - _logger.info( - `πŸŽ‰ Handshake completed successfully with ${successful.length}/${params.bootstrapUrls.length} nodes (threshold: ${minimumRequired})` - ); - }) - .catch((error) => { - // If Promise.all fails, we need to check what we've collected so far - const currentSuccessful = connectedNodes.size; - const minimumRequired = Math.max( - params.minimumThreshold, - Math.floor((params.bootstrapUrls.length * 2) / 3) - ); + const minimumRequired = Math.max( + params.minimumThreshold, + Math.floor((params.bootstrapUrls.length * 2) / 3) + ); - if (currentSuccessful >= minimumRequired) { - _logger.warn( - `⚠️ Promise.all failed, but we have ${currentSuccessful} successful connections (threshold: ${minimumRequired}). Proceeding with partial results.` - ); - return; // Continue execution - } + if (successful.length < minimumRequired) { + const msg = `Error: Insufficient successful handshakes. Got ${successful.length} successful connections but need at least ${minimumRequired}.`; + throw new InitError({ info: { requestId, successful, failed } }, msg); + } - // If we don't have enough, rethrow the error - throw error; - }), + _logger.info( + `πŸŽ‰ Handshake completed successfully with ${successful.length}/${params.bootstrapUrls.length} nodes (threshold: ${minimumRequired})` + ); + })(), ]).finally(() => { clearTimeout(timeoutHandle); }); diff --git a/packages/networks/src/networks/vNaga/shared/managers/api-manager/helper/get-signatures.ts b/packages/networks/src/networks/vNaga/shared/managers/api-manager/helper/get-signatures.ts index 5eecda045..12484dcc1 100644 --- a/packages/networks/src/networks/vNaga/shared/managers/api-manager/helper/get-signatures.ts +++ b/packages/networks/src/networks/vNaga/shared/managers/api-manager/helper/get-signatures.ts @@ -134,26 +134,100 @@ export const combineExecuteJSSignatures = async (params: { } } - if (preparedShares.length < threshold) { - throw new NoValidShares( - { - info: { - requestId, - signatureKey, - preparedSharesCount: preparedShares.length, - threshold, + /** + * Recursively attempt to combine signature shares while tolerating a limited number + * of faulty entries. If the crypto combine call throws (usually due to a corrupted + * share), the helper drops one share at a timeβ€”up to `dropBudget` timesβ€”until the + * combine step succeeds or the threshold can no longer be met. + * + * @param shares Prepared signature shares grouped for a single sig key. + * @param dropBudget How many shares we are allowed to discard before giving up. + * @returns The combined signature along with the final set of shares that produced it. + */ + const attemptCombine = async ( + shares: typeof preparedShares, + dropBudget: number + ): Promise<{ + combined: Awaited>; + remainingShares: typeof preparedShares; + }> => { + if (shares.length < threshold) { + throw new NoValidShares( + { + info: { + requestId, + signatureKey, + preparedSharesCount: shares.length, + threshold, + }, }, - }, - `Not enough valid signature shares for ${signatureKey}: ${preparedShares.length} (expected ${threshold})` - ); - } + `Not enough valid signature shares for ${signatureKey}: ${shares.length} (expected ${threshold})` + ); + } + + try { + const sharesForCryptoLib: LitActionSignedData[] = shares.map( + (ps) => ({ + publicKey: ps.publicKey!, + signatureShare: + typeof ps.originalShare.signatureShare === 'string' + ? ps.originalShare.signatureShare + : JSON.stringify(ps.originalShare.signatureShare), + sigName: ps.originalShare.sigName, + sigType: ps.sigType! as any, + }) + ); + + const combinedSignature = await combineExecuteJsNodeShares( + sharesForCryptoLib + ); + + return { + combined: combinedSignature, + remainingShares: shares, + }; + } catch (error) { + if (dropBudget <= 0) { + throw error; + } + + let lastError: unknown = error; + + for (let index = 0; index < shares.length; index += 1) { + const filteredShares = shares.filter((_, i) => i !== index); + if (filteredShares.length < threshold) { + continue; + } + + console.log( + `[executeJs] dropping signature share ${index + 1}/${ + shares.length + } for ${signatureKey}; drops left ${dropBudget - 1}` + ); + + try { + return await attemptCombine(filteredShares, dropBudget - 1); + } catch (nested) { + lastError = nested; + } + } + + throw lastError; + } + }; + + // We can only drop as many faulty shares as we have "spares" beyond the threshold. + // e.g. 6 prepared shares with a threshold of 4 => maxDrops = 2; with exactly 4 shares => 0. + const maxDrops = Math.max(0, preparedShares.length - threshold); + + const { combined: combinedSignature, remainingShares } = + await attemptCombine(preparedShares, maxDrops); - // Get most common public key and sig type const publicKey = mostCommonString( - preparedShares.map((s) => s.publicKey).filter(Boolean) as string[] + remainingShares.map((s) => s.publicKey).filter(Boolean) as string[] ); const sigType = mostCommonString( - preparedShares.map((s) => s.sigType).filter(Boolean) as string[] + remainingShares.map((s) => s.sigType).filter(Boolean) as string[] ); if (!publicKey || !sigType) { @@ -164,31 +238,13 @@ export const combineExecuteJSSignatures = async (params: { signatureKey, publicKey, sigType, - shares: preparedShares, + shares: remainingShares, }, }, `Could not get public key or sig type from lit action shares for ${signatureKey}` ); } - // Prepare shares for crypto library (similar to PKP sign process) - const sharesForCryptoLib: LitActionSignedData[] = preparedShares.map( - (ps) => ({ - publicKey: ps.publicKey!, - signatureShare: - typeof ps.originalShare.signatureShare === 'string' - ? ps.originalShare.signatureShare - : JSON.stringify(ps.originalShare.signatureShare), - sigName: ps.originalShare.sigName, - sigType: ps.sigType! as any, // Cast to match EcdsaSigType - }) - ); - - // Combine the signature shares using the crypto library - const combinedSignature = await combineExecuteJsNodeShares( - sharesForCryptoLib - ); - const sigResponse = applyTransformations( { ...combinedSignature, diff --git a/packages/networks/src/networks/vNaga/shared/managers/state-manager/createStateManager.ts b/packages/networks/src/networks/vNaga/shared/managers/state-manager/createStateManager.ts index 908b466fd..5518d45ee 100644 --- a/packages/networks/src/networks/vNaga/shared/managers/state-manager/createStateManager.ts +++ b/packages/networks/src/networks/vNaga/shared/managers/state-manager/createStateManager.ts @@ -94,7 +94,10 @@ export const createStateManager = async (params: { const refreshState = async (reason?: string) => { try { - _logger.info({ reason }, 'Refreshing connection info and handshake callback'); + _logger.info( + { reason }, + 'Refreshing connection info and handshake callback' + ); const newConnectionInfo = await readOnlyChainManager.api.connection.getConnectionInfo(); const newBootstrapUrls = newConnectionInfo.bootstrapUrls; diff --git a/tsconfig.base.json b/tsconfig.base.json index 3e4cc5d31..bbbf0fe05 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -10,11 +10,7 @@ "importHelpers": true, "target": "ES2022", "module": "ES2022", - "lib": [ - "ES2022", - "dom", - "ES2021.String" - ], + "lib": ["ES2022", "dom", "ES2021.String"], "skipLibCheck": true, "skipDefaultLibCheck": true, "baseUrl": ".", @@ -22,20 +18,10 @@ "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "paths": { - "@lit-protocol/*": [ - "packages/*/src" - ], - "@lit-protocol/contracts": [ - "packages/contracts/dist/index" - ], - "@lit-protocol/contracts/*": [ - "packages/contracts/dist/*" - ] + "@lit-protocol/*": ["packages/*/src"], + "@lit-protocol/contracts": ["packages/contracts/dist/index"], + "@lit-protocol/contracts/*": ["packages/contracts/dist/*"] } }, - "exclude": [ - "node_modules", - "tmp", - "dist" - ] -} \ No newline at end of file + "exclude": ["node_modules", "tmp", "dist"] +} From fd9544d42c3ba12a40db4a9ef7e1b34c98506512 Mon Sep 17 00:00:00 2001 From: anson Date: Fri, 7 Nov 2025 14:51:20 +0000 Subject: [PATCH 10/10] chore(release): add changelog --- .changeset/warm-lizards-fry.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/warm-lizards-fry.md diff --git a/.changeset/warm-lizards-fry.md b/.changeset/warm-lizards-fry.md new file mode 100644 index 000000000..d4717317d --- /dev/null +++ b/.changeset/warm-lizards-fry.md @@ -0,0 +1,7 @@ +--- +'@lit-protocol/lit-client': patch +'@lit-protocol/networks': patch +'@lit-protocol/e2e': patch +--- + +SDK exposes typed Shiva env helpers (`createShivaEnvVars`, `waitForTestnetInfo`, `SUPPORTED_NETWORKS`) so QA suites can spin up testnets without bespoke env plumbing, and the new `executeWithHandshake` runner automatically retry failures for more stable Lit action execution.