@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
576 lines • 29.9 kB
JavaScript
import { constants } from 'ethers';
import { CrossCollateralRoutingFee__factory, OffchainQuotedLinearFee__factory, RoutingFee__factory, } from '@hyperlane-xyz/core';
import { ProtocolType, assert, deepEquals, difference, eqAddress, objMap, objMerge, objOmit, promiseObjAll, rootLogger, } from '@hyperlane-xyz/utils';
import { transferOwnershipTransactions } from '../contracts/contracts.js';
import { HyperlaneModule, } from '../core/AbstractHyperlaneModule.js';
import { normalizeConfig } from '../utils/ism.js';
import { EvmTokenFeeDeployer } from './EvmTokenFeeDeployer.js';
import { EvmTokenFeeReader, } from './EvmTokenFeeReader.js';
import { getConfiguredCrossCollateralRouters, getConfiguredRoutingDestinations, mergeCrossCollateralRouters, } from './crossCollateralUtils.js';
import { TokenFeeConfigInputSchema, TokenFeeConfigSchema, TokenFeeType, } from './types.js';
import { convertToBps } from './utils.js';
function getDeployedFeeAddress(contracts, feeType) {
switch (feeType) {
case TokenFeeType.LinearFee:
return contracts.LinearFee.address;
case TokenFeeType.ProgressiveFee:
return contracts.ProgressiveFee.address;
case TokenFeeType.RegressiveFee:
return contracts.RegressiveFee.address;
case TokenFeeType.RoutingFee:
return contracts.RoutingFee.address;
case TokenFeeType.CrossCollateralRoutingFee:
return contracts.CrossCollateralRoutingFee.address;
case TokenFeeType.OffchainQuotedLinearFee:
return contracts.OffchainQuotedLinearFee.address;
}
}
function getResolvedFeeToken(config, fallbackToken) {
return 'token' in config && typeof config.token === 'string'
? config.token
: fallbackToken;
}
function getFallbackTokenFromFeeConfig(config) {
const directToken = getResolvedFeeToken(config);
if (directToken)
return directToken;
if (config.type === TokenFeeType.RoutingFee) {
return Object.values(config.feeContracts)
.map(getFallbackTokenFromFeeConfig)
.find(Boolean);
}
if (config.type === TokenFeeType.CrossCollateralRoutingFee) {
return Object.values(config.feeContracts)
.flatMap((destinationConfig) => Object.values(destinationConfig))
.map(getFallbackTokenFromFeeConfig)
.find(Boolean);
}
return undefined;
}
function requireResolvedFeeToken(config, fallbackToken) {
const resolvedToken = getResolvedFeeToken(config, fallbackToken);
if (!resolvedToken) {
throw new Error(`Token is required to resolve ${config.type} fee config children`);
}
return resolvedToken;
}
function resolveTokenForFeeConfig(config, fallbackToken) {
if (config.type === TokenFeeType.RoutingFee) {
const resolvedToken = requireResolvedFeeToken(config, fallbackToken);
return {
...config,
token: resolvedToken,
feeContracts: Object.fromEntries(Object.entries(config.feeContracts).map(([chain, subFee]) => [
chain,
resolveTokenForFeeConfig(subFee, resolvedToken),
])),
};
}
if (config.type === TokenFeeType.CrossCollateralRoutingFee) {
const nestedFallbackToken = getResolvedFeeToken(config, fallbackToken);
return {
...config,
feeContracts: objMap(config.feeContracts, (_, destinationConfig) => objMap(destinationConfig, (_, subFee) => resolveTokenForFeeConfig(subFee, nestedFallbackToken))),
};
}
return {
...config,
token: requireResolvedFeeToken(config, fallbackToken),
};
}
export class EvmTokenFeeModule extends HyperlaneModule {
multiProvider;
contractVerifier;
static protocols = [ProtocolType.Ethereum, ProtocolType.Tron];
logger = rootLogger.child({ module: 'EvmTokenFeeModule' });
deployer;
reader;
chainName;
chainId;
constructor(multiProvider, params, contractVerifier) {
super(params);
this.multiProvider = multiProvider;
this.contractVerifier = contractVerifier;
this.chainName = multiProvider.getChainName(params.chain);
this.chainId = multiProvider.getDomainId(this.chainName);
this.deployer = new EvmTokenFeeDeployer(multiProvider, this.chainName, {
logger: this.logger,
contractVerifier: contractVerifier,
});
this.reader = new EvmTokenFeeReader(multiProvider, this.chainName);
}
static async create({ multiProvider, chain, config, contractVerifier, }) {
const chainName = multiProvider.getChainName(chain);
const module = new EvmTokenFeeModule(multiProvider, {
addresses: {
deployedFee: constants.AddressZero,
},
chain,
config,
}, contractVerifier);
const contracts = await module.deploy({
multiProvider,
chainName,
contractVerifier,
config,
});
module.args.addresses.deployedFee = getDeployedFeeAddress(contracts[chainName], config.type);
return module;
}
// Processes the Input config to the Final config
// For LinearFee/OffchainQuotedLinearFee, it converts the bps to maxFee and halfAmount
static async expandConfig(params) {
const { config, multiProvider, chainName } = params;
let intermediaryConfig;
if (config.type === TokenFeeType.LinearFee ||
config.type === TokenFeeType.OffchainQuotedLinearFee) {
const { token } = config;
let maxFee;
let halfAmount;
let bps;
const reader = new EvmTokenFeeReader(params.multiProvider, params.chainName);
// Determine which values to use:
// - If maxFee/halfAmount are provided and bps matches what you'd compute from them,
// the user provided explicit values (bps was auto-computed by schema) - use them
// - If bps doesn't match, the user explicitly provided a different bps - use bps
// - If only bps is provided, derive maxFee/halfAmount from bps
if (config.maxFee !== undefined && config.halfAmount !== undefined) {
const explicitMaxFee = BigInt(config.maxFee);
const explicitHalfAmount = BigInt(config.halfAmount);
const computedBps = convertToBps(explicitMaxFee, explicitHalfAmount);
if (config.bps === undefined || config.bps === computedBps) {
// bps was auto-computed or matches - use explicit values
maxFee = explicitMaxFee;
halfAmount = explicitHalfAmount;
bps = computedBps;
}
else {
// User explicitly provided a different bps - use bps-derived values
const derived = reader.convertFromBps(config.bps);
maxFee = derived.maxFee;
halfAmount = derived.halfAmount;
bps = config.bps;
}
}
else if (config.bps !== undefined) {
const derived = reader.convertFromBps(config.bps);
maxFee = derived.maxFee;
halfAmount = derived.halfAmount;
bps = config.bps;
}
else {
throw new Error('LinearFee config must provide either bps or both maxFee and halfAmount');
}
if (config.type === TokenFeeType.OffchainQuotedLinearFee) {
intermediaryConfig = {
type: TokenFeeType.OffchainQuotedLinearFee,
token,
owner: config.owner,
bps,
maxFee,
halfAmount,
quoteSigners: config.quoteSigners,
};
}
else {
intermediaryConfig = {
type: TokenFeeType.LinearFee,
token,
owner: config.owner,
bps,
maxFee,
halfAmount,
};
}
}
else if (config.type === TokenFeeType.RoutingFee) {
const { token, owner } = config;
const feeContracts = await promiseObjAll(objMap(config.feeContracts, async (_, innerConfig) => {
return EvmTokenFeeModule.expandConfig({
config: resolveTokenForFeeConfig(innerConfig, ('token' in innerConfig ? innerConfig.token : undefined) ?? token),
multiProvider,
chainName,
});
}));
intermediaryConfig = {
type: TokenFeeType.RoutingFee,
token,
owner,
feeContracts,
};
}
else if (config.type === TokenFeeType.CrossCollateralRoutingFee) {
const { owner } = config;
const feeContracts = await promiseObjAll(objMap(config.feeContracts, async (_, destinationConfig) => {
return promiseObjAll(objMap(destinationConfig, async (_, innerConfig) => EvmTokenFeeModule.expandConfig({
config: innerConfig,
multiProvider,
chainName,
})));
}));
intermediaryConfig = {
type: TokenFeeType.CrossCollateralRoutingFee,
owner,
feeContracts,
};
}
else {
// Progressive/Regressive fees
intermediaryConfig = {
...config,
maxFee: BigInt(config.maxFee),
halfAmount: BigInt(config.halfAmount),
};
}
return TokenFeeConfigSchema.parse(intermediaryConfig);
}
async deploy(params) {
const deployer = new EvmTokenFeeDeployer(params.multiProvider, params.chainName, {
contractVerifier: params.contractVerifier,
});
return deployer.deploy({ [params.chainName]: params.config });
}
async read(params) {
const address = params?.address ?? this.args.addresses.deployedFee;
const routingDestinations = params?.routingDestinations;
return this.reader.deriveTokenFeeConfig({
address,
routingDestinations,
crossCollateralRouters: params?.crossCollateralRouters,
});
}
// Routing-fee diffs need enough read context to observe every configured
// destination plus any caller-specified CCR router hints for stale entries.
deriveReadParams(targetConfig, params) {
const effectiveParams = { ...params };
if ((targetConfig.type === TokenFeeType.RoutingFee ||
targetConfig.type === TokenFeeType.CrossCollateralRoutingFee) &&
!effectiveParams.routingDestinations) {
effectiveParams.routingDestinations = getConfiguredRoutingDestinations(targetConfig.feeContracts, (chainName) => this.multiProvider.getDomainId(chainName));
}
if (targetConfig.type !== TokenFeeType.CrossCollateralRoutingFee) {
return effectiveParams;
}
const targetCrossCollateralRouters = getConfiguredCrossCollateralRouters(targetConfig.feeContracts, (chainName) => this.multiProvider.getDomainId(chainName));
effectiveParams.crossCollateralRouters = mergeCrossCollateralRouters(effectiveParams.crossCollateralRouters, targetCrossCollateralRouters);
return effectiveParams;
}
shouldRedeploy(actualConfig, targetConfig) {
if (actualConfig.type !== targetConfig.type)
return true;
const mutableFields = { owner: true };
if (targetConfig.type === TokenFeeType.OffchainQuotedLinearFee) {
mutableFields.quoteSigners = true;
}
if (targetConfig.type === TokenFeeType.RoutingFee ||
targetConfig.type === TokenFeeType.CrossCollateralRoutingFee) {
mutableFields.feeContracts = true;
}
return !deepEquals(objOmit(actualConfig, mutableFields), objOmit(targetConfig, mutableFields));
}
/**
* Updates the fee configuration to match the target config.
*
* IMPORTANT: This method may deploy new contracts as a side effect when:
* - Any non-owner diff is detected (triggers redeploy)
*
* These deployments are executed immediately and are NOT included in the returned
* transaction array. The returned transactions only include configuration changes
* (ownership transfers) that callers need to execute.
*
* This behavior is consistent with other Hyperlane SDK modules (EvmIsmModule, EvmHookModule).
*
* @param targetConfig - The desired fee configuration
* @param params - Optional parameters including routingDestinations for reading sub-fees.
* If not provided for RoutingFee configs, destinations are derived from
* targetConfig.feeContracts keys.
* @returns Transactions to execute for configuration updates (does not include deployments)
*/
async update(targetConfig, params) {
TokenFeeConfigInputSchema.parse(targetConfig);
const actualConfig = await this.read(this.deriveReadParams(targetConfig, params));
const normalizedActualConfig = normalizeConfig(actualConfig);
const resolvedTargetConfig = resolveTokenForFeeConfig(targetConfig, getFallbackTokenFromFeeConfig(actualConfig));
const normalizedTargetConfig = normalizeConfig(await EvmTokenFeeModule.expandConfig({
config: resolvedTargetConfig,
multiProvider: this.multiProvider,
chainName: this.chainName,
}));
if (deepEquals(normalizedActualConfig, normalizedTargetConfig)) {
this.logger.debug(`Same config for ${normalizedTargetConfig.type}, no update needed`);
return [];
}
if (this.shouldRedeploy(normalizedActualConfig, normalizedTargetConfig)) {
this.logger.info(`Redeploying ${normalizedTargetConfig.type} due to non-owner config diff`);
const contracts = await this.deploy({
config: normalizedTargetConfig,
multiProvider: this.multiProvider,
chainName: this.chainName,
contractVerifier: this.contractVerifier,
});
this.args.addresses.deployedFee = getDeployedFeeAddress(contracts[this.chainName], normalizedTargetConfig.type);
return [];
}
// OffchainQuotedLinearFee: signers are mutable (fee params handled by shouldRedeploy)
if (normalizedTargetConfig.type === TokenFeeType.OffchainQuotedLinearFee &&
normalizedActualConfig.type === TokenFeeType.OffchainQuotedLinearFee) {
return [
...this.createQuoteSignerUpdateTxs(normalizedActualConfig.quoteSigners, normalizedTargetConfig.quoteSigners),
...this.createOwnershipUpdateTxs(normalizedActualConfig, normalizedTargetConfig),
];
}
// CrossCollateralRoutingFee: update sub-fee contracts (nested structure)
if (normalizedTargetConfig.type === TokenFeeType.CrossCollateralRoutingFee &&
normalizedActualConfig.type === TokenFeeType.CrossCollateralRoutingFee &&
actualConfig.type === TokenFeeType.CrossCollateralRoutingFee) {
const targetFeeContracts = normalizedTargetConfig.feeContracts ?? {};
// Carry actual addresses into target entries, but limit to target keys only so
// orphan entries from actualConfig don't get re-injected into the update loop.
const merged = objMerge(actualConfig, normalizedTargetConfig, 10, true);
if (merged.feeContracts) {
for (const chainName of Object.keys(merged.feeContracts)) {
if (!(chainName in targetFeeContracts)) {
delete merged.feeContracts[chainName];
}
else {
for (const routerBytes32 of Object.keys(merged.feeContracts[chainName])) {
if (!(routerBytes32 in targetFeeContracts[chainName])) {
delete merged.feeContracts[chainName][routerBytes32];
}
}
}
}
}
// Emit clearing transactions for entries removed from target.
const removalDestinations = [];
const removalRouterKeys = [];
const zeroAddresses = [];
for (const [chainName, routerConfigs] of Object.entries(actualConfig.feeContracts ?? {})) {
const targetRouterConfigs = targetFeeContracts[chainName] ?? {};
for (const routerBytes32 of Object.keys(routerConfigs)) {
if (!(routerBytes32 in targetRouterConfigs)) {
removalDestinations.push(this.multiProvider.getDomainId(chainName));
removalRouterKeys.push(routerBytes32);
zeroAddresses.push(constants.AddressZero);
}
}
}
const removalTxs = removalDestinations.length > 0
? [
{
annotation: 'Clearing removed CrossCollateralRoutingFee sub-contract pointers',
chainId: this.chainId,
to: this.args.addresses.deployedFee,
data: CrossCollateralRoutingFee__factory.createInterface().encodeFunctionData('setCrossCollateralRouterFeeContracts', [removalDestinations, removalRouterKeys, zeroAddresses]),
},
]
: [];
return [
...(await this.updateCrossCollateralRoutingFee(merged)),
...removalTxs,
...this.createOwnershipUpdateTxs(normalizedActualConfig, normalizedTargetConfig),
];
}
// Routing fee: update sub-fee contracts
if (normalizedTargetConfig.type === TokenFeeType.RoutingFee &&
normalizedActualConfig.type === TokenFeeType.RoutingFee) {
return [
...(await this.updateRoutingFee(objMerge(actualConfig, normalizedTargetConfig, 10, true))),
...this.createOwnershipUpdateTxs(normalizedActualConfig, normalizedTargetConfig),
];
}
return this.createOwnershipUpdateTxs(normalizedActualConfig, normalizedTargetConfig);
}
async updateCrossCollateralRoutingFee(targetConfig) {
const updateTransactions = [];
if (!targetConfig.feeContracts)
return [];
const currentRoutingAddress = this.args.addresses.deployedFee;
// Validate all destination chains and collect domain IDs upfront so that an unknown
// chain name fails before any sub-fee deployments are attempted.
const domainIdByChain = new Map();
for (const chainName of Object.keys(targetConfig.feeContracts)) {
domainIdByChain.set(chainName, this.multiProvider.getDomainId(chainName));
}
// Deduplicate update work for shared addresses (old address → deployed address after update).
// Multiple (chainName, routerBytes32) pairs may point to the same physical contract; we only
// run the update once per (address, config) pair. If two entries share an address but have
// divergent target configs (split case), each gets its own deployment.
const updatedByAddress = new Map();
// Per-entry deployed address, keyed by "chainName:routerBytes32".
const entryDeployedAddr = new Map();
for (const [chainName, routerConfigs] of Object.entries(targetConfig.feeContracts)) {
for (const [routerBytes32, subFeeConfig] of Object.entries(routerConfigs)) {
assert(/^0x[0-9a-fA-F]{64}$/.test(routerBytes32), `routerBytes32 key "${routerBytes32}" for chain ${chainName} is not a valid 32-byte hex string`);
const address = subFeeConfig.address;
const entryKey = `${chainName}:${routerBytes32}`;
if (!address) {
// No existing sub-fee contract — deploy a new one
this.logger.info(`No existing sub-fee contract for ${chainName}/${routerBytes32}, deploying new one`);
const subFeeModule = await EvmTokenFeeModule.create({
multiProvider: this.multiProvider,
chain: this.chainName,
config: subFeeConfig,
contractVerifier: this.contractVerifier,
});
const deployedSubFee = subFeeModule.serialize().deployedFee;
this.logger.debug(`New cross-collateral sub-fee contract deployed at ${deployedSubFee} for ${chainName}/${routerBytes32}`);
entryDeployedAddr.set(entryKey, deployedSubFee);
}
else {
const addrKey = address.toLowerCase();
const cached = updatedByAddress.get(addrKey);
const configMatches = cached && deepEquals(cached.config, subFeeConfig);
if (!cached || !configMatches) {
if (cached && !configMatches) {
// Same physical address but divergent target config — this is a route split.
// Deploy a fresh sub-fee contract rather than reusing the other entry's result.
this.logger.info(`Cross-collateral sub-fee config diverged for ${chainName}/${routerBytes32} at ${address}, deploying new contract`);
const subFeeModule = await EvmTokenFeeModule.create({
multiProvider: this.multiProvider,
chain: this.chainName,
config: subFeeConfig,
contractVerifier: this.contractVerifier,
});
const deployedSubFee = subFeeModule.serialize().deployedFee;
this.logger.debug(`New cross-collateral sub-fee contract deployed at ${deployedSubFee} for ${chainName}/${routerBytes32}`);
entryDeployedAddr.set(entryKey, deployedSubFee);
continue;
}
// First time we see this address — run the update
const subFeeModule = new EvmTokenFeeModule(this.multiProvider, {
addresses: { deployedFee: address },
chain: this.chainName,
config: subFeeConfig,
}, this.contractVerifier);
const subFeeUpdateTransactions = await subFeeModule.update(subFeeConfig, { address });
updateTransactions.push(...subFeeUpdateTransactions);
const deployedSubFeeAddr = subFeeModule.serialize().deployedFee;
if (!eqAddress(deployedSubFeeAddr, address)) {
this.logger.debug(`Cross-collateral sub-fee redeployed: ${address} → ${deployedSubFeeAddr} for ${chainName}/${routerBytes32}`);
}
updatedByAddress.set(addrKey, {
config: subFeeConfig,
deployedAddr: deployedSubFeeAddr,
});
}
const entry = updatedByAddress.get(addrKey);
assert(entry !== undefined, `Missing deployed fee for ${addrKey}`);
entryDeployedAddr.set(entryKey, entry.deployedAddr);
}
}
}
// Build setCrossCollateralRouterFeeContracts args for entries whose pointer changed
const destinations = [];
const routerKeys = [];
const newAddresses = [];
for (const [chainName, routerConfigs] of Object.entries(targetConfig.feeContracts)) {
const domainId = domainIdByChain.get(chainName);
assert(domainId !== undefined, `Domain ID not found for ${chainName}`);
for (const [routerBytes32, subFeeConfig] of Object.entries(routerConfigs)) {
const entryKey = `${chainName}:${routerBytes32}`;
const deployedSubFee = entryDeployedAddr.get(entryKey);
assert(deployedSubFee !== undefined, `Missing deployed fee for entry ${entryKey}`);
const oldAddr = subFeeConfig.address;
if (!oldAddr || !eqAddress(deployedSubFee, oldAddr)) {
destinations.push(domainId);
routerKeys.push(routerBytes32);
newAddresses.push(deployedSubFee);
}
}
}
if (destinations.length > 0) {
updateTransactions.push({
annotation: 'Updating CrossCollateralRoutingFee sub-contract pointers',
chainId: this.chainId,
to: currentRoutingAddress,
data: CrossCollateralRoutingFee__factory.createInterface().encodeFunctionData('setCrossCollateralRouterFeeContracts', [destinations, routerKeys, newAddresses]),
});
}
return updateTransactions;
}
async updateRoutingFee(targetConfig) {
const updateTransactions = [];
if (!targetConfig.feeContracts)
return [];
const currentRoutingAddress = this.args.addresses.deployedFee;
for (const [chainName, config] of Object.entries(targetConfig.feeContracts)) {
const address = config.address;
let subFeeModule;
let deployedSubFee;
if (!address) {
// Sub-fee contract doesn't exist yet, deploy a new one
this.logger.info(`No existing sub-fee contract for ${chainName}, deploying new one`);
subFeeModule = await EvmTokenFeeModule.create({
multiProvider: this.multiProvider,
chain: this.chainName,
config,
contractVerifier: this.contractVerifier,
});
deployedSubFee = subFeeModule.serialize().deployedFee;
const annotation = `New sub fee contract deployed. Setting contract for ${chainName} to ${deployedSubFee}`;
this.logger.debug(annotation);
updateTransactions.push({
annotation: annotation,
chainId: this.chainId,
to: currentRoutingAddress,
data: RoutingFee__factory.createInterface().encodeFunctionData('setFeeContract(uint32,address)', [this.multiProvider.getDomainId(chainName), deployedSubFee]),
});
}
else {
// Update existing sub-fee contract
subFeeModule = new EvmTokenFeeModule(this.multiProvider, {
addresses: {
deployedFee: address,
},
chain: this.chainName,
config,
}, this.contractVerifier);
const subFeeUpdateTransactions = await subFeeModule.update(config, {
address,
});
deployedSubFee = subFeeModule.serialize().deployedFee;
updateTransactions.push(...subFeeUpdateTransactions);
if (!eqAddress(deployedSubFee, address)) {
const annotation = `Sub fee contract redeployed on chain ${this.chainName}. Updating fee contract for destination ${chainName} to ${deployedSubFee}`;
this.logger.debug(annotation);
updateTransactions.push({
annotation: annotation,
chainId: this.chainId,
to: currentRoutingAddress,
data: RoutingFee__factory.createInterface().encodeFunctionData('setFeeContract(uint32,address)', [this.multiProvider.getDomainId(chainName), deployedSubFee]),
});
}
}
}
return updateTransactions;
}
createQuoteSignerUpdateTxs(actualSigners, targetSigners) {
const txs = [];
const iface = OffchainQuotedLinearFee__factory.createInterface();
const contractAddress = this.args.addresses.deployedFee;
const actualSet = new Set((actualSigners ?? []).map((s) => s.toLowerCase()));
const targetSet = new Set((targetSigners ?? []).map((s) => s.toLowerCase()));
for (const signer of difference(targetSet, actualSet)) {
txs.push({
annotation: `Add quote signer ${signer}`,
chainId: this.chainId,
to: contractAddress,
data: iface.encodeFunctionData('addQuoteSigner', [signer]),
});
}
for (const signer of difference(actualSet, targetSet)) {
txs.push({
annotation: `Remove quote signer ${signer}`,
chainId: this.chainId,
to: contractAddress,
data: iface.encodeFunctionData('removeQuoteSigner', [signer]),
});
}
return txs;
}
createOwnershipUpdateTxs(actualConfig, expectedConfig) {
return transferOwnershipTransactions(this.multiProvider.getEvmChainId(this.args.chain), this.args.addresses.deployedFee, actualConfig, expectedConfig, `${expectedConfig.type} Warp Route`);
}
}
//# sourceMappingURL=EvmTokenFeeModule.js.map