UNPKG

@hyperlane-xyz/sdk

Version:

The official SDK for the Hyperlane Network

526 lines 28.3 kB
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