UNPKG

@hyperlane-xyz/sdk

Version:

The official SDK for the Hyperlane Network

368 lines 15.6 kB
import { expect } from 'chai'; import { BigNumber } from 'ethers'; import sinon from 'sinon'; import { InterchainAccountRouter__factory } from '@hyperlane-xyz/core'; import { formatStandardHookMetadata } from '@hyperlane-xyz/utils'; import { TestChainName } from '../../consts/testChains.js'; import { MultiProvider } from '../../providers/MultiProvider.js'; import { randomAddress } from '../../test/testUtils.js'; import { InterchainAccount } from './InterchainAccount.js'; import { PostCallsSchema, commitmentFromRevealMessage } from './icaCalls.js'; describe('commitmentFromRevealMessage', () => { // https://explorer.hyperlane.xyz/message/0xd123b9eb8fc8777adf50963b2ad283f05332c584a1e4002f9e4ad21bdafea069 const REVEAL_MESSAGE = '0x0200000000000000000000000000000000000000000000000000000000000000002cd4f1bbd58a9c7fc481e3b8d319cea8795011b9dde770fa122c2e585fa01f69'; const COMMITMENT = '0x2cd4f1bbd58a9c7fc481e3b8d319cea8795011b9dde770fa122c2e585fa01f69'; describe('Valid inputs', () => { it('should extract commitment from a valid 65-byte message', () => { const result = commitmentFromRevealMessage(REVEAL_MESSAGE); expect(result).to.equal(COMMITMENT); }); }); describe('Invalid inputs - should throw', () => { it('should throw when message is too short (< 65 bytes)', () => { const shortMessage = REVEAL_MESSAGE.slice(0, 2 + 64 * 2); expect(() => commitmentFromRevealMessage(shortMessage)).to.throw('Invalid reveal message: expected at least 65 bytes, got 64 bytes'); }); }); }); const basePayload = { calls: [ { to: '0x' + 'ab'.repeat(20), data: '0x', value: '0', }, ], relayers: ['0x' + 'cd'.repeat(20)], salt: '0x' + '00'.repeat(32), originDomain: 1, }; const icaPayload = (overrides = {}) => ({ ...basePayload, destinationDomain: 2, owner: '0x' + 'aa'.repeat(20), ...overrides, }); const legacyPayload = (overrides = {}) => ({ ...basePayload, commitmentDispatchTx: '0x' + 'ef'.repeat(32), ...overrides, }); describe('PostCallsSchema', () => { it('accepts new ICA shape with destinationDomain + owner', () => { const result = PostCallsSchema.safeParse(icaPayload()); expect(result.success).to.be.true; }); it('accepts legacy shape with commitmentDispatchTx', () => { const result = PostCallsSchema.safeParse(legacyPayload()); expect(result.success).to.be.true; }); it('rejects payload missing both discriminants', () => { const result = PostCallsSchema.safeParse(basePayload); expect(result.success).to.be.false; }); it('accepts valid bytes32 address', () => { const result = PostCallsSchema.safeParse(icaPayload({ calls: [{ to: '0x' + 'ab'.repeat(32), data: '0x', value: '0' }], })); expect(result.success).to.be.true; }); it('rejects empty string to address', () => { const result = PostCallsSchema.safeParse(icaPayload({ calls: [{ to: '', data: '0x', value: '0' }], })); expect(result.success).to.be.false; }); it('rejects URL as to address', () => { const result = PostCallsSchema.safeParse(icaPayload({ calls: [{ to: 'http://evil.com', data: '0x', value: '0' }], })); expect(result.success).to.be.false; }); it('rejects SQL injection in to address', () => { const result = PostCallsSchema.safeParse(icaPayload({ calls: [{ to: "'; DROP TABLE--", data: '0x', value: '0' }], })); expect(result.success).to.be.false; }); it('rejects prototype pollution in to address', () => { const result = PostCallsSchema.safeParse(icaPayload({ calls: [{ to: '__proto__', data: '0x', value: '0' }], })); expect(result.success).to.be.false; }); it('rejects invalid relayer address', () => { const result = PostCallsSchema.safeParse(icaPayload({ relayers: ['not-an-address'] })); expect(result.success).to.be.false; }); }); describe('InterchainAccount.getCallRemote', () => { const defaultGasLimit = BigNumber.from(50_000); const chain = TestChainName.test1; const destination = TestChainName.test2; let sandbox; let multiProvider; let app; let mockLocalRouter; beforeEach(() => { sandbox = sinon.createSandbox(); multiProvider = MultiProvider.createTestMultiProvider(); sandbox.stub(multiProvider, 'getSigner').returns({}); const contractsMap = { [chain]: { interchainAccountRouter: { address: randomAddress() } }, [destination]: { interchainAccountRouter: { address: randomAddress() } }, }; app = new InterchainAccount(contractsMap, multiProvider); mockLocalRouter = { ['quoteGasPayment(uint32,uint256)']: sandbox .stub() .resolves(BigNumber.from(123)), ['quoteGasPayment(uint32)']: sandbox.stub().resolves(BigNumber.from(456)), populateTransaction: { ['callRemoteWithOverrides(uint32,bytes32,bytes32,(bytes32,uint256,bytes)[],bytes)']: sandbox.stub().resolves({ to: randomAddress(), data: '0x', value: BigNumber.from(0), }), }, }; sandbox .stub(InterchainAccountRouter__factory, 'connect') .returns(mockLocalRouter); }); afterEach(() => { sandbox.restore(); }); const baseConfig = { origin: chain, owner: randomAddress(), localRouter: randomAddress(), routerOverride: randomAddress(), ismOverride: randomAddress(), }; const baseCalls = [{ to: randomAddress(), data: '0x', value: '0' }]; it('uses IGP default gas when hookMetadata is missing', async () => { await app.getCallRemote({ chain, destination, innerCalls: baseCalls, config: baseConfig, }); sinon.assert.calledOnce(mockLocalRouter['quoteGasPayment(uint32,uint256)']); const [, gasLimit] = mockLocalRouter['quoteGasPayment(uint32,uint256)'].getCall(0).args; expect(gasLimit.toNumber()).to.equal(defaultGasLimit.toNumber()); }); it('uses gasLimit from StandardHookMetadata when provided', async () => { const gasLimit = 123456n; const hookMetadata = formatStandardHookMetadata({ refundAddress: randomAddress(), gasLimit, }); await app.getCallRemote({ chain, destination, innerCalls: baseCalls, config: baseConfig, hookMetadata, }); const [, gasLimitArg] = mockLocalRouter['quoteGasPayment(uint32,uint256)'].getCall(0).args; expect(gasLimitArg.toString()).to.equal(BigNumber.from(gasLimit).toString()); }); it('falls back to IGP default gas on malformed metadata', async () => { await app.getCallRemote({ chain, destination, innerCalls: baseCalls, config: baseConfig, hookMetadata: '0xZZ', }); const [, gasLimit] = mockLocalRouter['quoteGasPayment(uint32,uint256)'].getCall(0).args; expect(gasLimit.toNumber()).to.equal(defaultGasLimit.toNumber()); }); it('falls back to mailbox quoteDispatch when v2 quote fails', async () => { mockLocalRouter['quoteGasPayment(uint32,uint256)'].rejects(new Error('legacy router')); // Add mailbox stub for legacy fallback path const mockMailboxAddress = randomAddress(); mockLocalRouter.mailbox = sandbox.stub().resolves(mockMailboxAddress); // Create a mock provider with proper call responses for mailbox contract const defaultHookAddress = randomAddress(); const mockProvider = { _isProvider: true, // Respond to defaultHook() call - returns address // Respond to quoteDispatch() call - returns uint256 call: sandbox.stub().callsFake((tx) => { // defaultHook() selector: 0x... if (tx.data?.startsWith('0x3a871cdd')) { // Return encoded address return Promise.resolve('0x000000000000000000000000' + defaultHookAddress.slice(2)); } // quoteDispatch() - return encoded uint256 return Promise.resolve('0x0000000000000000000000000000000000000000000000000000000000000315'); }), getNetwork: sandbox.stub().resolves({ chainId: 1, name: 'test' }), }; sandbox.stub(multiProvider, 'getProvider').returns(mockProvider); await app.getCallRemote({ chain, destination, innerCalls: baseCalls, config: baseConfig, }); // Verify the fallback path was taken sinon.assert.calledOnce(mockLocalRouter.mailbox); }); it('calls isms() with origin domain when ismOverride not provided', async () => { const destRouterAddress = randomAddress(); const mockDestRouter = { isms: sandbox.stub().resolves(randomAddress()), }; // Restore the factory stub and recreate with address-aware logic InterchainAccountRouter__factory.connect.restore(); sandbox .stub(InterchainAccountRouter__factory, 'connect') .callsFake((address) => { if (address === destRouterAddress) { return mockDestRouter; } return mockLocalRouter; }); const configWithoutIsmOverride = { origin: chain, owner: randomAddress(), localRouter: randomAddress(), routerOverride: destRouterAddress, }; await app.getCallRemote({ chain, destination, innerCalls: baseCalls, config: configWithoutIsmOverride, }); const originDomain = multiProvider.getDomainId(chain); sinon.assert.calledWith(mockDestRouter.isms, originDomain); }); }); describe('InterchainAccount.estimateIcaHandleGas', () => { const chain = TestChainName.test1; const destination = TestChainName.test2; const ICA_OVERHEAD = BigNumber.from(50_000); const PER_CALL_OVERHEAD = BigNumber.from(5_000); const PER_CALL_FALLBACK = BigNumber.from(50_000); const ICA_HANDLE_GAS_FALLBACK = BigNumber.from(200_000); let sandbox; let multiProvider; let app; let mockDestRouter; let mockProvider; beforeEach(() => { sandbox = sinon.createSandbox(); multiProvider = MultiProvider.createTestMultiProvider(); mockProvider = { estimateGas: sandbox.stub(), }; mockDestRouter = { address: randomAddress(), isms: sandbox.stub().resolves(randomAddress()), mailbox: sandbox.stub().resolves(randomAddress()), routers: sandbox.stub().resolves(randomAddress()), estimateGas: { handle: sandbox.stub(), }, }; const mockOriginRouter = { address: randomAddress(), connect: sandbox.stub().returnsThis(), }; // Create contractsMap - origin needs connect() for constructor processing const contractsMap = {}; contractsMap[chain] = { interchainAccountRouter: mockOriginRouter }; contractsMap[destination] = { interchainAccountRouter: mockDestRouter }; // Mock connect() to return self (required by connectContracts) mockDestRouter.connect = sandbox.stub().returns(mockDestRouter); app = new InterchainAccount(contractsMap, multiProvider); // Stub getProvider after app creation to avoid affecting constructor sandbox.stub(multiProvider, 'getProvider').returns(mockProvider); }); afterEach(() => { sandbox.restore(); }); const baseConfig = { origin: chain, owner: randomAddress(), localRouter: randomAddress(), }; const baseCalls = [ { to: randomAddress(), data: '0x1234', value: '0' }, { to: randomAddress(), data: '0x5678', value: '0' }, ]; it('returns buffered handle() estimate when it succeeds', async () => { const handleEstimate = BigNumber.from(100_000); mockDestRouter.estimateGas.handle.resolves(handleEstimate); const result = await app.estimateIcaHandleGas({ origin: chain, destination, innerCalls: baseCalls, config: baseConfig, }); // addBufferToGasLimit adds 10% const expectedWithBuffer = handleEstimate.mul(110).div(100); expect(result.toString()).to.equal(expectedWithBuffer.toString()); }); it('falls back to individual estimation when handle() fails', async () => { mockDestRouter.estimateGas.handle.rejects(new Error('handle failed')); // Individual call estimates const call1Estimate = BigNumber.from(30_000); const call2Estimate = BigNumber.from(40_000); mockProvider.estimateGas .onFirstCall() .resolves(call1Estimate) .onSecondCall() .resolves(call2Estimate); const result = await app.estimateIcaHandleGas({ origin: chain, destination, innerCalls: baseCalls, config: baseConfig, }); // Total = calls + ICA overhead + per-call overhead const callsTotal = call1Estimate.add(call2Estimate); const overhead = ICA_OVERHEAD.add(PER_CALL_OVERHEAD.mul(2)); const expectedBeforeBuffer = callsTotal.add(overhead); const expectedWithBuffer = expectedBeforeBuffer.mul(110).div(100); expect(result.toString()).to.equal(expectedWithBuffer.toString()); }); it('uses per-call fallback when individual call estimation fails', async () => { mockDestRouter.estimateGas.handle.rejects(new Error('handle failed')); // First call succeeds, second fails (uses per-call fallback) const call1Estimate = BigNumber.from(30_000); mockProvider.estimateGas .onFirstCall() .resolves(call1Estimate) .onSecondCall() .rejects(new Error('call failed')); const result = await app.estimateIcaHandleGas({ origin: chain, destination, innerCalls: baseCalls, config: baseConfig, }); // Second call uses 50k fallback const callsTotal = call1Estimate.add(PER_CALL_FALLBACK); const overhead = ICA_OVERHEAD.add(PER_CALL_OVERHEAD.mul(2)); const expectedBeforeBuffer = callsTotal.add(overhead); const expectedWithBuffer = expectedBeforeBuffer.mul(110).div(100); expect(result.toString()).to.equal(expectedWithBuffer.toString()); }); it('returns static 200k fallback when getProvider fails', async () => { mockDestRouter.estimateGas.handle.rejects(new Error('handle failed')); multiProvider.getProvider.throws(new Error('provider error')); const result = await app.estimateIcaHandleGas({ origin: chain, destination, innerCalls: baseCalls, config: baseConfig, }); expect(result.toString()).to.equal(ICA_HANDLE_GAS_FALLBACK.toString()); }); }); //# sourceMappingURL=InterchainAccount.test.js.map