@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
332 lines • 17.2 kB
JavaScript
import { expect } from 'chai';
import { errors as EthersError, providers } from 'ethers';
import { AllProviderMethods } from './ProviderMethods.js';
import { BlockchainError, HyperlaneSmartProvider } from './SmartProvider.js';
import { ProviderStatus } from './types.js';
// Dummy provider for testing
class MockProvider extends providers.BaseProvider {
baseUrl;
errorToThrow;
successValue;
responseDelayMs;
supportedMethods = AllProviderMethods;
called = false;
thrownError;
static success(successValue, responseDelayMs = 0) {
return new MockProvider('http://provider', undefined, successValue, responseDelayMs);
}
static error(errorToThrow, responseDelayMs = 0) {
return new MockProvider('http://provider', errorToThrow, undefined, responseDelayMs);
}
constructor(baseUrl, errorToThrow, successValue, responseDelayMs = 0) {
super({ name: 'test', chainId: 1 });
this.baseUrl = baseUrl;
this.errorToThrow = errorToThrow;
this.successValue = successValue;
this.responseDelayMs = responseDelayMs;
}
getBaseUrl() {
return this.baseUrl;
}
async perform(_method, _params, _reqId) {
this.called = true;
if (this.responseDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, this.responseDelayMs));
}
if (this.errorToThrow) {
this.thrownError = this.errorToThrow;
throw this.errorToThrow;
}
return this.successValue ?? 'success';
}
// Required BaseProvider methods - minimal implementations
async detectNetwork() {
return { name: 'test', chainId: 1 };
}
}
class TestableSmartProvider extends HyperlaneSmartProvider {
mockProviders;
constructor(mockProviders) {
super({ chainId: 1, name: 'test' }, mockProviders.map((p) => ({ http: p.getBaseUrl() })), [], { fallbackStaggerMs: 50 });
this.mockProviders = mockProviders;
}
testGetCombinedProviderError(errors, fallbackMsg) {
return this.getCombinedProviderError(errors, fallbackMsg);
}
async simplePerform(method, reqId) {
return this.performWithFallback(method, {}, this.mockProviders, reqId);
}
}
class ProviderError extends Error {
reason;
code;
data;
constructor(message, code, data) {
super(message);
this.reason = message;
this.code = code;
this.data = data;
}
}
describe('SmartProvider', () => {
let provider;
beforeEach(() => {
provider = new TestableSmartProvider([MockProvider.success('success')]);
});
describe('getCombinedProviderError', () => {
const blockchainErrorTestCases = [
{
code: EthersError.INSUFFICIENT_FUNDS,
message: 'insufficient funds for intrinsic transaction cost',
},
{
code: EthersError.UNPREDICTABLE_GAS_LIMIT,
message: 'execution reverted: ERC20: transfer to the zero address',
},
{
code: EthersError.CALL_EXCEPTION,
message: 'execution reverted',
data: '0x08c379a0', // Must have revert data to be permanent error
},
{
code: EthersError.NONCE_EXPIRED,
message: 'nonce has already been used',
},
{
code: EthersError.REPLACEMENT_UNDERPRICED,
message: 'replacement transaction underpriced',
},
{
code: EthersError.TRANSACTION_REPLACED,
message: 'transaction was replaced',
},
];
blockchainErrorTestCases.forEach(({ code, message, data }) => {
it(`throws BlockchainError with isRecoverable=false for ${code}`, () => {
const error = new ProviderError(message, code, data);
const CombinedError = provider.testGetCombinedProviderError([error], 'Test fallback message');
const e = new CombinedError();
expect(e).to.be.instanceOf(BlockchainError);
expect(e.isRecoverable).to.equal(false);
expect(e.message).to.equal(message);
expect(e.cause).to.equal(error);
expect(e.cause.code).to.equal(code);
});
});
it('throws regular Error for SERVER_ERROR (not BlockchainError)', () => {
const error = new ProviderError('connection refused', EthersError.SERVER_ERROR);
const CombinedError = provider.testGetCombinedProviderError([error], 'Test fallback message');
const e = new CombinedError();
expect(e).to.be.instanceOf(Error);
expect(e).to.not.be.instanceOf(BlockchainError);
expect(e.isRecoverable).to.be.undefined;
expect(e.cause).to.equal(error);
expect(e.cause.code).to.equal(EthersError.SERVER_ERROR);
});
it('throws regular Error for TIMEOUT (not BlockchainError)', () => {
const error = { status: ProviderStatus.Timeout };
const CombinedError = provider.testGetCombinedProviderError([error], 'Test fallback message');
const e = new CombinedError();
expect(e).to.be.instanceOf(Error);
expect(e).to.not.be.instanceOf(BlockchainError);
expect(e.isRecoverable).to.be.undefined;
expect(e.cause).to.equal(error);
});
const mixedErrorTestCases = [
{
name: 'SERVER_ERROR',
errors: () => [
new ProviderError('connection refused', EthersError.SERVER_ERROR),
new ProviderError('execution reverted', EthersError.CALL_EXCEPTION, '0x08c379a0'),
],
expectedMessage: 'execution reverted',
},
{
name: 'TIMEOUT',
errors: () => [
{ status: ProviderStatus.Timeout },
new ProviderError('insufficient funds', EthersError.INSUFFICIENT_FUNDS),
],
expectedMessage: 'insufficient funds',
},
];
mixedErrorTestCases.forEach(({ name, errors, expectedMessage }) => {
it(`prioritizes BlockchainError when mixed with ${name}`, () => {
const [firstError, secondError] = errors();
const CombinedError = provider.testGetCombinedProviderError([firstError, secondError], 'Test fallback message');
const e = new CombinedError();
expect(e).to.be.instanceOf(BlockchainError);
expect(e.isRecoverable).to.equal(false);
expect(e.message).to.equal(expectedMessage);
expect(e.cause).to.equal(secondError);
});
});
it('treats CALL_EXCEPTION without revert data as recoverable (not BlockchainError)', () => {
// CALL_EXCEPTION without data is likely an RPC issue, not a real revert
const error = new ProviderError('execution reverted', EthersError.CALL_EXCEPTION);
const CombinedError = provider.testGetCombinedProviderError([error], 'Test fallback message');
const e = new CombinedError();
// Without revert data, this should NOT be a BlockchainError
expect(e).to.be.instanceOf(Error);
expect(e).to.not.be.instanceOf(BlockchainError);
// Falls through to generic error handler (unhandled case)
expect(e.message).to.equal('Test fallback message');
});
it('treats CALL_EXCEPTION with empty "0x" data as recoverable (not BlockchainError)', () => {
// ethers sets data to "0x" when there's no actual revert data
const error = new ProviderError('execution reverted', EthersError.CALL_EXCEPTION, '0x');
const CombinedError = provider.testGetCombinedProviderError([error], 'Test fallback message');
const e = new CombinedError();
// With empty "0x" data, this should NOT be a BlockchainError
expect(e).to.be.instanceOf(Error);
expect(e).to.not.be.instanceOf(BlockchainError);
// Falls through to generic error handler (unhandled case)
expect(e.message).to.equal('Test fallback message');
});
});
describe('performWithFallback', () => {
it('returns success from first provider, second provider not called', async () => {
const provider1 = MockProvider.success('success1');
const provider2 = MockProvider.success('success2');
const provider = new TestableSmartProvider([provider1, provider2]);
const result = await provider.simplePerform('getBlockNumber', 1);
expect(result).to.deep.equal('success1');
expect(provider1.called).to.be.true;
expect(provider2.called).to.be.false;
});
it('calls second provider when first throws server error, returns success from second', async () => {
const serverError = new ProviderError('connection refused', EthersError.SERVER_ERROR);
const provider1 = MockProvider.error(serverError);
const provider2 = MockProvider.success('success2');
const provider = new TestableSmartProvider([provider1, provider2]);
const result = await provider.simplePerform('getBlockNumber', 1);
expect(result).to.deep.equal('success2');
expect(provider1.called).to.be.true;
expect(provider1.thrownError).to.equal(serverError);
expect(provider2.called).to.be.true;
});
it('calls second provider when first times out, returns success from second', async () => {
const provider1 = MockProvider.success('success1', 100);
const provider2 = MockProvider.success('success2');
const provider = new TestableSmartProvider([provider1, provider2]);
const result = await provider.simplePerform('getBlockNumber', 1);
expect(result).to.deep.equal('success2');
expect(provider1.called).to.be.true;
expect(provider2.called).to.be.true;
});
it('both providers timeout, first provider ultimately returns result (waitForProviderSuccess)', async () => {
const provider1 = MockProvider.success('success1', 120); // 120ms delay
const provider2 = MockProvider.success('success2', 200); // 200ms delay
const provider = new TestableSmartProvider([provider1, provider2]);
const result = await provider.simplePerform('getBlockNumber', 1);
expect(result).to.deep.equal('success1');
expect(provider1.called).to.be.true;
expect(provider2.called).to.be.true;
});
it('both providers throw errors, combined error is thrown', async () => {
const serverError1 = new ProviderError('connection refused 1', EthersError.SERVER_ERROR);
const serverError2 = new ProviderError('connection refused 2', EthersError.SERVER_ERROR);
const provider1 = MockProvider.error(serverError1);
const provider2 = MockProvider.error(serverError2);
const provider = new TestableSmartProvider([provider1, provider2]);
try {
await provider.simplePerform('getBlockNumber', 1);
expect.fail('Should have thrown an error');
}
catch (e) {
expect(e).to.be.instanceOf(Error);
expect(e).to.not.be.instanceOf(BlockchainError);
expect(e.isRecoverable).to.be.undefined;
expect(e.cause).to.equal(serverError1); // First error should be the cause
expect(provider1.called).to.be.true;
expect(provider1.thrownError).to.equal(serverError1);
expect(provider2.called).to.be.true;
expect(provider2.thrownError).to.equal(serverError2);
}
});
it('both providers timeout, combined timeout error is thrown', async () => {
const provider1 = MockProvider.success('success1', 2000);
const provider2 = MockProvider.success('success2', 2000);
const provider = new TestableSmartProvider([provider1, provider2]);
try {
await provider.simplePerform('getBlockNumber', 1);
expect.fail('Should have thrown an error');
}
catch (e) {
expect(e).to.be.instanceOf(Error);
expect(e).to.not.be.instanceOf(BlockchainError);
expect(e.isRecoverable).to.be.undefined;
expect(e.message).to.include('All providers timed out');
expect(provider1.called).to.be.true;
expect(provider2.called).to.be.true;
}
});
it('blockchain error with revert data stops trying additional providers immediately', async () => {
const blockchainError = new ProviderError('execution reverted', EthersError.CALL_EXCEPTION, '0x08c379a0');
const provider1 = MockProvider.error(blockchainError);
const provider2 = MockProvider.success('success2');
const provider = new TestableSmartProvider([provider1, provider2]);
try {
await provider.simplePerform('getBlockNumber', 1);
expect.fail('Should have thrown an error');
}
catch (e) {
expect(e).to.be.instanceOf(BlockchainError);
expect(e.isRecoverable).to.equal(false);
expect(e.message).to.equal('execution reverted');
expect(e.cause).to.equal(blockchainError);
expect(provider1.called).to.be.true;
expect(provider1.thrownError).to.equal(blockchainError);
expect(provider2.called).to.be.false; // Key test - second provider should NOT be called
}
});
it('blockchain error takes priority over server error in actual flow', async () => {
const serverError = new ProviderError('connection refused', EthersError.SERVER_ERROR);
const blockchainError = new ProviderError('insufficient funds', EthersError.INSUFFICIENT_FUNDS);
const provider1 = MockProvider.error(serverError);
const provider2 = MockProvider.error(blockchainError);
const provider = new TestableSmartProvider([provider1, provider2]);
try {
await provider.simplePerform('getBlockNumber', 1);
expect.fail('Should have thrown an error');
}
catch (e) {
expect(e).to.be.instanceOf(BlockchainError); // Should get blockchain error, not server error
expect(e.isRecoverable).to.equal(false);
expect(e.message).to.equal('insufficient funds');
expect(e.cause).to.equal(blockchainError);
expect(provider1.called).to.be.true;
expect(provider1.thrownError).to.equal(serverError);
expect(provider2.called).to.be.true;
expect(provider2.thrownError).to.equal(blockchainError);
}
});
it('CALL_EXCEPTION without revert data triggers fallback to next provider', async () => {
// CALL_EXCEPTION without data is likely an RPC issue, should retry
const callExceptionNoData = new ProviderError('execution reverted', EthersError.CALL_EXCEPTION);
const provider1 = MockProvider.error(callExceptionNoData);
const provider2 = MockProvider.success('success2');
const provider = new TestableSmartProvider([provider1, provider2]);
const result = await provider.simplePerform('getBlockNumber', 1);
// Should succeed from second provider
expect(result).to.deep.equal('success2');
expect(provider1.called).to.be.true;
expect(provider1.thrownError).to.equal(callExceptionNoData);
expect(provider2.called).to.be.true; // Key test - second provider SHOULD be called
});
it('CALL_EXCEPTION with empty "0x" data triggers fallback to next provider', async () => {
// ethers sets data to "0x" when there's no actual revert data
const callExceptionEmptyData = new ProviderError('execution reverted', EthersError.CALL_EXCEPTION, '0x');
const provider1 = MockProvider.error(callExceptionEmptyData);
const provider2 = MockProvider.success('success2');
const provider = new TestableSmartProvider([provider1, provider2]);
const result = await provider.simplePerform('getBlockNumber', 1);
// Should succeed from second provider
expect(result).to.deep.equal('success2');
expect(provider1.called).to.be.true;
expect(provider1.thrownError).to.equal(callExceptionEmptyData);
expect(provider2.called).to.be.true; // Key test - second provider SHOULD be called
});
});
});
//# sourceMappingURL=SmartProvider.test.js.map