diff --git a/packages/e2e/src/e2e.spec.ts b/packages/e2e/src/e2e.spec.ts index b007077561..c2ea2f1d8b 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/helper/shiva-client.ts b/packages/e2e/src/helper/ShivaClient/createShivaClient.ts similarity index 67% rename from packages/e2e/src/helper/shiva-client.ts rename to packages/e2e/src/helper/ShivaClient/createShivaClient.ts index b8357531c6..739bd619c6 100644 --- a/packages/e2e/src/helper/shiva-client.ts +++ b/packages/e2e/src/helper/ShivaClient/createShivaClient.ts @@ -1,4 +1,8 @@ -import type { LitClientInstance } from '../types'; +import { createLitClient } from '@lit-protocol/lit-client'; +import { + createEpochSnapshot, + EpochSnapshot, +} from './helpers/createEpochSnapshot'; /** * Options used when Shiva spins up a brand-new testnet instance. @@ -17,11 +21,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; }; @@ -42,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; }; @@ -81,15 +79,31 @@ export type ShivaClient = { /** 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_STATE_POLL_INTERVAL = 2000; +const DEFAULT_STATE_POLL_TIMEOUT = 60_000; const normaliseBaseUrl = (baseUrl: string) => { return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; @@ -153,7 +167,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 @@ -178,38 +192,14 @@ const ensureTestnetId = async ( body: createRequest, }); - if (!response.testnet_id) { - throw new Error('Shiva create testnet response did not include testnet_id'); + if (!response.testnetId) { + throw new Error( + 'Shiva create testnet response did not include testnetId. 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, - }; + return response.testnetId; }; /** @@ -218,23 +208,37 @@ 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 ); + let litClientInstance: + | Awaited> + | undefined; + + const setLitClient = ( + client: Awaited> + ) => { + litClientInstance = client; + }; + const inspectEpoch = async () => { - const ctx = await litClient.getContext(); - return buildEpochSnapshot(ctx); + if (!litClientInstance) { + throw new Error( + `Lit client not set. Please call setLitClient() before using inspectEpoch().` + ); + } + + return createEpochSnapshot(litClientInstance); }; const waitForEpochChange = async ({ - baselineEpoch, + expectedEpoch, timeoutMs = DEFAULT_TIMEOUT, intervalMs = DEFAULT_POLL_INTERVAL, }: WaitForEpochOptions) => { @@ -243,13 +247,15 @@ export const createShivaClient = async ( while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, intervalMs)); const snapshot = await inspectEpoch(); - if (snapshot.epoch !== baselineEpoch) { + if ( + snapshot.latestConnectionInfo.epochState.currentNumber !== expectedEpoch + ) { return snapshot; } } throw new Error( - `Epoch did not change from ${baselineEpoch} within ${timeoutMs}ms` + `Epoch did not change from ${expectedEpoch} within ${timeoutMs}ms` ); }; @@ -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 0000000000..18c9b4fb76 --- /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 77331ae103..642edf82cd 100644 --- a/packages/e2e/src/index.ts +++ b/packages/e2e/src/index.ts @@ -1,10 +1,20 @@ // 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'; +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 f497d1a273..79f7afb287 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 0000000000..042a68defb --- /dev/null +++ b/packages/lit-client/src/lib/LitClient/helpers/executeWithHandshake.ts @@ -0,0 +1,166 @@ +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; +}; + +// 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') { + 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: RETRY_REASONS.missingVerificationKey, + }; + } + + if (EdgeCase.isNetworkFetchError(error)) { + return { shouldRetry: true, reason: RETRY_REASONS.networkFetch }; + } + + if (EdgeCase.isNoValidSharesError(error)) { + return { shouldRetry: true, reason: RETRY_REASONS.noValidShares }; + } + + 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_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)); + } + + _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 9ab10a4493..908b466fde 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). */ diff --git a/tsconfig.base.json b/tsconfig.base.json index bbbf0fe051..3e4cc5d315 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