diff --git a/packages/core/jest.config.ts b/packages/core/jest.config.ts index 04ee6b21c..94ee66dd3 100644 --- a/packages/core/jest.config.ts +++ b/packages/core/jest.config.ts @@ -6,6 +6,7 @@ export default { 'ts-jest': { tsconfig: '/tsconfig.spec.json', }, + fetch: global.fetch, }, transform: { '^.+\\.[t]s$': 'ts-jest', diff --git a/packages/core/src/lib/lit-core.spec.ts b/packages/core/src/lib/lit-core.spec.ts new file mode 100644 index 000000000..eed2d5ee9 --- /dev/null +++ b/packages/core/src/lib/lit-core.spec.ts @@ -0,0 +1,177 @@ +import { InvalidEthBlockhash } from '@lit-protocol/constants'; + +import { LitCore } from './lit-core'; + +describe('LitCore', () => { + let core: LitCore; + + describe('getLatestBlockhash', () => { + let originalFetch: typeof fetch; + let originalDateNow: typeof Date.now; + const mockBlockhashUrl = + 'https://block-indexer-url.com/get_most_recent_valid_block'; + + beforeEach(() => { + core = new LitCore({ + litNetwork: 'custom', + }); + core['_blockHashUrl'] = mockBlockhashUrl; + originalFetch = fetch; + originalDateNow = Date.now; + }); + + afterEach(() => { + global.fetch = originalFetch; + Date.now = originalDateNow; + jest.clearAllMocks(); + }); + + it('should return cached blockhash if still valid', async () => { + // Setup + const mockBlockhash = '0x1234'; + const currentTime = 1000000; + core.latestBlockhash = mockBlockhash; + core.lastBlockHashRetrieved = currentTime; + Date.now = jest.fn().mockReturnValue(currentTime + 15000); // 15 seconds later + global.fetch = jest.fn(); + + // Execute + const result = await core.getLatestBlockhash(); + + // Assert + expect(result).toBe(mockBlockhash); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('should fetch new blockhash when cache is expired', async () => { + // Setup + const mockBlockhash = '0x5678'; + const currentTime = 1000000; + core.latestBlockhash = '0x1234'; + core.lastBlockHashRetrieved = currentTime - 31000; // 31 seconds ago currentTime + const blockNumber = 12345; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + blockhash: mockBlockhash, + timestamp: currentTime, + blockNumber, + }), + }); + Date.now = jest.fn().mockReturnValue(currentTime); + + // Execute + const result = await core.getLatestBlockhash(); + + // Assert + expect(result).toBe(mockBlockhash); + expect(fetch).toHaveBeenCalledWith(mockBlockhashUrl); + }); + + it('should throw error when blockhash is not available', async () => { + // Setup + core.latestBlockhash = null; + core.lastBlockHashRetrieved = null; + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + }); + core['_getProviderWithFallback'] = jest.fn(() => Promise.resolve(null)); + + // Execute & Assert + await expect(core.getLatestBlockhash()).rejects.toThrow( + InvalidEthBlockhash + ); + }); + + it('should handle fetch failure and use fallback RPC', async () => { + // Setup + const mockBlockhash = '0xabc'; + const currentTime = 1000000; + Date.now = jest.fn().mockReturnValue(currentTime); + global.fetch = jest.fn().mockRejectedValue(new Error('Fetch failed')); + const mockProvider = { + getBlockNumber: jest.fn().mockResolvedValue(12345), + getBlock: jest.fn().mockResolvedValue({ + hash: mockBlockhash, + number: 12345, + timestamp: currentTime, + }), + }; + jest.spyOn(core as any, '_getProviderWithFallback').mockResolvedValue({ + ...mockProvider, + }); + + // Execute + const result = await core.getLatestBlockhash(); + + // Assert + expect(fetch).toHaveBeenCalledWith(mockBlockhashUrl); + expect(mockProvider.getBlock).toHaveBeenCalledWith(-1); // safety margin + expect(result).toBe(mockBlockhash); + }); + + it('should handle empty blockhash response with fallback RPC URLs', async () => { + // Setup + const mockBlockhash = '0xabc'; + const currentTime = 1000000; + Date.now = jest.fn().mockReturnValue(currentTime); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + blockhash: null, + blockNumber: null, + }), + }); + const mockProvider = { + getBlockNumber: jest.fn().mockResolvedValue(12345), + getBlock: jest.fn().mockResolvedValue({ + hash: mockBlockhash, + number: 12345, + timestamp: currentTime, + }), + }; + jest.spyOn(core as any, '_getProviderWithFallback').mockResolvedValue({ + ...mockProvider, + }); + + // Execute + const result = await core.getLatestBlockhash(); + + // Assert + expect(fetch).toHaveBeenCalledWith(mockBlockhashUrl); + expect(mockProvider.getBlock).toHaveBeenCalledWith(-1); // safety margin + expect(result).toBe(mockBlockhash); + }); + + it('should handle network timeouts gracefully', async () => { + // Setup + const currentTime = 1000000; + Date.now = jest.fn().mockReturnValue(currentTime); + + global.fetch = jest + .fn() + .mockImplementation( + () => + new Promise((_, reject) => + setTimeout(() => reject(new Error('Network timeout')), 1000) + ) + ); + + const mockProvider = { + getBlockNumber: jest.fn().mockResolvedValue(12345), + getBlock: jest.fn().mockResolvedValue(null), // Provider also fails + }; + + jest.spyOn(core as any, '_getProviderWithFallback').mockResolvedValue({ + ...mockProvider, + }); + + // Execute & Assert + await expect(() => core.getLatestBlockhash()).rejects.toThrow( + InvalidEthBlockhash + ); + }); + }); +}); diff --git a/packages/core/src/lib/lit-core.ts b/packages/core/src/lib/lit-core.ts index d32c7b1ab..97c690cc6 100644 --- a/packages/core/src/lib/lit-core.ts +++ b/packages/core/src/lib/lit-core.ts @@ -31,6 +31,7 @@ import { version, InitError, InvalidParamType, + NetworkError, NodeError, UnknownError, InvalidArgumentException, @@ -118,6 +119,8 @@ export type LitNodeClientConfigWithDefaults = Required< const EPOCH_PROPAGATION_DELAY = 45_000; // This interval is responsible for keeping latest block hash up to date const BLOCKHASH_SYNC_INTERVAL = 30_000; +// When fetching the blockhash from a provider (not lit), we use a previous block to avoid a nodes not knowing about the new block yet +const BLOCKHASH_COUNT_PROVIDER_DELAY = -1; // Intentionally not including datil-dev here per discussion with Howard const NETWORKS_REQUIRING_SEV: string[] = [ @@ -784,6 +787,8 @@ export class LitCore { /** * Fetches the latest block hash and log any errors that are returned + * Nodes will accept any blockhash in the last 30 days but use the latest 10 as challenges for webauthn + * Note: last blockhash from providers might not be propagated to the nodes yet, so we need to use a slightly older one * @returns void */ private async _syncBlockhash() { @@ -805,52 +810,72 @@ export class LitCore { this.latestBlockhash ); - return fetch(this._blockHashUrl) - .then(async (resp: Response) => { - const blockHashBody: EthBlockhashInfo = await resp.json(); - this.latestBlockhash = blockHashBody.blockhash; - this.lastBlockHashRetrieved = Date.now(); - log('Done syncing state new blockhash: ', this.latestBlockhash); - - // If the blockhash retrieval failed, throw an error to trigger fallback in catch block - if (!this.latestBlockhash) { - throw new Error( - `Error getting latest blockhash. Received: "${this.latestBlockhash}"` - ); - } - }) - .catch(async (err: BlockHashErrorResponse | Error) => { - logError( - 'Error while attempting to fetch new latestBlockhash:', - err instanceof Error ? err.message : err.messages, - 'Reason: ', - err instanceof Error ? err : err.reason + try { + // This fetches from the lit propagation service so nodes will always have it + const resp = await fetch(this._blockHashUrl); + // If the blockhash retrieval failed, throw an error to trigger fallback in catch block + if (!resp.ok) { + throw new NetworkError( + { + responseResult: resp.ok, + responseStatus: resp.status, + }, + `Error getting latest blockhash from ${this._blockHashUrl}. Received: "${resp.status}"` ); + } - log( - 'Attempting to fetch blockhash manually using ethers with fallback RPC URLs...' + const blockHashBody: EthBlockhashInfo = await resp.json(); + const { blockhash, timestamp } = blockHashBody; + + // If the blockhash retrieval does not have the required fields, throw an error to trigger fallback in catch block + if (!blockhash || !timestamp) { + throw new NetworkError( + { + responseResult: resp.ok, + blockHashBody, + }, + `Error getting latest blockhash from block indexer. Received: "${blockHashBody}"` ); - const provider = await this._getProviderWithFallback(); + } - if (!provider) { - logError( - 'All fallback RPC URLs failed. Unable to retrieve blockhash.' - ); - return; - } + this.latestBlockhash = blockHashBody.blockhash; + this.lastBlockHashRetrieved = parseInt(timestamp) * 1000; + log('Done syncing state new blockhash: ', this.latestBlockhash); + } catch (error: unknown) { + const err = error as BlockHashErrorResponse | Error; + + logError( + 'Error while attempting to fetch new latestBlockhash:', + err instanceof Error ? err.message : err.messages, + 'Reason: ', + err instanceof Error ? err : err.reason + ); - try { - const latestBlock = await provider.getBlock('latest'); - this.latestBlockhash = latestBlock.hash; - this.lastBlockHashRetrieved = Date.now(); - log( - 'Successfully retrieved blockhash manually: ', - this.latestBlockhash - ); - } catch (ethersError) { - logError('Failed to manually retrieve blockhash using ethers'); - } - }); + log( + 'Attempting to fetch blockhash manually using ethers with fallback RPC URLs...' + ); + const provider = await this._getProviderWithFallback(); + + if (!provider) { + logError('All fallback RPC URLs failed. Unable to retrieve blockhash.'); + return; + } + + try { + // We use a previous block to avoid nodes not having received the latest block yet + const priorBlock = await provider.getBlock( + BLOCKHASH_COUNT_PROVIDER_DELAY + ); + this.latestBlockhash = priorBlock.hash; + this.lastBlockHashRetrieved = priorBlock.timestamp; + log( + 'Successfully retrieved blockhash manually: ', + this.latestBlockhash + ); + } catch (ethersError) { + logError('Failed to manually retrieve blockhash using ethers'); + } + } } /** Currently, we perform a full sync every 30s, including handshaking with every node