Skip to content

Commit

Permalink
Merge pull request #727 from LIT-Protocol/feature/lit-4046-blockhash-…
Browse files Browse the repository at this point in the history
…sync-fixes

fix: blockhash syncing
  • Loading branch information
FedericoAmura authored Dec 5, 2024
2 parents 1f852ec + a2fab43 commit 6fc8ad3
Show file tree
Hide file tree
Showing 3 changed files with 244 additions and 41 deletions.
1 change: 1 addition & 0 deletions packages/core/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export default {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
},
fetch: global.fetch,
},
transform: {
'^.+\\.[t]s$': 'ts-jest',
Expand Down
177 changes: 177 additions & 0 deletions packages/core/src/lib/lit-core.spec.ts
Original file line number Diff line number Diff line change
@@ -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
);
});
});
});
107 changes: 66 additions & 41 deletions packages/core/src/lib/lit-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
version,
InitError,
InvalidParamType,
NetworkError,
NodeError,
UnknownError,
InvalidArgumentException,
Expand Down Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand Down

0 comments on commit 6fc8ad3

Please sign in to comment.