Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: blockhash syncing #727

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading