UNPKG

@hyperlane-xyz/sdk

Version:

The official SDK for the Hyperlane Network

1,081 lines 58.6 kB
import { expect } from 'chai'; import hre from 'hardhat'; import sinon from 'sinon'; import { zeroAddress } from 'viem'; import { CrossCollateralRouter__factory, ERC20Test__factory, ERC4626Test__factory, FiatTokenTest__factory, HypERC20__factory, ISafe__factory, Mailbox__factory, MockCircleMessageTransmitter__factory, MockCircleTokenMessenger__factory, MockEverclearAdapter__factory, MockWETH__factory, MovableCollateralRouter__factory, PackageVersioned__factory, ProxyAdmin__factory, TokenRouter__factory, TokenBridgeDepositAddress__factory, XERC20LockboxTest__factory, XERC20Test__factory, } from '@hyperlane-xyz/core'; import { buildArtifact as coreBuildArtifact } from '@hyperlane-xyz/core/buildArtifact.js'; import { ContractVerifier, ExplorerLicenseType, TestChainName, TokenFeeType, normalizeConfig, proxyAdmin, proxyImplementation, test3, } from '@hyperlane-xyz/sdk'; import { addressToBytes32, assert, randomInt } from '@hyperlane-xyz/utils'; import { TestCoreDeployer } from '../core/TestCoreDeployer.js'; import { HyperlaneProxyFactoryDeployer } from '../deploy/HyperlaneProxyFactoryDeployer.js'; import { VerifyContractTypes } from '../deploy/verify/types.js'; import { BPS, HALF_AMOUNT, MAX_FEE, } from '../fee/EvmTokenFeeReader.hardhat-test.js'; import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js'; import { MultiProvider } from '../providers/MultiProvider.js'; import { EvmWarpRouteReader, TOKEN_FEE_CONTRACT_VERSION, } from './EvmWarpRouteReader.js'; import { TokenType } from './config.js'; import { HypERC20Deployer } from './deploy.js'; import { ContractVerificationStatus, OwnerStatus, derivedIsmAddress, } from './types.js'; describe('EvmWarpRouteReader', async () => { const TOKEN_NAME = 'fake'; const TOKEN_SUPPLY = '100000000000000000000'; const TOKEN_DECIMALS = 18; const GAS = 65_000; const chain = TestChainName.test4; let ismFactory; let factories; let erc20Factory; let token; let wethMockFactory; let weth; let signer; let deployer; let contractVerifier; let multiProvider; let coreApp; let routerConfigMap; let baseConfig; let mailbox; let evmERC20WarpRouteReader; let vault; let collateralFiatToken; let everclearBridgeAdapterMockFactory; let everclearBridgeAdapterMock; let mockCircleTokenMessenger; let mockCircleMessageTransmitter; before(async () => { [signer] = await hre.ethers.getSigners(); multiProvider = MultiProvider.createTestMultiProvider({ signer }); for (const chainName of multiProvider.getKnownChainNames()) { multiProvider.metadata[chainName] = { ...multiProvider.metadata[chainName], blockExplorers: [], }; } const ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider); factories = await ismFactoryDeployer.deploy(multiProvider.mapKnownChains(() => ({}))); ismFactory = new HyperlaneIsmFactory(factories, multiProvider); coreApp = await new TestCoreDeployer(multiProvider, ismFactory).deployApp(); routerConfigMap = coreApp.getRouterConfig(signer.address); erc20Factory = new ERC20Test__factory(signer); token = await erc20Factory.deploy(TOKEN_NAME, TOKEN_NAME, TOKEN_SUPPLY, TOKEN_DECIMALS); wethMockFactory = new MockWETH__factory(signer); weth = await wethMockFactory.deploy(); baseConfig = routerConfigMap[chain]; mailbox = Mailbox__factory.connect(baseConfig.mailbox, signer); deployer = new HypERC20Deployer(multiProvider); const vaultFactory = new ERC4626Test__factory(signer); vault = await vaultFactory.deploy(token.address, TOKEN_NAME, TOKEN_NAME); const fiatCollateralFactory = new FiatTokenTest__factory(signer); collateralFiatToken = await fiatCollateralFactory.deploy(TOKEN_NAME, TOKEN_NAME, TOKEN_SUPPLY, TOKEN_DECIMALS); everclearBridgeAdapterMockFactory = new MockEverclearAdapter__factory(signer); everclearBridgeAdapterMock = await everclearBridgeAdapterMockFactory.deploy(); mockCircleTokenMessenger = await new MockCircleTokenMessenger__factory(signer).deploy(token.address); mockCircleMessageTransmitter = await new MockCircleMessageTransmitter__factory(signer).deploy(token.address); }); beforeEach(async () => { // Reset the MultiProvider and create a new deployer for each test multiProvider = MultiProvider.createTestMultiProvider({ signer }); for (const chainName of multiProvider.getKnownChainNames()) { multiProvider.metadata[chainName] = { ...multiProvider.metadata[chainName], blockExplorers: [], }; } contractVerifier = new ContractVerifier(multiProvider, {}, coreBuildArtifact, ExplorerLicenseType.MIT); evmERC20WarpRouteReader = new EvmWarpRouteReader(multiProvider, chain, 1, contractVerifier); deployer = new HypERC20Deployer(multiProvider); }); it('should derive a token type from contract', async () => { const typesToDerive = [ TokenType.collateral, TokenType.collateralVault, TokenType.synthetic, TokenType.native, ]; await Promise.all(typesToDerive.map(async (type) => { // Create config const config = { [chain]: { type, token: type === TokenType.collateralVault ? vault.address : token.address, hook: await mailbox.defaultHook(), name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: TOKEN_DECIMALS, gas: GAS, ...baseConfig, }, }; // Deploy warp route with config const warpRoute = await deployer.deploy(config); const derivedTokenType = await evmERC20WarpRouteReader.deriveTokenType(warpRoute[chain][type].address); expect(derivedTokenType).to.equal(type); })); }); it('should derive collateral config correctly', async () => { // Create config const config = { [chain]: { type: TokenType.collateral, token: token.address, hook: await mailbox.defaultHook(), interchainSecurityModule: await mailbox.defaultIsm(), ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].collateral.address); for (const [key, value] of Object.entries(derivedConfig)) { const deployedValue = config[chain][key]; if (deployedValue && typeof value === 'string') expect(deployedValue).to.equal(value); } // Check hook because they're potentially objects expect(derivedConfig.hook).to.deep.equal(await evmERC20WarpRouteReader.evmHookReader.deriveHookConfig(config[chain].hook)); // Check ism expect(derivedIsmAddress(derivedConfig)).to.be.equal(await mailbox.defaultIsm()); // Check if token values matches if (derivedConfig.type === TokenType.collateral) { expect(derivedConfig.name).to.equal(TOKEN_NAME); expect(derivedConfig.symbol).to.equal(TOKEN_NAME); expect(derivedConfig.decimals).to.equal(TOKEN_DECIMALS); } }); it('should derive config with non-default scale fraction correctly', async () => { // Create config with a scale fraction (e.g., for downscaling 18 decimals to 6 decimals) const scaleNumerator = 1; const scaleDenominator = 1000000000000; // 10^12 for 18->6 decimal conversion const config = { [chain]: { type: TokenType.synthetic, name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: 6, // Lower decimals than standard 18 scale: { numerator: scaleNumerator, denominator: scaleDenominator, }, hook: await mailbox.defaultHook(), interchainSecurityModule: await mailbox.defaultIsm(), ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if scale is correctly read const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].synthetic.address); // Verify scale is returned as an object with bigint numerator/denominator expect(derivedConfig.scale).to.deep.equal({ numerator: BigInt(scaleNumerator), denominator: BigInt(scaleDenominator), }); // Verify other properties expect(derivedConfig.type).to.equal(TokenType.synthetic); expect(derivedConfig.name).to.equal(TOKEN_NAME); expect(derivedConfig.symbol).to.equal(TOKEN_NAME); expect(derivedConfig.decimals).to.equal(6); }); it('should derive xerc20 config correctly', async () => { // Create token const xerc20Token = await new XERC20Test__factory(signer).deploy(TOKEN_NAME, TOKEN_NAME, TOKEN_SUPPLY, TOKEN_DECIMALS); // Create config const config = { [chain]: { type: TokenType.XERC20, token: xerc20Token.address, hook: await mailbox.defaultHook(), interchainSecurityModule: await mailbox.defaultIsm(), ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].xERC20.address); for (const [key, value] of Object.entries(derivedConfig)) { const deployedValue = config[chain][key]; if (deployedValue && typeof value === 'string') expect(deployedValue).to.equal(value); } // Check hook because they're potentially objects expect(derivedConfig.hook).to.deep.equal(await evmERC20WarpRouteReader.evmHookReader.deriveHookConfig(config[chain].hook)); // Check ism expect(derivedIsmAddress(derivedConfig)).to.be.equal(await mailbox.defaultIsm()); // Check if token values matches if (derivedConfig.type === TokenType.XERC20) { expect(derivedConfig.name).to.equal(TOKEN_NAME); expect(derivedConfig.symbol).to.equal(TOKEN_NAME); expect(derivedConfig.decimals).to.equal(TOKEN_DECIMALS); expect(derivedConfig.token).to.equal(xerc20Token.address); } }); it('should derive xerc20lockbox config correctly', async () => { // Create token const xerc20Lockbox = await new XERC20LockboxTest__factory(signer).deploy(TOKEN_NAME, TOKEN_NAME, TOKEN_SUPPLY, TOKEN_DECIMALS); // Create config const config = { [chain]: { type: TokenType.XERC20Lockbox, token: xerc20Lockbox.address, hook: await mailbox.defaultHook(), interchainSecurityModule: await mailbox.defaultIsm(), ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].xERC20Lockbox.address); for (const [key, value] of Object.entries(derivedConfig)) { const deployedValue = config[chain][key]; if (deployedValue && typeof value === 'string') expect(deployedValue).to.equal(value); } // Check hook because they're potentially objects expect(derivedConfig.hook).to.deep.equal(await evmERC20WarpRouteReader.evmHookReader.deriveHookConfig(config[chain].hook)); // Check ism expect(derivedIsmAddress(derivedConfig)).to.be.equal(await mailbox.defaultIsm()); // Check if token values matches if (derivedConfig.type === TokenType.XERC20) { expect(derivedConfig.name).to.equal(TOKEN_NAME); expect(derivedConfig.symbol).to.equal(TOKEN_NAME); expect(derivedConfig.decimals).to.equal(TOKEN_DECIMALS); expect(derivedConfig.token).to.equal(xerc20Lockbox.address); } }); it('should derive synthetic rebase config correctly', async () => { // Create config const config = { [chain]: { type: TokenType.syntheticRebase, collateralChainName: TestChainName.test4, hook: await mailbox.defaultHook(), name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: TOKEN_DECIMALS, ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].syntheticRebase.address); for (const [key, value] of Object.entries(derivedConfig)) { const deployedValue = config[chain][key]; if (deployedValue && typeof value === 'string') expect(deployedValue).to.equal(value); } // Check if token values matches if (derivedConfig.type === TokenType.collateral) { expect(derivedConfig.name).to.equal(TOKEN_NAME); expect(derivedConfig.symbol).to.equal(TOKEN_NAME); } }); it('should derive synthetic config correctly', async () => { // Create config const config = { [chain]: { type: TokenType.synthetic, hook: await mailbox.defaultHook(), name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: TOKEN_DECIMALS, initialSupply: TOKEN_SUPPLY, ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].synthetic.address); for (const [key, value] of Object.entries(derivedConfig)) { const deployedValue = config[chain][key]; if (deployedValue && typeof value === 'string') expect(deployedValue).to.equal(value); } // Check if token values matches if (derivedConfig.type === TokenType.collateral) { expect(derivedConfig.name).to.equal(TOKEN_NAME); expect(derivedConfig.symbol).to.equal(TOKEN_NAME); } }); it('should derive native config correctly', async () => { // Create config const config = { [chain]: { type: TokenType.native, hook: await mailbox.defaultHook(), ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].native.address); for (const [key, value] of Object.entries(derivedConfig)) { const deployedValue = config[chain][key]; if (deployedValue && typeof value === 'string') expect(deployedValue).to.equal(value); } // Check if token values matches expect(derivedConfig.decimals).to.equal(TOKEN_DECIMALS); }); it('should derive collateral vault config correctly', async () => { // Create config const config = { [chain]: { type: TokenType.collateralVault, token: vault.address, ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].collateralVault.address); assert(derivedConfig.type === TokenType.collateralVault, 'Must be collateralVault'); expect(derivedConfig.type).to.equal(config[chain].type); expect(derivedConfig.mailbox).to.equal(config[chain].mailbox); expect(derivedConfig.owner).to.equal(config[chain].owner); expect(derivedConfig.token).to.equal(vault.address); }); it('should derive rebase collateral vault config correctly', async () => { // Create config const config = { [chain]: { type: TokenType.collateralVaultRebase, token: vault.address, ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].collateralVaultRebase.address); assert(derivedConfig.type === TokenType.collateralVaultRebase, 'Must be collateralVaultRebase'); expect(derivedConfig.type).to.equal(config[chain].type); expect(derivedConfig.mailbox).to.equal(config[chain].mailbox); expect(derivedConfig.owner).to.equal(config[chain].owner); expect(derivedConfig.token).to.equal(vault.address); }); // FiatTokenTest it('should derive collateral fiat token type correctly', async () => { // Create config const config = { [chain]: { type: TokenType.collateralFiat, token: collateralFiatToken.address, ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].collateralFiat.address); assert(derivedConfig.type === TokenType.collateralFiat, `Must be ${TokenType.collateralFiat}`); expect(derivedConfig.type).to.equal(config[chain].type); expect(derivedConfig.mailbox).to.equal(config[chain].mailbox); expect(derivedConfig.owner).to.equal(config[chain].owner); expect(derivedConfig.token).to.equal(collateralFiatToken.address); }); const getEverclearTokenBridgeConfig = () => { return { [TokenType.ethEverclear]: { type: TokenType.ethEverclear, wethAddress: weth.address, everclearBridgeAddress: everclearBridgeAdapterMock.address, everclearFeeParams: { [chain]: { deadline: Date.now(), fee: randomInt(10000000), signature: '0x', }, }, outputAssets: {}, ...baseConfig, }, [TokenType.collateralEverclear]: { type: TokenType.collateralEverclear, token: token.address, everclearBridgeAddress: everclearBridgeAdapterMock.address, everclearFeeParams: { [chain]: { deadline: Date.now(), fee: randomInt(10000000), signature: '0x', }, }, outputAssets: {}, ...baseConfig, }, }; }; for (const tokenType of [ TokenType.ethEverclear, TokenType.collateralEverclear, ]) { it(`should derive ${tokenType} token correctly`, async () => { // Create config const config = { [chain]: getEverclearTokenBridgeConfig()[tokenType], }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain][tokenType].address); assert(derivedConfig.type === tokenType, `Must be ${tokenType}`); expect(derivedConfig.type).to.equal(config[chain].type); expect(derivedConfig.mailbox).to.equal(config[chain].mailbox); expect(derivedConfig.owner).to.equal(config[chain].owner); expect(derivedConfig.everclearBridgeAddress).to.equal(everclearBridgeAdapterMock.address); if (derivedConfig.type === TokenType.collateralEverclear) { expect(derivedConfig.token).to.equal(token.address); } if (derivedConfig.type === TokenType.ethEverclear) { expect(derivedConfig.wethAddress).to.equal(weth.address); } }); } for (const cctpVersion of ['V1', 'V2']) { it(`should derive CCTP ${cctpVersion} token correctly`, async () => { const rawVersion = cctpVersion === 'V2' ? 1 : 0; await mockCircleMessageTransmitter.setVersion(rawVersion); await mockCircleTokenMessenger.setVersion(rawVersion); const tokenType = TokenType.collateralCctp; const cctpConfig = { type: tokenType, token: token.address, cctpVersion: cctpVersion, messageTransmitter: mockCircleMessageTransmitter.address, tokenMessenger: mockCircleTokenMessenger.address, urls: ['https://fake-cctp-url.com'], }; if (cctpVersion === 'V2') { cctpConfig.maxFeeBps = 1; cctpConfig.minFinalityThreshold = 1000; } // Create config const config = { [TestChainName.test4]: { ...cctpConfig, ...baseConfig, }, [TestChainName.test3]: { ...cctpConfig, ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain][tokenType].address); // delete undefined member delete config[TestChainName.test4].ownerOverrides; // check that derived is a superset of specified config expect(derivedConfig).to.deep.include(config[TestChainName.test4]); }); } it('should return 0x0 if ism is not set onchain', async () => { // Create config const config = { [chain]: { type: TokenType.collateral, token: token.address, hook: await mailbox.defaultHook(), ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].collateral.address); expect(derivedConfig.interchainSecurityModule).to.be.equal(zeroAddress); }); it('should return the remote routers', async () => { // Create config const otherChain = TestChainName.test3; const otherChainMetadata = test3; const config = { [chain]: { type: TokenType.collateral, token: token.address, hook: await mailbox.defaultHook(), ...baseConfig, }, [otherChain]: { type: TokenType.collateral, token: token.address, hook: await mailbox.defaultHook(), ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if remote router matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].collateral.address); expect(Object.keys(derivedConfig.remoteRouters).length).to.equal(1); expect(derivedConfig.remoteRouters[otherChainMetadata.domainId].address).to.be.equal(addressToBytes32(warpRoute[otherChain].collateral.address)); }); it('should return the contractVerificationStatus virtual config', async () => { const otherChain = TestChainName.test3; const config = { [chain]: { type: TokenType.collateral, token: token.address, hook: await mailbox.defaultHook(), ...baseConfig, }, [otherChain]: { type: TokenType.collateral, token: token.address, hook: await mailbox.defaultHook(), ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Stub isLocalRpc to bypass local rpc check const isLocalRpcStub = sinon .stub(multiProvider, 'isLocalRpc') .returns(false); // Stub getContractVerificationStatus const getContractVerificationStatus = sinon .stub(contractVerifier, 'getContractVerificationStatus') .resolves(ContractVerificationStatus.Verified); // Derive config and check if the owner is active const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteVirtualConfig(chain, warpRoute[chain].collateral.address); expect(derivedConfig.contractVerificationStatus).to.deep.equal({ [VerifyContractTypes.Proxy]: ContractVerificationStatus.Verified, [VerifyContractTypes.Implementation]: ContractVerificationStatus.Verified, [VerifyContractTypes.ProxyAdmin]: ContractVerificationStatus.Verified, }); // Restore stub getContractVerificationStatus.restore(); isLocalRpcStub.restore(); }); it('should return the ownerStatus virtual config for the proxy, implementation, and proxy admin, if they are different', async () => { const provider = multiProvider.getProvider(chain); const otherChain = TestChainName.test3; const config = { [chain]: { type: TokenType.collateral, token: token.address, hook: await mailbox.defaultHook(), ...baseConfig, }, [otherChain]: { type: TokenType.collateral, token: token.address, hook: await mailbox.defaultHook(), ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Stub isLocalRpc to bypass local rpc check const isLocalRpcStub = sinon .stub(multiProvider, 'isLocalRpc') .returns(false); // Derive config and transfer the proxy, implementation, and proxyAdmin over const warpRouteAddress = warpRoute[chain].collateral.address; const proxyAdminAddress = await proxyAdmin(provider, warpRouteAddress); await new ProxyAdmin__factory() .connect(signer) .attach(proxyAdminAddress) .transferOwnership(warpRouteAddress); const implementation = await proxyImplementation(provider, warpRouteAddress); await new HypERC20__factory() .connect(signer) .attach(implementation) .transferOwnership(mailbox.address); const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteVirtualConfig(chain, warpRouteAddress); expect(derivedConfig.ownerStatus).to.deep.equal({ [signer.address]: OwnerStatus.Active, [warpRouteAddress]: OwnerStatus.Active, [mailbox.address]: OwnerStatus.Active, }); // Restore stub isLocalRpcStub.restore(); }); it('should return a Gnosis Safe ownerStatus', async () => { const config = { [chain]: { type: TokenType.collateral, token: token.address, hook: await mailbox.defaultHook(), ...baseConfig, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); const warpRouteAddress = warpRoute[chain].collateral.address; // Stub isLocalRpc to bypass local rpc check const isLocalRpcStub = sinon .stub(multiProvider, 'isLocalRpc') .returns(false); const mockOwnerManager = { getThreshold: sinon.stub().resolves(randomInt(1e4)), nonce: sinon.stub().resolves(randomInt(1e4)), }; const connectStub = sinon .stub(ISafe__factory, 'connect') .returns(mockOwnerManager); // Derive config and check if the owner is active const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteVirtualConfig(chain, warpRouteAddress); expect(derivedConfig.ownerStatus).to.deep.equal({ [signer.address]: OwnerStatus.GnosisSafe, }); // Restore stub connectStub.restore(); isLocalRpcStub.restore(); }); it('should derive token fee config correctly', async () => { const config = { [chain]: { ...baseConfig, type: TokenType.collateral, token: token.address, hook: await mailbox.defaultHook(), tokenFee: { type: TokenFeeType.LinearFee, owner: mailbox.address, bps: BPS, }, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].collateral.address); expect(normalizeConfig(derivedConfig.tokenFee)).to.deep.equal(normalizeConfig({ ...config[chain].tokenFee, token: token.address, maxFee: MAX_FEE, halfAmount: HALF_AMOUNT, })); }); it('should not require routing destinations to derive RoutingFee token fee config', async () => { const config = { [chain]: { ...baseConfig, type: TokenType.collateral, token: token.address, hook: await mailbox.defaultHook(), tokenFee: { type: TokenFeeType.RoutingFee, owner: mailbox.address, feeContracts: { [TestChainName.test3]: { type: TokenFeeType.LinearFee, owner: mailbox.address, bps: BPS, }, }, }, }, }; const warpRoute = await deployer.deploy(config); const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].collateral.address); expect(derivedConfig.tokenFee?.type).to.equal(TokenFeeType.RoutingFee); expect(derivedConfig.tokenFee.owner).to.equal(mailbox.address); expect(Object.keys(derivedConfig.tokenFee.feeContracts)).to.have.length(0); }); it('should return undefined fee token config if it is not set onchain', async () => { const config = { [chain]: { ...baseConfig, type: TokenType.collateral, token: token.address, hook: await mailbox.defaultHook(), }, }; // Deploy with config const warpRoute = await deployer.deploy(config); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].collateral.address); expect(derivedConfig.tokenFee).to.be.undefined; }); it(`should return undefined fee token config if the package version is below ${TOKEN_FEE_CONTRACT_VERSION}`, async () => { const config = { [chain]: { ...baseConfig, type: TokenType.collateral, token: token.address, hook: await mailbox.defaultHook(), tokenFee: { type: TokenFeeType.LinearFee, owner: mailbox.address, bps: BPS, }, }, }; // Deploy with config const warpRoute = await deployer.deploy(config); const mockPackageVersioned = { PACKAGE_VERSION: sinon.stub().resolves('8.0.1'), }; const fetchPackageVersionStub = sinon .stub(PackageVersioned__factory, 'connect') .returns(mockPackageVersioned); // Also stub fetchScale to avoid version mismatch when reading scale // For old contracts (< 11.0.0), scale would default to 1 const fetchScaleStub = sinon .stub(evmERC20WarpRouteReader, 'fetchScale') .resolves(undefined); // Derive config and check if each value matches const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpRoute[chain].collateral.address); // Even though we deployed a token fee, it should be undefined because the package version is below the required version. // This should never happen, but serves as a clear test expect(derivedConfig.tokenFee).to.be.undefined; fetchPackageVersionStub.restore(); fetchScaleStub.restore(); }); it('derives deposit-address bridge config', async () => { const bridgeAddress = '0x1000000000000000000000000000000000000002'; const tokenAddress = '0x2000000000000000000000000000000000000002'; const recipient = addressToBytes32('0x3000000000000000000000000000000000000003'); const depositAddress = '0x4000000000000000000000000000000000000004'; const bridgeStub = sinon .stub(TokenBridgeDepositAddress__factory, 'connect') .returns({ token: sinon.stub().resolves(tokenAddress), getDomainConfigs: sinon .stub() .resolves([[101], [depositAddress], [recipient], ['1234']]), }); const metadataStub = sinon .stub(evmERC20WarpRouteReader, 'fetchERC20Metadata') .resolves({ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: TOKEN_DECIMALS, isNft: false, }); const deriveDepositAddressConfig = evmERC20WarpRouteReader .deriveHypCollateralDepositAddressTokenConfig; try { const derivedConfig = await deriveDepositAddressConfig.call(evmERC20WarpRouteReader, bridgeAddress); expect(derivedConfig.type).to.equal(TokenType.collateralDepositAddress); expect(derivedConfig.token).to.equal(tokenAddress); expect(derivedConfig.destinationConfigs).to.deep.equal({ '101': { [recipient.toLowerCase()]: { depositAddress, feeBps: '1234', }, }, }); } finally { bridgeStub.restore(); metadataStub.restore(); } }); it('derives multicollateral config with scale from the router', async () => { const routerAddress = '0x1000000000000000000000000000000000000001'; const wrappedTokenAddress = '0x2000000000000000000000000000000000000002'; const localDomain = 31337; const remoteDomain = 31338; const localRouter = addressToBytes32('0x3000000000000000000000000000000000000003'); const remoteRouter = addressToBytes32('0x4000000000000000000000000000000000000004'); const expectedScale = { numerator: 1n, denominator: 1000000000000n, }; const mcConnectStub = sinon .stub(CrossCollateralRouter__factory, 'connect') .returns({ wrappedToken: sinon.stub().resolves(wrappedTokenAddress), localDomain: sinon.stub().resolves(localDomain), getCrossCollateralDomains: sinon .stub() .resolves([localDomain, remoteDomain]), getCrossCollateralRouters: sinon .stub() .callsFake(async (domain) => domain === localDomain ? [localRouter] : [remoteRouter]), }); const tokenRouterConnectStub = sinon .stub(TokenRouter__factory, 'connect') .returns({ domains: sinon.stub().resolves([remoteDomain]), }); const metadataStub = sinon .stub(evmERC20WarpRouteReader, 'fetchERC20Metadata') .resolves({ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: TOKEN_DECIMALS, isNft: false, }); const scaleStub = sinon .stub(evmERC20WarpRouteReader, 'fetchScale') .resolves(expectedScale); const deriveCrossCollateralTokenConfig = evmERC20WarpRouteReader .deriveCrossCollateralTokenConfig; try { const derivedConfig = await deriveCrossCollateralTokenConfig.call(evmERC20WarpRouteReader, routerAddress); expect(derivedConfig.type).to.equal(TokenType.crossCollateral); expect(derivedConfig.token).to.equal(wrappedTokenAddress); expect(derivedConfig.scale).to.deep.equal(expectedScale); expect(derivedConfig.crossCollateralRouters).to.deep.equal({ [localDomain.toString()]: [localRouter], [remoteDomain.toString()]: [remoteRouter], }); } finally { mcConnectStub.restore(); tokenRouterConnectStub.restore(); metadataStub.restore(); scaleStub.restore(); } }); it('fetchDestinationGas includes MC-enrolled domains via additionalDomains', async () => { const routerAddress = '0x1000000000000000000000000000000000000001'; const defaultDomain = 2; const mcOnlyDomain = 99; const tokenRouterStub = sinon .stub(TokenRouter__factory, 'connect') .returns({ domains: sinon.stub().resolves([defaultDomain]), destinationGas: sinon.stub().callsFake(async (domain) => { if (domain === defaultDomain) return { toString: () => '100000' }; if (domain === mcOnlyDomain) return { toString: () => '200000' }; return { toString: () => '0' }; }), }); try { const gas = await evmERC20WarpRouteReader.fetchDestinationGas(routerAddress, [mcOnlyDomain]); expect(gas[defaultDomain]).to.equal('100000'); expect(gas[mcOnlyDomain]).to.equal('200000'); } finally { tokenRouterStub.restore(); } }); it('fetchDestinationGas excludes local domain entries', async () => { const routerAddress = '0x1000000000000000000000000000000000000002'; const localDomain = multiProvider.getDomainId(chain); const remoteDomain = localDomain + 1; const tokenRouterStub = sinon .stub(TokenRouter__factory, 'connect') .returns({ domains: sinon.stub().resolves([localDomain, remoteDomain]), destinationGas: sinon.stub().callsFake(async (domain) => { if (domain === localDomain) return { toString: () => '999' }; if (domain === remoteDomain) return { toString: () => '100000' }; return { toString: () => '0' }; }), }); try { const gas = await evmERC20WarpRouteReader.fetchDestinationGas(routerAddress, [localDomain]); expect(gas[remoteDomain]).to.equal('100000'); expect(gas[localDomain]).to.be.undefined; } finally { tokenRouterStub.restore(); } }); it('deriveWarpRouteConfig includes CCR-only destinations when deriving token fees', async () => { const routerAddress = token.address; const localDomain = multiProvider.getDomainId(chain); const ccrOnlyDomain = localDomain + 100; const readRouterConfigStub = sinon .stub(evmERC20WarpRouteReader, 'readRouterConfig') .resolves({ mailbox: mailbox.address, owner: signer.address, hook: zeroAddress, interchainSecurityModule: zeroAddress, remoteRouters: {}, }); const fetchTokenConfigStub = sinon .stub(evmERC20WarpRouteReader, 'fetchTokenConfig') .resolves({ type: TokenType.crossCollateral, token: token.address, contractVersion: '8.0.0', crossCollateralRouters: { [localDomain.toString()]: [addressToBytes32(routerAddress)], [ccrOnlyDomain.toString()]: [addressToBytes32(routerAddress)], }, }); const fetchDestinationGasStub = sinon .stub(evmERC20WarpRouteReader, 'fetchDestinationGas') .resolves({}); const fetchTokenFeeStub = sinon .stub(evmERC20WarpRouteReader, 'fetchTokenFee') .resolves(undefined); const movableConnectStub = sinon .stub(MovableCollateralRouter__factory, 'connect') .returns({ allowedRebalancers: sinon.stub().resolves([]), domains: sinon.stub().resolves([]), allowedBridges: sinon.stub().resolves([]), }); try { await evmERC20WarpRouteReader.deriveWarpRouteConfig(routerAddress); expect(fetchTokenFeeStub.calledOnce).to.equal(true); expect(fetchTokenFeeStub.firstCall.args[1]).to.deep.equal([ localDomain, ccrOnlyDomain, ]); } finally { readRouterConfigStub.restore(); fetchTokenConfigStub.restore(); fetchDestinationGasStub.restore(); fetchTokenFeeStub.restore(); movableConnectStub.restore(); } }); describe('Backward compatibility for token type detection', () => { // Test table for token type detection const tokenTypeTestCases = [ { version: '9.0.0', tokenType: TokenType.native, description: 'legacy native token using estimateGas fallback', isLegacy: true, }, { version: '9.0.0', tokenType: TokenType.synthetic, description: 'legacy synthetic token using decimals() fallback', isLegacy: true, }, { version: TOKEN_FEE_CONTRACT_VERSION, tokenType: TokenType.native, description: 'modern native token using token() method', isLegacy: false, }, { version: TOKEN_FEE_CONTRACT_VERSION, tokenType: TokenType.synthetic, description: 'modern synthetic token using token() method', isLegacy: false, }, ]; for (const testCase of tokenTypeTestCases) { it(`should detect ${testCase.description} (v${testCase.version})`, async () => { const config = { [chain]: { type: testCase.tokenType, hook: await mailbox.defaultHook(), ...(testCase.tokenType === TokenType.synthetic ? { name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: TOKEN_DECIMALS, initialSupply: TOKEN_SUPPLY, } : {}), ...baseConfig, }, }; const warpRoute = await deployer.deploy(config); const warpAddress = warpRoute[chain][testCase.tokenType].address; // Stub package version for legacy contracts let fetchPackageVersionStub; if (testCase.isLegacy) { const mockPackageVersioned = { PACKAGE_VERSION: sinon.stub().resolves(testCase.version), }; fetchPackageVersionStub = sinon .stub(PackageVersioned__factory, 'connect') .returns(mockPackageVersioned); } const derivedTokenType = await evmERC20WarpRouteReader.deriveTokenType(warpAddress); expect(derivedTokenType).to.equal(testCase.tokenType); // Cleanup if (fetchPackageVersionStub) { fetchPackageVersionStub.restore(); } }); } // Test table for full config derivation const fullConfigTestCases = [ { version: '9.0.0', tokenType: TokenType.native, description: 'legacy native contract', }, { version: '9.0.0', tokenType: TokenType.synthetic, description: 'legacy synthetic contract', }, ]; for (const testCase of fullConfigTestCases) { it(`should derive warp route config for ${testCase.description} (v${testCase.version})`, async () => { const config = { [chain]: { type: testCase.tokenType, hook: await mailbox.defaultHook(), ...(testCase.tokenType === TokenType.synthetic ? { name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: TOKEN_DECIMALS, initialSupply: TOKEN_SUPPLY, } : {}), ...baseConfig, }, }; const warpRoute = await deployer.deploy(config); const warpAddress = warpRoute[chain][testCase.tokenType].address; // Stub package version to simulate legacy contract const mockPackageVersioned = { PACKAGE_VERSION: sinon.stub().resolves(testCase.version), }; const fetchPackageVersionStub = sinon .stub(PackageVersioned__factory, 'connect') .returns(mockPackageVersioned); // Also stub fetchScale to avoid version mismatch when reading scale // For old contracts (< 11.0.0), scale would default to 1 const fetchScaleStub = sinon .stub(evmERC20WarpRouteReader, 'fetchScale') .resolves(undefined); const derivedConfig = await evmERC20WarpRouteReader.deriveWarpRouteConfig(warpAddress); expect(derivedConfig.type).to.equal(testCase.tokenType); expect(derivedConfig.contractVersion).to.equal(testCase.version); if (testCase.tokenType === TokenType.native) { expect(derivedConfig.decimals).to.equal(TOKEN_DECIMALS); } else if (testCase.tokenType === TokenType.synthetic) { expect(derivedConfig.name).to.equal(TOKEN_NAME); expect(derivedConfig.symbol).to.equal(TOKEN_NAME); expect(derivedConfig.decimals).to.equal(TOKEN_DECIMALS); } fetchPackageVersionStub.restore(); fetchScaleStub.restore(); }); } // Note: legacy contract fetchScale path (< 11.0.0) is tested via hardhat_setCode // to inject minimal bytecode that responds to the scale() selector. // The legacy path converts a single uint256 scale() return to { numerator: bigint, denominator: 1n }. describe('fetchScale', () => { it('should return undefined for contracts before scaling was introduced (< 6.0.0)', async () => { const config = { [chain]: { type: TokenType.synthetic, name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: TOKEN_DECIMALS, hook: await mailbox.defaultHook(), ...baseConfig, }, }; const warpR