@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
526 lines • 28.3 kB
JavaScript
import { expect } from 'chai';
import { errors as EthersError, providers } from 'ethers';
import { AllProviderMethods, ProviderMethod, } from './ProviderMethods.js';
import { BlockchainError, HyperlaneSmartProvider } from './SmartProvider.js';
import { ProviderStatus } from './types.js';
import { isMissingSelectorCallException } from '../../utils/contract.js';
// Dummy provider for testing
class MockProvider extends providers.BaseProvider {
baseUrl;
errorToThrow;
successValue;
responseDelayMs;
supportedMethods = AllProviderMethods;
called = false;
callCount = 0;
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;
this.callCount += 1;
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 RetrySpySmartProvider extends HyperlaneSmartProvider {
performWithFallbackCallCount = 0;
constructor() {
super({ chainId: 1, name: 'test' }, [{ http: 'http://provider' }], [], {
maxRetries: 3,
baseRetryDelayMs: 1,
fallbackStaggerMs: 1,
});
}
async performWithFallback(_method, _params, _providers, _reqId) {
this.performWithFallbackCallCount += 1;
throw new ProviderError('connection refused', EthersError.SERVER_ERROR);
}
}
class ProviderError extends Error {
reason;
code;
data;
error;
constructor(message, code, data, options) {
super(message);
this.reason = message;
this.code = code;
this.data = data;
// Simulate ethers nested error structure for JSON-RPC errors
if (options?.jsonRpcErrorCode !== undefined) {
this.error = { error: { code: options.jsonRpcErrorCode } };
}
else if (options?.hasNestedError) {
// Has nested error but no JSON-RPC code (e.g., RPC connection issue)
this.error = { error: {} };
}
// If neither is set, error remains undefined (empty return decode failure)
}
}
describe('SmartProvider', () => {
let provider;
beforeEach(() => {
provider = new TestableSmartProvider([MockProvider.success('success')]);
});
describe('custom_rpc_header handling', () => {
it('merges custom headers into existing connection and preserves fields', () => {
const rawUrl = 'http://example.com/path?custom_rpc_header=Authorization:token&foo=bar';
const provider = new HyperlaneSmartProvider({ chainId: 1, name: 'test' }, [
{
http: rawUrl,
connection: {
url: rawUrl,
timeout: 1234,
headers: { 'X-Test': 'abc' },
},
},
], []);
const rpcConfig = provider.rpcProviders[0].rpcConfig;
const expectedUrl = new URL('http://example.com/path?foo=bar').toString();
expect(rpcConfig.http).to.equal(expectedUrl);
expect(rpcConfig.connection?.url).to.equal(expectedUrl);
expect(rpcConfig.connection?.timeout).to.equal(1234);
expect(rpcConfig.connection?.headers).to.deep.equal({
'X-Test': 'abc',
Authorization: '[REDACTED]',
});
});
it('preserves existing connection url when different and merges headers', () => {
const rawUrl = 'http://example.com/path?custom_rpc_header=Authorization:new';
const provider = new HyperlaneSmartProvider({ chainId: 1, name: 'test' }, [
{
http: rawUrl,
connection: {
url: 'http://other.example.com/path',
timeout: 5678,
headers: { Authorization: 'old', 'X-Test': 'abc' },
},
},
], []);
const rpcConfig = provider.rpcProviders[0].rpcConfig;
expect(rpcConfig.connection?.url).to.equal('http://other.example.com/path');
expect(rpcConfig.connection?.timeout).to.equal(5678);
expect(rpcConfig.connection?.headers).to.deep.equal({
Authorization: '[REDACTED]',
'X-Test': 'abc',
});
});
it('handles multiple custom_rpc_header params', () => {
const rawUrl = 'http://example.com/path?custom_rpc_header=Authorization:Bearer%20token&custom_rpc_header=X-Api-Key:secret123';
const provider = new HyperlaneSmartProvider({ chainId: 1, name: 'test' }, [{ http: rawUrl }], []);
const rpcConfig = provider.rpcProviders[0].rpcConfig;
expect(rpcConfig.http).to.equal('http://example.com/path');
expect(rpcConfig.connection?.headers).to.deep.equal({
Authorization: '[REDACTED]',
'X-Api-Key': '[REDACTED]',
});
});
it('silently skips malformed headers without colon', () => {
const rawUrl = 'http://example.com/path?custom_rpc_header=MalformedNoColon&custom_rpc_header=Valid:header';
const provider = new HyperlaneSmartProvider({ chainId: 1, name: 'test' }, [{ http: rawUrl }], []);
const rpcConfig = provider.rpcProviders[0].rpcConfig;
expect(rpcConfig.http).to.equal('http://example.com/path');
// Malformed header silently ignored, only valid one present
expect(rpcConfig.connection?.headers).to.deep.equal({
Valid: '[REDACTED]',
});
});
it('passes through URL unchanged when no custom_rpc_header present', () => {
const rawUrl = 'http://example.com/path?foo=bar&baz=qux';
const provider = new HyperlaneSmartProvider({ chainId: 1, name: 'test' }, [{ http: rawUrl }], []);
const rpcConfig = provider.rpcProviders[0].rpcConfig;
expect(rpcConfig.http).to.equal(rawUrl);
expect(rpcConfig.connection).to.be.undefined;
});
it('last duplicate header wins (like Rust behavior)', () => {
const rawUrl = 'http://example.com/path?custom_rpc_header=Authorization:first&custom_rpc_header=Authorization:second';
const provider = new HyperlaneSmartProvider({ chainId: 1, name: 'test' }, [{ http: rawUrl }], []);
const rpcConfig = provider.rpcProviders[0].rpcConfig;
// rpcConfig has redacted headers for logging safety
expect(rpcConfig.connection?.headers?.['Authorization']).to.equal('[REDACTED]');
// Actual connection (used for requests) has real value - last duplicate wins
const actualConnection = provider.rpcProviders[0].connection;
expect(actualConnection.headers?.['Authorization']).to.equal('second');
});
});
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 nested error as permanent (BlockchainError)', () => {
// CALL_EXCEPTION without nested error means ethers failed to decode empty return data
// This is permanent - retrying won't help since the contract doesn't have this method
const error = new ProviderError('call revert exception', EthersError.CALL_EXCEPTION, '0x');
const CombinedError = provider.testGetCombinedProviderError([error], 'Test fallback message');
const e = new CombinedError();
// Without nested error, this IS a BlockchainError (decode failure is permanent)
expect(e).to.be.instanceOf(BlockchainError);
expect(e.isRecoverable).to.equal(false);
});
it('treats CALL_EXCEPTION with nested RPC error (not code 3) as recoverable', () => {
// CALL_EXCEPTION with nested error but not code 3 is likely an RPC issue
const error = new ProviderError('execution reverted', EthersError.CALL_EXCEPTION, '0x', // Empty data
{ hasNestedError: true });
const CombinedError = provider.testGetCombinedProviderError([error], 'Test fallback message');
const e = new CombinedError();
// With nested error but no code 3, 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('preserves unhandled provider errors as causes', () => {
const error = new Error('Invalid response from provider');
const CombinedError = provider.testGetCombinedProviderError([error], 'Test fallback message');
const e = new CombinedError();
expect(e).to.be.instanceOf(Error);
expect(e.cause).to.equal(error);
expect(isMissingSelectorCallException(e)).to.equal(true);
});
it('uses the most diagnostic unhandled provider error as the cause', () => {
const genericError = new Error('generic provider error');
const emptyResponseError = new Error('Invalid response from provider');
const CombinedError = provider.testGetCombinedProviderError([genericError, emptyResponseError], 'Test fallback message');
const e = new CombinedError();
expect(e).to.be.instanceOf(Error);
expect(e.cause).to.equal(emptyResponseError);
expect(isMissingSelectorCallException(e)).to.equal(true);
});
it('treats CALL_EXCEPTION with JSON-RPC error code 3 as permanent (BlockchainError)', () => {
// JSON-RPC error code 3 definitively indicates execution revert (EIP-1474)
// Even without revert data, this is a real contract revert
const error = new ProviderError('execution reverted', EthersError.CALL_EXCEPTION, undefined, // No revert data
{ jsonRpcErrorCode: 3 });
const CombinedError = provider.testGetCombinedProviderError([error], 'Test fallback message');
const e = new CombinedError();
// With JSON-RPC code 3, this SHOULD be a BlockchainError
expect(e).to.be.instanceOf(BlockchainError);
expect(e.isRecoverable).to.equal(false);
expect(e.message).to.equal('execution reverted');
expect(e.cause).to.equal(error);
});
});
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 nested error stops trying additional providers', async () => {
// CALL_EXCEPTION without nested error means ethers decode failure - permanent
const callExceptionNoNestedError = new ProviderError('call revert exception', EthersError.CALL_EXCEPTION, '0x');
const provider1 = MockProvider.error(callExceptionNoNestedError);
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(provider1.called).to.be.true;
expect(provider2.called).to.be.false; // Key test - second provider should NOT be called
}
});
it('CALL_EXCEPTION with nested RPC error triggers fallback to next provider', async () => {
// CALL_EXCEPTION with nested error but not code 3 is an RPC issue, should retry
const callExceptionWithNestedError = new ProviderError('execution reverted', EthersError.CALL_EXCEPTION, '0x', // Empty data
{ hasNestedError: true });
const provider1 = MockProvider.error(callExceptionWithNestedError);
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(callExceptionWithNestedError);
expect(provider2.called).to.be.true; // Key test - second provider SHOULD be called
});
it('CALL_EXCEPTION with JSON-RPC error code 3 stops trying additional providers', async () => {
// JSON-RPC error code 3 definitively indicates execution revert (EIP-1474)
// Even without revert data, this should NOT trigger fallback
const callExceptionJsonRpcCode3 = new ProviderError('execution reverted', EthersError.CALL_EXCEPTION, undefined, // No revert data
{ jsonRpcErrorCode: 3 });
const provider1 = MockProvider.error(callExceptionJsonRpcCode3);
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(callExceptionJsonRpcCode3);
expect(provider1.called).to.be.true;
expect(provider1.thrownError).to.equal(callExceptionJsonRpcCode3);
expect(provider2.called).to.be.false; // Key test - second provider should NOT be called
}
});
it('sendTransaction bypasses retryAsync to prevent duplicate submissions', async () => {
const smartProvider = new RetrySpySmartProvider();
// Call perform() (the public entry point) so the SendTransaction bypass in perform() is exercised.
// RetrySpySmartProvider.performWithFallback always throws, so if retryAsync
// were wrapping it, the call count would be > 1 (maxRetries=3).
let threw = false;
try {
await smartProvider.perform(ProviderMethod.SendTransaction, {
signedTransaction: '0x02',
});
}
catch {
threw = true;
}
expect(threw, 'perform should have thrown').to.be.true;
// performWithFallback should be called exactly once (no retryAsync wrapping)
expect(smartProvider.performWithFallbackCallCount).to.equal(1);
});
it('sendTransaction waits for provider instead of racing against timeout', async () => {
// Provider responds slowly (longer than fallbackStaggerMs of 50ms)
const provider1 = MockProvider.success('tx-hash', 200);
const provider2 = MockProvider.success('tx-hash-2');
const smartProvider = new TestableSmartProvider([provider1, provider2]);
const result = await smartProvider.simplePerform(ProviderMethod.SendTransaction, 1);
// Should wait for the slow provider instead of timing out and trying provider2
expect(result).to.equal('tx-hash');
expect(provider1.called).to.be.true;
expect(provider1.callCount).to.equal(1);
expect(provider2.called).to.be.false;
});
});
});
//# sourceMappingURL=SmartProvider.test.js.map