Skip to content

Commit 181933e

Browse files
committed
fix: rewrite _syncBlockhash with async/await instead of promise chains. Verify lit block indexer response success. Use a previous block from public providers to avoid using one nodes haven't received yet
1 parent d7aabfd commit 181933e

File tree

3 files changed

+233
-41
lines changed

3 files changed

+233
-41
lines changed

packages/core/jest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default {
66
'ts-jest': {
77
tsconfig: '<rootDir>/tsconfig.spec.json',
88
},
9+
fetch: global.fetch,
910
},
1011
transform: {
1112
'^.+\\.[t]s$': 'ts-jest',
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { InvalidEthBlockhash } from '@lit-protocol/constants';
2+
3+
import { LitCore } from './lit-core';
4+
5+
describe('LitCore', () => {
6+
let core: LitCore;
7+
8+
describe('getLatestBlockhash', () => {
9+
let originalFetch: typeof fetch;
10+
let originalDateNow: typeof Date.now;
11+
const mockBlockhashUrl = 'https://block-indexer-url.com/get_most_recent_valid_block';
12+
13+
beforeEach(() => {
14+
core = new LitCore({
15+
litNetwork: 'custom',
16+
});
17+
core['_blockHashUrl'] = mockBlockhashUrl;
18+
originalFetch = fetch;
19+
originalDateNow = Date.now;
20+
});
21+
22+
afterEach(() => {
23+
global.fetch = originalFetch;
24+
Date.now = originalDateNow;
25+
jest.clearAllMocks();
26+
});
27+
28+
it('should return cached blockhash if still valid', async () => {
29+
// Setup
30+
const mockBlockhash = '0x1234';
31+
const currentTime = 1000000;
32+
core.latestBlockhash = mockBlockhash;
33+
core.lastBlockHashRetrieved = currentTime;
34+
Date.now = jest.fn().mockReturnValue(currentTime + 15000); // 15 seconds later
35+
global.fetch = jest.fn();
36+
37+
// Execute
38+
const result = await core.getLatestBlockhash();
39+
40+
// Assert
41+
expect(result).toBe(mockBlockhash);
42+
expect(global.fetch).not.toHaveBeenCalled();
43+
});
44+
45+
it('should fetch new blockhash when cache is expired', async () => {
46+
// Setup
47+
const mockBlockhash = '0x5678';
48+
const currentTime = 1000000;
49+
core.latestBlockhash = '0x1234';
50+
core.lastBlockHashRetrieved = currentTime - 31000; // 31 seconds ago currentTime
51+
const blockNumber = 12345;
52+
global.fetch = jest.fn().mockResolvedValue({
53+
ok: true,
54+
json: () => Promise.resolve({ blockhash: mockBlockhash, timestamp: currentTime, blockNumber }),
55+
});
56+
Date.now = jest.fn().mockReturnValue(currentTime);
57+
58+
// Execute
59+
const result = await core.getLatestBlockhash();
60+
61+
// Assert
62+
expect(result).toBe(mockBlockhash);
63+
expect(fetch).toHaveBeenCalledWith(mockBlockhashUrl);
64+
});
65+
66+
it('should throw error when blockhash is not available', async () => {
67+
// Setup
68+
core.latestBlockhash = null;
69+
core.lastBlockHashRetrieved = null;
70+
global.fetch = jest.fn().mockResolvedValue({
71+
ok: false,
72+
});
73+
core['_getProviderWithFallback'] = jest.fn(() => Promise.resolve(null));
74+
75+
// Execute & Assert
76+
await expect(core.getLatestBlockhash()).rejects.toThrow(InvalidEthBlockhash);
77+
});
78+
79+
it('should handle fetch failure and use fallback RPC', async () => {
80+
// Setup
81+
const mockBlockhash = '0xabc';
82+
const currentTime = 1000000;
83+
Date.now = jest.fn().mockReturnValue(currentTime);
84+
global.fetch = jest.fn().mockRejectedValue(new Error('Fetch failed'));
85+
const mockProvider = {
86+
getBlockNumber: jest.fn().mockResolvedValue(12345),
87+
getBlock: jest.fn().mockResolvedValue({
88+
hash: mockBlockhash,
89+
number: 12345,
90+
timestamp: currentTime
91+
}),
92+
};
93+
jest.spyOn(core as any, '_getProviderWithFallback').mockResolvedValue({
94+
...mockProvider,
95+
});
96+
97+
// Execute
98+
const result = await core.getLatestBlockhash();
99+
100+
// Assert
101+
expect(fetch).toHaveBeenCalledWith(mockBlockhashUrl);
102+
expect(mockProvider.getBlock).toHaveBeenCalledWith(-1); // safety margin
103+
expect(result).toBe(mockBlockhash);
104+
});
105+
106+
it('should handle empty blockhash response with fallback RPC URLs', async () => {
107+
// Setup
108+
const mockBlockhash = '0xabc';
109+
const currentTime = 1000000;
110+
Date.now = jest.fn().mockReturnValue(currentTime);
111+
global.fetch = jest.fn().mockResolvedValue({
112+
ok: true,
113+
json: () => Promise.resolve({
114+
blockhash: null,
115+
blockNumber: null
116+
}),
117+
});
118+
const mockProvider = {
119+
getBlockNumber: jest.fn().mockResolvedValue(12345),
120+
getBlock: jest.fn().mockResolvedValue({
121+
hash: mockBlockhash,
122+
number: 12345,
123+
timestamp: currentTime
124+
}),
125+
};
126+
jest.spyOn(core as any, '_getProviderWithFallback').mockResolvedValue({
127+
...mockProvider,
128+
});
129+
130+
// Execute
131+
const result = await core.getLatestBlockhash();
132+
133+
// Assert
134+
expect(fetch).toHaveBeenCalledWith(mockBlockhashUrl);
135+
expect(mockProvider.getBlock).toHaveBeenCalledWith(-1); // safety margin
136+
expect(result).toBe(mockBlockhash);
137+
});
138+
139+
it('should handle network timeouts gracefully', async () => {
140+
// Setup
141+
const currentTime = 1000000;
142+
Date.now = jest.fn().mockReturnValue(currentTime);
143+
144+
global.fetch = jest.fn().mockImplementation(() =>
145+
new Promise((_, reject) =>
146+
setTimeout(() => reject(new Error('Network timeout')), 1000)
147+
)
148+
);
149+
150+
const mockProvider = {
151+
getBlockNumber: jest.fn().mockResolvedValue(12345),
152+
getBlock: jest.fn().mockResolvedValue(null), // Provider also fails
153+
};
154+
155+
jest.spyOn(core as any, '_getProviderWithFallback').mockResolvedValue({
156+
...mockProvider,
157+
});
158+
159+
// Execute & Assert
160+
await expect(() => core.getLatestBlockhash()).rejects.toThrow(
161+
InvalidEthBlockhash
162+
);
163+
});
164+
});
165+
166+
});

packages/core/src/lib/lit-core.ts

Lines changed: 66 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
version,
3232
InitError,
3333
InvalidParamType,
34+
NetworkError,
3435
NodeError,
3536
UnknownError,
3637
InvalidArgumentException,
@@ -118,6 +119,8 @@ export type LitNodeClientConfigWithDefaults = Required<
118119
const EPOCH_PROPAGATION_DELAY = 45_000;
119120
// This interval is responsible for keeping latest block hash up to date
120121
const BLOCKHASH_SYNC_INTERVAL = 30_000;
122+
// 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
123+
const BLOCKHASH_COUNT_PROVIDER_DELAY = -1;
121124

122125
// Intentionally not including datil-dev here per discussion with Howard
123126
const NETWORKS_REQUIRING_SEV: string[] = [
@@ -784,6 +787,8 @@ export class LitCore {
784787

785788
/**
786789
* Fetches the latest block hash and log any errors that are returned
790+
* Nodes will accept any blockhash in the last 30 days but use the latest 10 as challenges for webauthn
791+
* Note: last blockhash from providers might not be propagated to the nodes yet, so we need to use a slightly older one
787792
* @returns void
788793
*/
789794
private async _syncBlockhash() {
@@ -805,52 +810,72 @@ export class LitCore {
805810
this.latestBlockhash
806811
);
807812

808-
return fetch(this._blockHashUrl)
809-
.then(async (resp: Response) => {
810-
const blockHashBody: EthBlockhashInfo = await resp.json();
811-
this.latestBlockhash = blockHashBody.blockhash;
812-
this.lastBlockHashRetrieved = Date.now();
813-
log('Done syncing state new blockhash: ', this.latestBlockhash);
814-
815-
// If the blockhash retrieval failed, throw an error to trigger fallback in catch block
816-
if (!this.latestBlockhash) {
817-
throw new Error(
818-
`Error getting latest blockhash. Received: "${this.latestBlockhash}"`
819-
);
820-
}
821-
})
822-
.catch(async (err: BlockHashErrorResponse | Error) => {
823-
logError(
824-
'Error while attempting to fetch new latestBlockhash:',
825-
err instanceof Error ? err.message : err.messages,
826-
'Reason: ',
827-
err instanceof Error ? err : err.reason
813+
try {
814+
// This fetches from the lit propagation service so nodes will always have it
815+
const resp = await fetch(this._blockHashUrl);
816+
// If the blockhash retrieval failed, throw an error to trigger fallback in catch block
817+
if (!resp.ok) {
818+
throw new NetworkError(
819+
{
820+
responseResult: resp.ok,
821+
responseStatus: resp.status,
822+
},
823+
`Error getting latest blockhash from ${this._blockHashUrl}. Received: "${resp.status}"`
828824
);
825+
}
829826

830-
log(
831-
'Attempting to fetch blockhash manually using ethers with fallback RPC URLs...'
827+
const blockHashBody: EthBlockhashInfo = await resp.json();
828+
const { blockhash, timestamp } = blockHashBody;
829+
830+
// If the blockhash retrieval does not have the required fields, throw an error to trigger fallback in catch block
831+
if (!blockhash || !timestamp) {
832+
throw new NetworkError(
833+
{
834+
responseResult: resp.ok,
835+
blockHashBody,
836+
},
837+
`Error getting latest blockhash from block indexer. Received: "${blockHashBody}"`
832838
);
833-
const provider = await this._getProviderWithFallback();
839+
}
834840

835-
if (!provider) {
836-
logError(
837-
'All fallback RPC URLs failed. Unable to retrieve blockhash.'
838-
);
839-
return;
840-
}
841+
this.latestBlockhash = blockHashBody.blockhash;
842+
this.lastBlockHashRetrieved = parseInt(timestamp) * 1000;
843+
log('Done syncing state new blockhash: ', this.latestBlockhash);
844+
} catch (error: unknown) {
845+
const err = error as BlockHashErrorResponse | Error;
846+
847+
logError(
848+
'Error while attempting to fetch new latestBlockhash:',
849+
err instanceof Error ? err.message : err.messages,
850+
'Reason: ',
851+
err instanceof Error ? err : err.reason
852+
);
841853

842-
try {
843-
const latestBlock = await provider.getBlock('latest');
844-
this.latestBlockhash = latestBlock.hash;
845-
this.lastBlockHashRetrieved = Date.now();
846-
log(
847-
'Successfully retrieved blockhash manually: ',
848-
this.latestBlockhash
849-
);
850-
} catch (ethersError) {
851-
logError('Failed to manually retrieve blockhash using ethers');
852-
}
853-
});
854+
log(
855+
'Attempting to fetch blockhash manually using ethers with fallback RPC URLs...'
856+
);
857+
const provider = await this._getProviderWithFallback();
858+
859+
if (!provider) {
860+
logError(
861+
'All fallback RPC URLs failed. Unable to retrieve blockhash.'
862+
);
863+
return;
864+
}
865+
866+
try {
867+
// We use a previous block to avoid nodes not having received the latest block yet
868+
const priorBlock = await provider.getBlock(BLOCKHASH_COUNT_PROVIDER_DELAY);
869+
this.latestBlockhash = priorBlock.hash;
870+
this.lastBlockHashRetrieved = priorBlock.timestamp;
871+
log(
872+
'Successfully retrieved blockhash manually: ',
873+
this.latestBlockhash
874+
);
875+
} catch (ethersError) {
876+
logError('Failed to manually retrieve blockhash using ethers');
877+
}
878+
}
854879
}
855880

856881
/** Currently, we perform a full sync every 30s, including handshaking with every node

0 commit comments

Comments
 (0)