@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
1,081 lines • 58.6 kB
JavaScript
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