@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
932 lines • 44.4 kB
JavaScript
import { expect } from 'chai';
import hre from 'hardhat';
import sinon from 'sinon';
import { CrossCollateralRoutingFee__factory, ERC20Test__factory, } from '@hyperlane-xyz/core';
import { assert } from '@hyperlane-xyz/utils';
import { TestChainName } from '../consts/testChains.js';
import { MultiProvider } from '../providers/MultiProvider.js';
import { randomAddress } from '../test/testUtils.js';
import { normalizeConfig } from '../utils/ism.js';
import { EvmTokenFeeModule } from './EvmTokenFeeModule.js';
import { BPS, HALF_AMOUNT, MAX_FEE } from './EvmTokenFeeReader.hardhat-test.js';
import { EvmTokenFeeReader, } from './EvmTokenFeeReader.js';
import { CrossCollateralRoutingFeeConfigSchema, DEFAULT_ROUTER_KEY, TokenFeeConfigSchema, TokenFeeType, } from './types.js';
import { convertToBps } from './utils.js';
describe('EvmTokenFeeModule', () => {
const test4Chain = TestChainName.test4;
let multiProvider;
let signer;
let token;
let config;
before(async () => {
[signer] = await hre.ethers.getSigners();
multiProvider = MultiProvider.createTestMultiProvider({ signer });
const factory = new ERC20Test__factory(signer);
token = await factory.deploy('fake', 'FAKE', '100000000000000000000', 18);
await token.deployed();
config = {
type: TokenFeeType.LinearFee,
owner: signer.address,
token: token.address,
maxFee: MAX_FEE,
halfAmount: HALF_AMOUNT,
bps: BPS,
};
});
async function deployCrossCollateralRoutingFee(owner) {
const factory = new CrossCollateralRoutingFee__factory(signer);
const ccrf = await factory.deploy(owner);
await ccrf.deployed();
return ccrf;
}
async function expectTxsAndUpdate(feeModule, config, n, params) {
const txs = await feeModule.update(config, params);
expect(txs.length).to.equal(n);
for (const tx of txs) {
await multiProvider.sendTransaction(test4Chain, tx);
}
}
it('should create a new token fee', async () => {
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: TestChainName.test2,
config,
});
const onchainConfig = await module.read();
expect(normalizeConfig(onchainConfig)).to.deep.equal(normalizeConfig({
...config,
maxFee: MAX_FEE,
halfAmount: HALF_AMOUNT,
}));
});
it('should create a new token fee with bps', async () => {
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config,
});
const onchainConfig = await module.read();
assert(onchainConfig.type === TokenFeeType.LinearFee, `Must be ${TokenFeeType.LinearFee}`);
assert(onchainConfig.type === TokenFeeType.LinearFee, `Must be ${TokenFeeType.LinearFee}`);
expect(onchainConfig.bps).to.equal(BPS);
});
it('should deploy and read the routing fee config', async () => {
const routingFeeConfig = {
feeContracts: {
[test4Chain]: config,
},
owner: signer.address,
token: token.address,
type: TokenFeeType.RoutingFee,
};
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config: routingFeeConfig,
});
const routingDestination = multiProvider.getDomainId(test4Chain);
const onchainConfig = await module.read({
routingDestinations: [routingDestination],
});
expect(normalizeConfig(onchainConfig)).to.deep.equal(normalizeConfig(routingFeeConfig));
});
describe('Update', async () => {
it('should not update if the linear configs are the same', async () => {
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config,
});
const txs = await module.update(config);
expect(txs).to.have.lengthOf(0);
});
it('should not update if the routing configs are the same', async () => {
const routingConfig = TokenFeeConfigSchema.parse({
type: TokenFeeType.RoutingFee,
owner: signer.address,
token: token.address,
feeContracts: {
[test4Chain]: config,
},
});
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config: routingConfig,
});
const chainId = multiProvider.getDomainId(test4Chain);
const txs = await module.update(routingConfig, {
routingDestinations: [chainId],
});
expect(txs).to.have.lengthOf(0);
});
it('should not update if providing a bps that is the same as the result of calculating with maxFee and halfAmount', async () => {
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config,
});
const updatedConfig = {
type: TokenFeeType.LinearFee,
owner: config.owner,
token: config.token,
bps: BPS,
};
const txs = await module.update(updatedConfig);
expect(txs).to.have.lengthOf(0);
});
it(`should redeploy immutable fees if updating token for ${TokenFeeType.LinearFee}`, async () => {
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config,
});
const updatedConfig = { ...config, bps: BPS + 1 };
await expectTxsAndUpdate(module, updatedConfig, 0);
const onchainConfig = await module.read();
assert(onchainConfig.type === TokenFeeType.LinearFee, `Must be ${TokenFeeType.LinearFee}`);
expect(onchainConfig.bps).to.eql(updatedConfig.bps);
});
it(`should redeploy routing fees when nested fee config changes`, async () => {
const feeContracts = {
[test4Chain]: config,
};
const routingFeeConfig = {
type: TokenFeeType.RoutingFee,
owner: signer.address,
token: token.address,
feeContracts: feeContracts,
};
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config: routingFeeConfig,
});
const updatedConfig = {
...routingFeeConfig,
feeContracts: {
[test4Chain]: {
...feeContracts[test4Chain],
bps: BPS + 1,
},
},
};
// 1 tx: setFeeContract to point to redeployed sub-fee
await expectTxsAndUpdate(module, updatedConfig, 1, {
routingDestinations: [multiProvider.getDomainId(test4Chain)],
});
const onchainConfig = await module.read({
routingDestinations: [multiProvider.getDomainId(test4Chain)],
});
assert(onchainConfig.type === TokenFeeType.RoutingFee, `Must be ${TokenFeeType.RoutingFee}`);
expect(onchainConfig.feeContracts[test4Chain]?.bps).to.equal(BPS + 1);
});
it('should transfer ownership if they are different', async () => {
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config,
});
await expectTxsAndUpdate(module, { ...config, owner: config.owner }, 0);
const newOwner = randomAddress();
await expectTxsAndUpdate(module, { ...config, owner: newOwner }, 1);
const onchainConfig = await module.read();
assert(onchainConfig.type === TokenFeeType.LinearFee, `Must be ${TokenFeeType.LinearFee}`);
expect(normalizeConfig(onchainConfig).owner).to.equal(normalizeConfig(newOwner));
});
it('should redeploy routing fees when nested owner changes', async () => {
const feeContracts = {
[test4Chain]: config,
};
const routingFeeConfig = {
type: TokenFeeType.RoutingFee,
owner: signer.address,
token: token.address,
feeContracts: feeContracts,
};
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config: routingFeeConfig,
});
const newOwner = normalizeConfig(randomAddress());
// 2 txs: sub-fee ownership transfer + routing fee ownership transfer
await expectTxsAndUpdate(module, {
...routingFeeConfig,
owner: newOwner,
feeContracts: {
[test4Chain]: {
...feeContracts[test4Chain],
owner: newOwner,
},
},
}, 2, {
routingDestinations: [multiProvider.getDomainId(test4Chain)],
});
const onchainConfig = await module.read({
routingDestinations: [multiProvider.getDomainId(test4Chain)],
});
assert(onchainConfig.type === TokenFeeType.RoutingFee, `Must be ${TokenFeeType.RoutingFee}`);
expect(normalizeConfig(onchainConfig).owner).to.equal(newOwner);
expect(normalizeConfig(onchainConfig.feeContracts[test4Chain]).owner).to.equal(newOwner);
});
it('should derive routingDestinations from target config when not provided', async () => {
const feeContracts = {
[test4Chain]: config,
};
const routingFeeConfig = {
type: TokenFeeType.RoutingFee,
owner: signer.address,
token: token.address,
feeContracts,
};
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config: routingFeeConfig,
});
// Update without providing routingDestinations - should derive from target config
const updatedConfig = {
...routingFeeConfig,
feeContracts: {
[test4Chain]: {
...feeContracts[test4Chain],
bps: BPS + 1,
},
},
};
// 1 tx: setFeeContract to point to redeployed sub-fee
const txs = await module.update(updatedConfig);
expect(txs.length).to.equal(1);
for (const tx of txs) {
await multiProvider.sendTransaction(test4Chain, tx);
}
const onchainConfig = await module.read({
routingDestinations: [multiProvider.getDomainId(test4Chain)],
});
assert(onchainConfig.type === TokenFeeType.RoutingFee, `Must be ${TokenFeeType.RoutingFee}`);
expect(onchainConfig.feeContracts[test4Chain]?.bps).to.equal(BPS + 1);
});
it('should forward token reader params when updating routing fees', async () => {
const routingConfig = {
type: TokenFeeType.RoutingFee,
owner: signer.address,
token: token.address,
feeContracts: {
[test4Chain]: config,
},
};
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config: routingConfig,
});
const routingDestination = multiProvider.getDomainId(test4Chain);
const actualConfig = await module.read({
routingDestinations: [routingDestination],
});
const readStub = sinon.stub(module, 'read').resolves(actualConfig);
try {
const txs = await module.update({
type: TokenFeeType.RoutingFee,
owner: signer.address,
feeContracts: {
[test4Chain]: {
type: TokenFeeType.LinearFee,
owner: signer.address,
bps: BPS,
},
},
}, {
routingDestinations: [routingDestination],
});
expect(txs).to.have.lengthOf(0);
expect(readStub.calledOnce).to.be.true;
expect(readStub.firstCall.args[0]).to.deep.equal({
routingDestinations: [routingDestination],
});
}
finally {
readStub.restore();
}
});
it('should update CCRF owner when reader params are provided', async () => {
const initialSubFeeModule = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config,
});
const ccrf = await deployCrossCollateralRoutingFee(signer.address);
const routingDestination = multiProvider.getDomainId(test4Chain);
await ccrf.setCrossCollateralRouterFeeContracts([routingDestination], [await ccrf.DEFAULT_ROUTER()], [initialSubFeeModule.serialize().deployedFee]);
const routingConfig = {
type: TokenFeeType.RoutingFee,
owner: signer.address,
token: token.address,
feeContracts: {},
};
const module = new EvmTokenFeeModule(multiProvider, {
chain: test4Chain,
config: routingConfig,
addresses: { deployedFee: ccrf.address },
});
const newOwner = randomAddress();
const txs = await module.update({
type: TokenFeeType.CrossCollateralRoutingFee,
owner: newOwner,
feeContracts: {
[test4Chain]: {
[DEFAULT_ROUTER_KEY]: {
type: TokenFeeType.LinearFee,
owner: signer.address,
bps: BPS,
},
},
},
}, {
routingDestinations: [routingDestination],
crossCollateralRouters: {
[routingDestination]: [],
},
});
expect(txs).to.have.lengthOf(1);
await multiProvider.sendTransaction(test4Chain, txs[0]);
expect(await ccrf.owner()).to.equal(hre.ethers.utils.getAddress(newOwner));
});
it('should update CCRF sub-fee when fee contracts differ', async () => {
const initialSubFeeModule = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config,
});
const ccrf = await deployCrossCollateralRoutingFee(signer.address);
const routingDestination = multiProvider.getDomainId(test4Chain);
await ccrf.setCrossCollateralRouterFeeContracts([routingDestination], [await ccrf.DEFAULT_ROUTER()], [initialSubFeeModule.serialize().deployedFee]);
const routingConfig = {
type: TokenFeeType.RoutingFee,
owner: signer.address,
token: token.address,
feeContracts: {},
};
const module = new EvmTokenFeeModule(multiProvider, {
chain: test4Chain,
config: routingConfig,
addresses: { deployedFee: ccrf.address },
});
const txs = await module.update({
type: TokenFeeType.CrossCollateralRoutingFee,
owner: signer.address,
feeContracts: {
[test4Chain]: {
[DEFAULT_ROUTER_KEY]: {
type: TokenFeeType.LinearFee,
owner: signer.address,
bps: BPS + 1,
},
},
},
}, {
routingDestinations: [routingDestination],
});
expect(txs).to.have.lengthOf(1);
expect(module.serialize().deployedFee).to.equal(ccrf.address);
await multiProvider.sendTransaction(test4Chain, txs[0]);
const onchainConfig = await module.read({
routingDestinations: [routingDestination],
crossCollateralRouters: {
[routingDestination]: [],
},
});
assert(onchainConfig.type === TokenFeeType.CrossCollateralRoutingFee, `Must be ${TokenFeeType.CrossCollateralRoutingFee}`);
assert(onchainConfig.feeContracts[test4Chain]?.[DEFAULT_ROUTER_KEY]?.type ===
TokenFeeType.LinearFee, `Must be ${TokenFeeType.LinearFee}`);
expect(onchainConfig.feeContracts[test4Chain]?.[DEFAULT_ROUTER_KEY]?.bps).to.equal(BPS + 1);
});
it('should update an empty CCRF using explicitly resolved child tokens', async () => {
const emptyCcrf = await deployCrossCollateralRoutingFee(signer.address);
const routingDestination = multiProvider.getDomainId(test4Chain);
const module = new EvmTokenFeeModule(multiProvider, {
chain: test4Chain,
config: CrossCollateralRoutingFeeConfigSchema.parse({
type: TokenFeeType.CrossCollateralRoutingFee,
owner: signer.address,
feeContracts: {},
}),
addresses: { deployedFee: emptyCcrf.address },
});
const targetConfig = {
type: TokenFeeType.CrossCollateralRoutingFee,
owner: signer.address,
feeContracts: {
[test4Chain]: {
[DEFAULT_ROUTER_KEY]: {
type: TokenFeeType.LinearFee,
owner: signer.address,
token: token.address,
bps: BPS,
},
},
},
};
const txs = await module.update(targetConfig, {
routingDestinations: [routingDestination],
crossCollateralRouters: {
[routingDestination]: [],
},
});
expect(txs).to.have.lengthOf(1);
expect(module.serialize().deployedFee).to.equal(emptyCcrf.address);
await multiProvider.sendTransaction(test4Chain, txs[0]);
const onchainConfig = await module.read({
routingDestinations: [routingDestination],
crossCollateralRouters: {
[routingDestination]: [],
},
});
assert(onchainConfig.type === TokenFeeType.CrossCollateralRoutingFee, `Must be ${TokenFeeType.CrossCollateralRoutingFee}`);
expect(onchainConfig.feeContracts[test4Chain]?.[DEFAULT_ROUTER_KEY]?.token).to.equal(token.address);
});
it('should preserve caller-provided CCR routers when diffing for update', async () => {
const initialSubFeeModule = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config,
});
const ccrf = await deployCrossCollateralRoutingFee(signer.address);
const routerKey = hre.ethers.utils.hexZeroPad(signer.address, 32);
const routingDestination = multiProvider.getDomainId(test4Chain);
await ccrf.setCrossCollateralRouterFeeContracts([routingDestination], [routerKey], [initialSubFeeModule.serialize().deployedFee]);
const module = new EvmTokenFeeModule(multiProvider, {
chain: test4Chain,
config: {
type: TokenFeeType.CrossCollateralRoutingFee,
owner: signer.address,
feeContracts: {
[test4Chain]: {
[routerKey]: config,
},
},
},
addresses: { deployedFee: ccrf.address },
});
const txs = await module.update({
type: TokenFeeType.CrossCollateralRoutingFee,
owner: signer.address,
feeContracts: {
[test4Chain]: {
[DEFAULT_ROUTER_KEY]: {
type: TokenFeeType.LinearFee,
owner: signer.address,
bps: BPS,
},
},
},
}, {
crossCollateralRouters: {
[routingDestination]: [routerKey],
},
});
// 2 txs: one to wire DEFAULT_ROUTER_KEY, one to clear the removed routerKey
expect(txs).to.have.lengthOf(2);
expect(module.serialize().deployedFee).to.equal(ccrf.address);
await multiProvider.sendTransaction(test4Chain, txs[0]);
await multiProvider.sendTransaction(test4Chain, txs[1]);
const onchainConfig = await module.read({
crossCollateralRouters: {
[routingDestination]: [],
},
});
assert(onchainConfig.type === TokenFeeType.CrossCollateralRoutingFee, `Must be ${TokenFeeType.CrossCollateralRoutingFee}`);
expect(onchainConfig.feeContracts[test4Chain]?.[DEFAULT_ROUTER_KEY]?.type).to.equal(TokenFeeType.LinearFee);
// Verify the orphan routerKey pointer was actually cleared on-chain
expect(await ccrf.feeContracts(routingDestination, routerKey)).to.equal(hre.ethers.constants.AddressZero);
});
it('should deploy a fresh sub-fee when two routers sharing an address diverge in target config', async () => {
// Arrange: one shared sub-fee; both router keys wired to it
const sharedSubFeeModule = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config, // bps: BPS
});
const sharedSubFeeAddr = sharedSubFeeModule.serialize().deployedFee;
const ccrf = await deployCrossCollateralRoutingFee(signer.address);
const routingDestination = multiProvider.getDomainId(test4Chain);
const routerKeyA = hre.ethers.utils.hexZeroPad('0x01', 32);
const routerKeyB = hre.ethers.utils.hexZeroPad('0x02', 32);
await ccrf.setCrossCollateralRouterFeeContracts([routingDestination, routingDestination], [routerKeyA, routerKeyB], [sharedSubFeeAddr, sharedSubFeeAddr]);
const module = new EvmTokenFeeModule(multiProvider, {
chain: test4Chain,
config: CrossCollateralRoutingFeeConfigSchema.parse({
type: TokenFeeType.CrossCollateralRoutingFee,
owner: signer.address,
feeContracts: {},
}),
addresses: { deployedFee: ccrf.address },
});
// routerKeyA keeps config (bps: BPS), routerKeyB diverges — processed in A-first order
const txs = await module.update({
type: TokenFeeType.CrossCollateralRoutingFee,
owner: signer.address,
feeContracts: {
[test4Chain]: {
[routerKeyA]: config, // unchanged
[routerKeyB]: { ...config, bps: BPS + 5 }, // diverged
},
},
});
// routerKeyA: config matches, pointer stays → no tx for A
// routerKeyB: diverges from cached A config → split deploy, pointer updated → 1 tx
expect(txs).to.have.lengthOf(1);
await multiProvider.sendTransaction(test4Chain, txs[0]);
const addrA = await ccrf.feeContracts(routingDestination, routerKeyA);
const addrB = await ccrf.feeContracts(routingDestination, routerKeyB);
// routerKeyA retained the shared address (no change needed)
expect(addrA.toLowerCase()).to.equal(sharedSubFeeAddr.toLowerCase());
// routerKeyB received a fresh contract
expect(addrB.toLowerCase()).to.not.equal(sharedSubFeeAddr.toLowerCase());
// Both have the correct on-chain configs
const onchainConfig = await module.read({
crossCollateralRouters: {
[routingDestination]: [routerKeyA, routerKeyB],
},
});
assert(onchainConfig.type === TokenFeeType.CrossCollateralRoutingFee, `Must be ${TokenFeeType.CrossCollateralRoutingFee}`);
expect(onchainConfig.feeContracts[test4Chain]?.[routerKeyA]?.bps).to.equal(BPS);
expect(onchainConfig.feeContracts[test4Chain]?.[routerKeyB]?.bps).to.equal(BPS + 5);
});
it('should produce correct per-router configs regardless of split iteration order', async () => {
// Same scenario as above but routerKeyB appears first in the feeContracts object,
// so the update loop processes it first — exercising the opposite split code path
// (B triggers the "first time" update+cache, A is the split deployment).
const sharedSubFeeModule = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config, // bps: BPS
});
const sharedSubFeeAddr = sharedSubFeeModule.serialize().deployedFee;
const ccrf = await deployCrossCollateralRoutingFee(signer.address);
const routingDestination = multiProvider.getDomainId(test4Chain);
const routerKeyA = hre.ethers.utils.hexZeroPad('0x01', 32);
const routerKeyB = hre.ethers.utils.hexZeroPad('0x02', 32);
await ccrf.setCrossCollateralRouterFeeContracts([routingDestination, routingDestination], [routerKeyA, routerKeyB], [sharedSubFeeAddr, sharedSubFeeAddr]);
const module = new EvmTokenFeeModule(multiProvider, {
chain: test4Chain,
config: CrossCollateralRoutingFeeConfigSchema.parse({
type: TokenFeeType.CrossCollateralRoutingFee,
owner: signer.address,
feeContracts: {},
}),
addresses: { deployedFee: ccrf.address },
});
// B-first iteration order: routerKeyB processed before routerKeyA
const txs = await module.update({
type: TokenFeeType.CrossCollateralRoutingFee,
owner: signer.address,
feeContracts: {
[test4Chain]: {
[routerKeyB]: { ...config, bps: BPS + 5 }, // B first in insertion order
[routerKeyA]: config,
},
},
});
// B triggers a redeploy (bps changed), A then sees B's cached config ≠ A's config → split.
// Both pointers change → both packed into 1 setCrossCollateralRouterFeeContracts tx.
expect(txs).to.have.lengthOf(1);
await multiProvider.sendTransaction(test4Chain, txs[0]);
const addrA = await ccrf.feeContracts(routingDestination, routerKeyA);
const addrB = await ccrf.feeContracts(routingDestination, routerKeyB);
// Both routers now have their own unique contracts (neither retained sharedAddr)
expect(addrA.toLowerCase()).to.not.equal(sharedSubFeeAddr.toLowerCase());
expect(addrB.toLowerCase()).to.not.equal(sharedSubFeeAddr.toLowerCase());
expect(addrA.toLowerCase()).to.not.equal(addrB.toLowerCase());
// Config correctness: same outcome as A-first order
const onchainConfig = await module.read({
crossCollateralRouters: {
[routingDestination]: [routerKeyA, routerKeyB],
},
});
assert(onchainConfig.type === TokenFeeType.CrossCollateralRoutingFee, `Must be ${TokenFeeType.CrossCollateralRoutingFee}`);
expect(onchainConfig.feeContracts[test4Chain]?.[routerKeyA]?.bps).to.equal(BPS);
expect(onchainConfig.feeContracts[test4Chain]?.[routerKeyB]?.bps).to.equal(BPS + 5);
});
it('should redeploy when fee type changes', async () => {
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config,
});
const initialFeeAddress = module.serialize().deployedFee;
const routingDestination = multiProvider.getDomainId(test4Chain);
const txs = await module.update({
type: TokenFeeType.RoutingFee,
owner: signer.address,
feeContracts: {
[test4Chain]: {
type: TokenFeeType.LinearFee,
owner: signer.address,
bps: BPS,
},
},
});
expect(txs).to.have.lengthOf(0);
expect(module.serialize().deployedFee).to.not.equal(initialFeeAddress);
const onchainConfig = await module.read({
routingDestinations: [routingDestination],
});
assert(onchainConfig.type === TokenFeeType.RoutingFee, `Must be ${TokenFeeType.RoutingFee}`);
});
it('should redeploy routing fee when adding a new destination', async () => {
// Create a routing fee with one destination
const initialFeeContracts = {
[test4Chain]: config,
};
const routingFeeConfig = {
type: TokenFeeType.RoutingFee,
owner: signer.address,
token: token.address,
feeContracts: initialFeeContracts,
};
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config: routingFeeConfig,
});
// Update with an additional destination (test1)
const test1Chain = TestChainName.test1;
const updatedConfig = {
...routingFeeConfig,
feeContracts: {
[test4Chain]: initialFeeContracts[test4Chain],
[test1Chain]: {
...config,
// New destination - should trigger deployment
},
},
};
// 1 tx: setFeeContract for the new destination
const txs = await module.update(updatedConfig);
expect(txs.length).to.equal(1);
for (const tx of txs) {
await multiProvider.sendTransaction(test4Chain, tx);
}
const onchainConfig = await module.read({
routingDestinations: [
multiProvider.getDomainId(test4Chain),
multiProvider.getDomainId(test1Chain),
],
});
assert(onchainConfig.type === TokenFeeType.RoutingFee, `Must be ${TokenFeeType.RoutingFee}`);
expect(onchainConfig.feeContracts[test1Chain]).to.not.be.undefined;
});
});
describe('expandConfig', () => {
it('should expand config for zero-supply token using safe fallback', async () => {
const factory = new ERC20Test__factory(signer);
const zeroSupplyToken = await factory.deploy('ZeroSupply', 'ZERO', 0, 18);
await zeroSupplyToken.deployed();
const inputConfig = {
type: TokenFeeType.LinearFee,
owner: signer.address,
token: zeroSupplyToken.address,
bps: 8,
};
const expandedConfig = await EvmTokenFeeModule.expandConfig({
config: inputConfig,
multiProvider,
chainName: test4Chain,
});
assert(expandedConfig.type === TokenFeeType.LinearFee, `Must be ${TokenFeeType.LinearFee}`);
expect(expandedConfig.maxFee > 0n).to.be.true;
expect(expandedConfig.halfAmount > 0n).to.be.true;
expect(expandedConfig.bps).to.equal(8);
const roundTripBps = convertToBps(expandedConfig.maxFee, expandedConfig.halfAmount);
expect(roundTripBps).to.equal(8);
});
it('should expand nested RoutingFee config for zero-supply token', async () => {
const factory = new ERC20Test__factory(signer);
const zeroSupplyToken = await factory.deploy('ZeroSupply', 'ZERO', 0, 18);
await zeroSupplyToken.deployed();
const inputConfig = {
type: TokenFeeType.RoutingFee,
owner: signer.address,
token: zeroSupplyToken.address,
feeContracts: {
[test4Chain]: {
type: TokenFeeType.LinearFee,
owner: signer.address,
token: zeroSupplyToken.address,
bps: 8,
},
},
};
const expandedConfig = await EvmTokenFeeModule.expandConfig({
config: inputConfig,
multiProvider,
chainName: test4Chain,
});
assert(expandedConfig.type === TokenFeeType.RoutingFee, `Must be ${TokenFeeType.RoutingFee}`);
const routingConfig = expandedConfig;
const nestedFee = routingConfig.feeContracts[test4Chain];
assert(nestedFee, 'Nested fee must exist');
assert(nestedFee.type === TokenFeeType.LinearFee, `Nested fee must be ${TokenFeeType.LinearFee}`);
const linearFee = nestedFee;
expect(linearFee.maxFee > 0n).to.be.true;
expect(linearFee.halfAmount > 0n).to.be.true;
expect(linearFee.bps).to.equal(8);
});
it('should expand config with explicit maxFee/halfAmount (no bps) and preserve values', async () => {
const explicitMaxFee = 10000n;
const explicitHalfAmount = 5000n;
const expectedBps = convertToBps(explicitMaxFee, explicitHalfAmount);
// Using type assertion because we're testing the pre-schema-transform input path
// where bps is computed from maxFee/halfAmount at runtime
const inputConfig = {
type: TokenFeeType.LinearFee,
owner: signer.address,
token: token.address,
maxFee: explicitMaxFee,
halfAmount: explicitHalfAmount,
};
const expandedConfig = await EvmTokenFeeModule.expandConfig({
config: inputConfig,
multiProvider,
chainName: test4Chain,
});
assert(expandedConfig.type === TokenFeeType.LinearFee, `Must be ${TokenFeeType.LinearFee}`);
expect(expandedConfig.maxFee).to.equal(explicitMaxFee);
expect(expandedConfig.halfAmount).to.equal(explicitHalfAmount);
expect(expandedConfig.bps).to.equal(expectedBps);
});
it('should expand nested RoutingFee with explicit maxFee/halfAmount in child LinearFee', async () => {
const explicitMaxFee = 10000n;
const explicitHalfAmount = 5000n;
// Using type assertion because we're testing the pre-schema-transform input path
const inputConfig = {
type: TokenFeeType.RoutingFee,
owner: signer.address,
token: token.address,
feeContracts: {
[test4Chain]: {
type: TokenFeeType.LinearFee,
owner: signer.address,
token: token.address,
maxFee: explicitMaxFee,
halfAmount: explicitHalfAmount,
},
},
};
const expandedConfig = await EvmTokenFeeModule.expandConfig({
config: inputConfig,
multiProvider,
chainName: test4Chain,
});
assert(expandedConfig.type === TokenFeeType.RoutingFee, `Must be ${TokenFeeType.RoutingFee}`);
const routingConfig = expandedConfig;
const nestedFee = routingConfig.feeContracts[test4Chain];
assert(nestedFee, 'Nested fee must exist');
assert(nestedFee.type === TokenFeeType.LinearFee, `Nested fee must be ${TokenFeeType.LinearFee}`);
const linearFee = nestedFee;
expect(linearFee.maxFee).to.equal(explicitMaxFee);
expect(linearFee.halfAmount).to.equal(explicitHalfAmount);
});
it('should propagate parent token to nested feeContracts without explicit token', async () => {
const inputConfig = {
type: TokenFeeType.RoutingFee,
owner: signer.address,
token: token.address,
feeContracts: {
[test4Chain]: {
type: TokenFeeType.LinearFee,
owner: signer.address,
bps: 8,
},
},
};
const expandedConfig = await EvmTokenFeeModule.expandConfig({
config: inputConfig,
multiProvider,
chainName: test4Chain,
});
assert(expandedConfig.type === TokenFeeType.RoutingFee, `Must be ${TokenFeeType.RoutingFee}`);
const routingConfig = expandedConfig;
const nestedFee = routingConfig.feeContracts[test4Chain];
assert(nestedFee, 'Nested fee must exist');
expect(nestedFee.token).to.equal(token.address);
});
it('should expand config with fractional bps (1.5)', async () => {
const reader = new EvmTokenFeeReader(multiProvider, test4Chain);
const expected = reader.convertFromBps(1.5);
const inputConfig = {
type: TokenFeeType.LinearFee,
owner: signer.address,
token: token.address,
bps: 1.5,
};
const expandedConfig = await EvmTokenFeeModule.expandConfig({
config: inputConfig,
multiProvider,
chainName: test4Chain,
});
assert(expandedConfig.type === TokenFeeType.LinearFee, `Must be ${TokenFeeType.LinearFee}`);
expect(expandedConfig.maxFee).to.equal(expected.maxFee);
expect(expandedConfig.halfAmount).to.equal(expected.halfAmount);
expect(expandedConfig.bps).to.equal(1.5);
});
});
describe('OffchainQuotedLinearFee', () => {
let offchainConfig;
before(() => {
offchainConfig = TokenFeeConfigSchema.parse({
type: TokenFeeType.OffchainQuotedLinearFee,
owner: signer.address,
token: token.address,
maxFee: MAX_FEE,
halfAmount: HALF_AMOUNT,
bps: BPS,
quoteSigners: [signer.address],
});
});
it('should create and read OffchainQuotedLinearFee', async () => {
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config: offchainConfig,
});
const onchainConfig = await module.read();
expect(normalizeConfig(onchainConfig)).to.deep.equal(normalizeConfig(offchainConfig));
});
it('should not update if configs are the same', async () => {
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config: offchainConfig,
});
const txs = await module.update(offchainConfig);
expect(txs).to.have.lengthOf(0);
});
it('should redeploy if fee params change', async () => {
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config: offchainConfig,
});
const updatedConfig = { ...offchainConfig, bps: BPS + 1 };
await expectTxsAndUpdate(module, updatedConfig, 0);
const onchainConfig = await module.read();
assert(onchainConfig.type === TokenFeeType.OffchainQuotedLinearFee, `Must be ${TokenFeeType.OffchainQuotedLinearFee}`);
expect(onchainConfig.bps).to.eql(updatedConfig.bps);
});
it('should redeploy when transitioning from LinearFee to OffchainQuotedLinearFee', async () => {
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config, // LinearFee
});
// 0 txs because redeploy happens inline
await expectTxsAndUpdate(module, offchainConfig, 0);
const onchainConfig = await module.read();
assert(onchainConfig.type === TokenFeeType.OffchainQuotedLinearFee, `Must be ${TokenFeeType.OffchainQuotedLinearFee}`);
});
it('should update signers without redeploying', async () => {
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config: offchainConfig,
});
const [, otherSigner] = await hre.ethers.getSigners();
const updatedConfig = {
...offchainConfig,
quoteSigners: [signer.address, otherSigner.address],
};
// 1 tx to add the new signer
await expectTxsAndUpdate(module, updatedConfig, 1);
const onchainConfig = await module.read();
assert(onchainConfig.type === TokenFeeType.OffchainQuotedLinearFee, `Must be ${TokenFeeType.OffchainQuotedLinearFee}`);
expect(onchainConfig.quoteSigners).to.have.lengthOf(2);
expect(onchainConfig.quoteSigners).to.include(otherSigner.address);
});
it('should remove signers without redeploying', async () => {
const [, otherSigner] = await hre.ethers.getSigners();
const twoSignerConfig = {
...offchainConfig,
quoteSigners: [signer.address, otherSigner.address],
};
const module = await EvmTokenFeeModule.create({
multiProvider,
chain: test4Chain,
config: twoSignerConfig,
});
const updatedConfig = {
...offchainConfig,
quoteSigners: [signer.address],
};
// 1 tx to remove the other signer
await expectTxsAndUpdate(module, updatedConfig, 1);
const onchainConfig = await module.read();
assert(onchainConfig.type === TokenFeeType.OffchainQuotedLinearFee, `Must be ${TokenFeeType.OffchainQuotedLinearFee}`);
expect(onchainConfig.quoteSigners).to.have.lengthOf(1);
expect(onchainConfig.quoteSigners).to.include(signer.address);
});
});
});
//# sourceMappingURL=EvmTokenFeeModule.hardhat-test.js.map