Skip to content

Commit 9e719cd

Browse files
Merge pull request #732 from LIT-Protocol/feature/lit-4046-blockhash-sync-fixes-v6-2
fix: blockhash syncing v6 - 2
2 parents ded2c3f + ea776a4 commit 9e719cd

File tree

3 files changed

+239
-44
lines changed

3 files changed

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

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

Lines changed: 63 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ import {
6363
NodeClientErrorV0,
6464
NodeClientErrorV1,
6565
NodeCommandServerKeysResponse,
66-
NodeErrorV3,
6766
RejectedNodePromises,
6867
SendNodeCommand,
6968
SessionSigsMap,
@@ -113,6 +112,8 @@ export type LitNodeClientConfigWithDefaults = Required<
113112
const EPOCH_PROPAGATION_DELAY = 45_000;
114113
// This interval is responsible for keeping latest block hash up to date
115114
const BLOCKHASH_SYNC_INTERVAL = 30_000;
115+
// 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
116+
const BLOCKHASH_COUNT_PROVIDER_DELAY = -1;
116117

117118
// Intentionally not including datil-dev here per discussion with Howard
118119
const NETWORKS_REQUIRING_SEV: string[] = [
@@ -798,6 +799,8 @@ export class LitCore {
798799

799800
/**
800801
* Fetches the latest block hash and log any errors that are returned
802+
* Nodes will accept any blockhash in the last 30 days but use the latest 10 as challenges for webauthn
803+
* Note: last blockhash from providers might not be propagated to the nodes yet, so we need to use a slightly older one
801804
* @returns void
802805
*/
803806
private async _syncBlockhash() {
@@ -819,52 +822,68 @@ export class LitCore {
819822
this.latestBlockhash
820823
);
821824

822-
return fetch(this._blockHashUrl)
823-
.then(async (resp: Response) => {
824-
const blockHashBody: EthBlockhashInfo = await resp.json();
825-
this.latestBlockhash = blockHashBody.blockhash;
826-
this.lastBlockHashRetrieved = Date.now();
827-
log('Done syncing state new blockhash: ', this.latestBlockhash);
828-
829-
// If the blockhash retrieval failed, throw an error to trigger fallback in catch block
830-
if (!this.latestBlockhash) {
831-
throw new Error(
832-
`Error getting latest blockhash. Received: "${this.latestBlockhash}"`
833-
);
834-
}
835-
})
836-
.catch(async (err: BlockHashErrorResponse | Error) => {
837-
logError(
838-
'Error while attempting to fetch new latestBlockhash:',
839-
err instanceof Error ? err.message : err.messages,
840-
'Reason: ',
841-
err instanceof Error ? err : err.reason
842-
);
825+
try {
826+
// This fetches from the lit propagation service so nodes will always have it
827+
const resp = await fetch(this._blockHashUrl);
828+
// If the blockhash retrieval failed, throw an error to trigger fallback in catch block
829+
if (!resp.ok) {
830+
throwError({
831+
message: `Error getting latest blockhash from ${this._blockHashUrl}. Received: "${resp.status}"`,
832+
errorKind: LIT_ERROR.INVALID_ETH_BLOCKHASH.kind,
833+
errorCode: LIT_ERROR.INVALID_ETH_BLOCKHASH.code,
834+
});
835+
}
843836

844-
log(
845-
'Attempting to fetch blockhash manually using ethers with fallback RPC URLs...'
846-
);
847-
const provider = await this._getProviderWithFallback();
837+
const blockHashBody: EthBlockhashInfo = await resp.json();
838+
const { blockhash, timestamp } = blockHashBody;
848839

849-
if (!provider) {
850-
logError(
851-
'All fallback RPC URLs failed. Unable to retrieve blockhash.'
852-
);
853-
return;
854-
}
840+
// If the blockhash retrieval does not have the required fields, throw an error to trigger fallback in catch block
841+
if (!blockhash || !timestamp) {
842+
throwError({
843+
message: `Error getting latest blockhash from block indexer. Received: "${blockHashBody}"`,
844+
errorKind: LIT_ERROR.INVALID_ETH_BLOCKHASH.kind,
845+
errorCode: LIT_ERROR.INVALID_ETH_BLOCKHASH.code,
846+
});
847+
}
855848

856-
try {
857-
const latestBlock = await provider.getBlock('latest');
858-
this.latestBlockhash = latestBlock.hash;
859-
this.lastBlockHashRetrieved = Date.now();
860-
log(
861-
'Successfully retrieved blockhash manually: ',
862-
this.latestBlockhash
863-
);
864-
} catch (ethersError) {
865-
logError('Failed to manually retrieve blockhash using ethers');
866-
}
867-
});
849+
this.latestBlockhash = blockHashBody.blockhash;
850+
this.lastBlockHashRetrieved = parseInt(timestamp) * 1000;
851+
log('Done syncing state new blockhash: ', this.latestBlockhash);
852+
} catch (error: unknown) {
853+
const err = error as BlockHashErrorResponse | Error;
854+
855+
logError(
856+
'Error while attempting to fetch new latestBlockhash:',
857+
err instanceof Error ? err.message : err.messages,
858+
'Reason: ',
859+
err instanceof Error ? err : err.reason
860+
);
861+
862+
log(
863+
'Attempting to fetch blockhash manually using ethers with fallback RPC URLs...'
864+
);
865+
const provider = await this._getProviderWithFallback();
866+
867+
if (!provider) {
868+
logError('All fallback RPC URLs failed. Unable to retrieve blockhash.');
869+
return;
870+
}
871+
872+
try {
873+
// We use a previous block to avoid nodes not having received the latest block yet
874+
const priorBlock = await provider.getBlock(
875+
BLOCKHASH_COUNT_PROVIDER_DELAY
876+
);
877+
this.latestBlockhash = priorBlock.hash;
878+
this.lastBlockHashRetrieved = priorBlock.timestamp;
879+
log(
880+
'Successfully retrieved blockhash manually: ',
881+
this.latestBlockhash
882+
);
883+
} catch (ethersError) {
884+
logError('Failed to manually retrieve blockhash using ethers');
885+
}
886+
}
868887
}
869888

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

0 commit comments

Comments
 (0)