UNPKG

@hyperlane-xyz/sdk

Version:

The official SDK for the Hyperlane Network

576 lines 29.9 kB
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