UNPKG

@hyperlane-xyz/sdk

Version:

The official SDK for the Hyperlane Network

992 lines 53 kB
import { compareVersions } from 'compare-versions'; import { BigNumber, Contract, constants } from 'ethers'; import { CrossCollateralRouter__factory, EverclearTokenBridge__factory, PredicateRouterWrapper__factory, HypERC20Collateral__factory, HypERC20__factory, HypERC4626Collateral__factory, HypERC4626OwnerCollateral__factory, HypERC4626__factory, HypXERC20Lockbox__factory, HypXERC20__factory, IFiatToken__factory, IMessageTransmitter__factory, ISafe__factory, IWETH__factory, IXERC20__factory, MovableCollateralRouter__factory, OpL1NativeTokenBridge__factory, OpL2NativeTokenBridge__factory, Ownable__factory, PackageVersioned__factory, ProxyAdmin__factory, TokenBridgeOft__factory, TokenBridgeCctpBase__factory, TokenBridgeCctpV2__factory, TokenBridgeDepositAddress__factory, TokenRouter__factory, } from '@hyperlane-xyz/core'; import { buildArtifact as coreBuildArtifact } from '@hyperlane-xyz/core/buildArtifact.js'; import { arrayToObject, assert, eqAddress, getLogLevel, isZeroish, isZeroishAddress, objFilter, objMap, promiseObjAll, rootLogger, strip0x, } from '@hyperlane-xyz/utils'; import { ExplorerLicenseType } from '../block-explorer/etherscan.js'; import { DEFAULT_CONTRACT_READ_CONCURRENCY } from '../consts/concurrency.js'; import { isAddressActive } from '../contracts/contracts.js'; import { ContractVerifier } from '../deploy/verify/ContractVerifier.js'; import { VerifyContractTypes } from '../deploy/verify/types.js'; import { EvmTokenFeeReader, } from '../fee/EvmTokenFeeReader.js'; import { EvmHookReader } from '../hook/EvmHookReader.js'; import { HookType, OnchainHookType } from '../hook/types.js'; import { EvmIsmReader } from '../ism/EvmIsmReader.js'; import { EvmRouterReader } from '../router/EvmRouterReader.js'; import { isMissingSelectorCallException, throwIfNotMissingSelector, } from '../utils/contract.js'; import { isProxy, isStorageEmpty, proxyAdmin, proxyImplementation, } from './../deploy/proxy.js'; import { NON_ZERO_SENDER_ADDRESS, TokenType } from './config.js'; import { ContractVerificationStatus, HypTokenConfigSchema, OwnerStatus, XERC20Type, isMovableCollateralTokenConfig, isCrossCollateralTokenConfig, } from './types.js'; import { getExtraLockBoxConfigs } from './xerc20.js'; const REBALANCING_CONTRACT_VERSION = '8.0.0'; export const TOKEN_FEE_CONTRACT_VERSION = '10.0.0'; // version that introduced the fractional scale interface const SCALE_FRACTION_VERSION = '11.0.0'; // version that introduced the legacy scale interface // https://github.com/hyperlane-xyz/hyperlane-monorepo/releases/tag/%40hyperlane-xyz%2Fcore%406.0.0 const SCALE_VERSION = '6.0.0'; // Version that first introduced ppm precision for CCTP V2 fee storage (was bps before) export const CCTP_PPM_STORAGE_VERSION = '10.2.0'; // Version that renamed maxFeeBps() to maxFeePpm() on-chain export const CCTP_PPM_PRECISION_VERSION = '11.0.0'; export class EvmWarpRouteReader extends EvmRouterReader { multiProvider; chain; concurrency; logger = rootLogger.child({ module: 'EvmWarpRouteReader', }); depositAddressDomainConfigsCache = new Map(); // Using null instead of undefined to force // a compile error when adding a new token type deriveTokenConfigMap; evmHookReader; evmIsmReader; evmTokenFeeReader; contractVerifier; constructor(multiProvider, chain, concurrency = DEFAULT_CONTRACT_READ_CONCURRENCY, contractVerifier) { super(multiProvider, chain); this.multiProvider = multiProvider; this.chain = chain; this.concurrency = concurrency; this.evmHookReader = new EvmHookReader(multiProvider, chain, concurrency); this.evmIsmReader = new EvmIsmReader(multiProvider, chain, concurrency); this.evmTokenFeeReader = new EvmTokenFeeReader(multiProvider, chain); this.deriveTokenConfigMap = { [TokenType.XERC20]: this.deriveHypXERC20TokenConfig.bind(this), [TokenType.XERC20Lockbox]: this.deriveHypXERC20LockboxTokenConfig.bind(this), [TokenType.collateral]: this.deriveHypCollateralTokenConfig.bind(this), [TokenType.collateralFiat]: this.deriveHypCollateralFiatTokenConfig.bind(this), [TokenType.collateralVault]: this.deriveHypCollateralVaultTokenConfig.bind(this), [TokenType.collateralCctp]: this.deriveHypCollateralCctpTokenConfig.bind(this), [TokenType.collateralVaultRebase]: this.deriveHypCollateralVaultRebaseTokenConfig.bind(this), [TokenType.native]: this.deriveHypNativeTokenConfig.bind(this), [TokenType.nativeOpL2]: this.deriveOpL2TokenConfig.bind(this), [TokenType.nativeOpL1]: this.deriveOpL1TokenConfig.bind(this), [TokenType.synthetic]: this.deriveHypSyntheticTokenConfig.bind(this), [TokenType.unknown]: null, [TokenType.syntheticRebase]: this.deriveHypSyntheticRebaseConfig.bind(this), [TokenType.nativeScaled]: null, [TokenType.collateralUri]: null, [TokenType.syntheticUri]: null, [TokenType.ethEverclear]: this.deriveEverclearEthTokenBridgeConfig.bind(this), [TokenType.collateralEverclear]: this.deriveEverclearCollateralTokenBridgeConfig.bind(this), [TokenType.collateralDepositAddress]: this.deriveHypCollateralDepositAddressTokenConfig.bind(this), [TokenType.collateralOft]: this.deriveHypCollateralOftTokenConfig.bind(this), [TokenType.crossCollateral]: this.deriveCrossCollateralTokenConfig.bind(this), }; this.contractVerifier = contractVerifier ?? new ContractVerifier(multiProvider, {}, coreBuildArtifact, ExplorerLicenseType.MIT); } /** * Derives the configuration for a Hyperlane warp route token router contract at the given address. * * @param warpRouteAddress - The address of the Hyperlane warp route token router contract. * @returns The configuration for the Hyperlane warp route token router. * */ async deriveWarpRouteConfig(warpRouteAddress) { // Derive the config type const type = await this.deriveTokenType(warpRouteAddress); const tokenConfig = await this.fetchTokenConfig(type, warpRouteAddress); const isDepositAddressBridge = type === TokenType.collateralDepositAddress; // OFT and deposit-address bridges don't expose Router/MailboxClient interfaces. const isOft = type === TokenType.collateralOft; const usesSentinelRouterConfig = isDepositAddressBridge || isOft; const routerConfig = usesSentinelRouterConfig ? { mailbox: constants.AddressZero, owner: await Ownable__factory.connect(warpRouteAddress, this.provider).owner(), hook: constants.AddressZero, interchainSecurityModule: constants.AddressZero, remoteRouters: {}, } : await this.readRouterConfig(warpRouteAddress); // if the token has not been deployed as a proxy do not derive the config // inevm warp routes are an example const proxyAdmin = (await isProxy(this.provider, warpRouteAddress)) ? await this.fetchProxyAdminConfig(warpRouteAddress) : undefined; const ccrEnrolledDomains = []; if (isCrossCollateralTokenConfig(tokenConfig) && tokenConfig.crossCollateralRouters) { for (const domain of Object.keys(tokenConfig.crossCollateralRouters)) { ccrEnrolledDomains.push(Number(domain)); } } // OFT contracts don't have destination gas config // For CrossCollateralRouter tokens, include domains from crossCollateralRouters so // fetchDestinationGas also reads gas for MC-only enrolled domains. let destinationGas; if (usesSentinelRouterConfig) { destinationGas = undefined; } else { destinationGas = await this.fetchDestinationGas(warpRouteAddress, ccrEnrolledDomains); } assert(tokenConfig.contractVersion, `Missing contractVersion for ${warpRouteAddress} on ${this.chain}`); const hasRebalancingInterface = compareVersions(tokenConfig.contractVersion, REBALANCING_CONTRACT_VERSION) >= 0; let allowedRebalancers; let allowedRebalancingBridges; let domains; // Only movable collateral tokens (collateral/native) have rebalancing config if (hasRebalancingInterface && isMovableCollateralTokenConfig(tokenConfig)) { const movableToken = MovableCollateralRouter__factory.connect(warpRouteAddress, this.provider); try { allowedRebalancers = await MovableCollateralRouter__factory.connect(warpRouteAddress, this.provider).allowedRebalancers(); } catch (error) { // If this crashes it probably is because the token implementation has not been updated to be a movable collateral this.logger.error(`Failed to get configured rebalancers for token at "${warpRouteAddress}" on chain ${this.chain}`, error); } try { domains = await movableToken.domains(); const allowedBridgesByDomain = await promiseObjAll(objMap(arrayToObject(domains.map((domain) => domain.toString())), (domain) => movableToken.allowedBridges(domain))); allowedRebalancingBridges = objFilter(objMap(allowedBridgesByDomain, (_domain, bridges) => bridges.map((bridge) => ({ bridge }))), // Remove domains that do not have allowed bridges (_domain, bridges) => bridges.length !== 0); } catch (error) { // If this crashes it probably is because the token implementation has not been updated to be a movable collateral this.logger.error(`Failed to get allowed rebalancer bridges for token at "${warpRouteAddress}" on chain ${this.chain}`, error); } } // Use both router.domains() and CCR-enrolled domains for token fee derivation. // Unlike destinationGas, token fees may legitimately exist for self-domain // same-chain CCR swaps, so keep selfDomain in the destination set here. const feeDestinations = [ ...new Set([...(domains ?? []), ...ccrEnrolledDomains]), ]; const tokenFee = await this.fetchTokenFee(warpRouteAddress, feeDestinations.length ? feeDestinations : undefined, isCrossCollateralTokenConfig(tokenConfig) ? tokenConfig.crossCollateralRouters : undefined); // CCTP tokens implement their own ISM (the contract itself acts as the ISM via AbstractCcipReadIsm). // The ISM is hardcoded and not configurable, so we return zero address to match deploy config expectations. if (type === TokenType.collateralCctp && 'interchainSecurityModule' in routerConfig) { routerConfig.interchainSecurityModule = constants.AddressZero; } const predicateWrapper = await this.derivePredicateWrapperConfig(routerConfig.hook, warpRouteAddress); const derivedConfig = { ...routerConfig, ...tokenConfig, allowedRebalancers, allowedRebalancingBridges, proxyAdmin, destinationGas, tokenFee, ...(predicateWrapper && { predicateWrapper }), }; return derivedConfig; } /** * Searches the derived hook tree for a PredicateRouterWrapper and, if found, * reads its on-chain config (registry, policyId, owner). * * EvmHookReader.preserveUnredeployable() stores PREDICATE sub-hooks as bare address * strings (to survive normalizeConfig and deploy's string branch). The sync * findPredicateAddressInHook() returns undefined for bare strings, so we fall back to * an on-chain hookType() probe on bare string sub-hooks of aggregation hooks. */ async derivePredicateWrapperConfig(hook, warpRouteAddress) { let predicateAddress = this.findPredicateAddressInHook(hook); if (!predicateAddress && typeof hook !== 'string' && hook?.type === HookType.AGGREGATION) { for (const sub of hook.hooks) { if (typeof sub !== 'string') continue; try { const candidate = PredicateRouterWrapper__factory.connect(sub, this.provider); const [hookType, warpRoute] = await Promise.all([ candidate.hookType(), candidate.warpRoute(), ]); if (hookType === OnchainHookType.PREDICATE_ROUTER_WRAPPER && eqAddress(warpRoute, warpRouteAddress)) { predicateAddress = sub; break; } } catch (error) { throwIfNotMissingSelector(error); // Not a PredicateRouterWrapper — continue } } } if (!predicateAddress) return undefined; const wrapper = PredicateRouterWrapper__factory.connect(predicateAddress, this.provider); const [predicateRegistry, policyId, owner] = await Promise.all([ wrapper.getRegistry(), wrapper.getPolicyID(), wrapper.owner(), ]); return { predicateRegistry, policyId, owner }; } findPredicateAddressInHook(hook) { if (!hook || typeof hook === 'string') return undefined; if (hook.type === HookType.PREDICATE) return hook.address; if (hook.type === HookType.AGGREGATION) { for (const sub of hook.hooks) { const found = this.findPredicateAddressInHook(sub); if (found) return found; } } return undefined; } async fetchTokenFee(routerAddress, destinations, crossCollateralRouters) { const TokenRouter = TokenRouter__factory.connect(routerAddress, this.provider); const [packageVersion, tokenFee] = await Promise.all([ this.fetchPackageVersion(routerAddress), TokenRouter.feeRecipient().catch((error) => { throwIfNotMissingSelector(error); this.logger.debug(`Failed to read feeRecipient for token at address "${routerAddress}" on chain "${this.chain}", defaulting to AddressZero`, error); return constants.AddressZero; }), ]); const hasTokenFeeInterface = compareVersions(packageVersion, TOKEN_FEE_CONTRACT_VERSION) >= 0; if (!hasTokenFeeInterface) { this.logger.debug(`Token at address "${routerAddress}" on chain "${this.chain}" does not have a token fee interface`); return undefined; } if (isZeroishAddress(tokenFee)) { this.logger.debug(`Token at address "${routerAddress}" on chain "${this.chain}" has a no token fee`); return undefined; } const routingDestinations = destinations ?? (await TokenRouter.domains().catch((error) => { throwIfNotMissingSelector(error); this.logger.debug(`Failed to derive token router domains for routing fee config on "${this.chain}"`, error); return undefined; })); const normalizedCrossCollateralRouters = crossCollateralRouters ? Object.fromEntries(Object.entries(crossCollateralRouters).map(([domain, routers]) => [ Number(domain), routers, ])) : undefined; return this.evmTokenFeeReader.deriveTokenFeeConfig({ address: tokenFee, routingDestinations, crossCollateralRouters: normalizedCrossCollateralRouters, }); } async getContractVerificationStatus(chain, address) { const contractVerificationStatus = {}; const contractType = (await isProxy(this.provider, address)) ? VerifyContractTypes.Proxy : VerifyContractTypes.Implementation; if (this.multiProvider.isLocalRpc(chain)) { this.logger.debug('Skipping verification for local endpoints'); return { [contractType]: ContractVerificationStatus.Skipped }; } const quietVerificationLogger = this.logger.child({ module: 'contract-verifier' }, { level: 'silent' }); contractVerificationStatus[contractType] = await this.contractVerifier.getContractVerificationStatus(chain, address, quietVerificationLogger); if (contractType === VerifyContractTypes.Proxy) { contractVerificationStatus[VerifyContractTypes.Implementation] = await this.contractVerifier.getContractVerificationStatus(chain, await proxyImplementation(this.provider, address), quietVerificationLogger); // Derive ProxyAdmin status contractVerificationStatus[VerifyContractTypes.ProxyAdmin] = await this.contractVerifier.getContractVerificationStatus(chain, await proxyAdmin(this.provider, address), quietVerificationLogger); } return contractVerificationStatus; } async getOwnerStatus(chain, address) { let ownerStatus = {}; if (this.multiProvider.isLocalRpc(chain)) { this.logger.debug('Skipping owner verification for local endpoints'); return { [address]: OwnerStatus.Skipped, }; } const provider = this.multiProvider.getProvider(chain); const owner = await Ownable__factory.connect(address, provider).owner(); ownerStatus[owner] = (await isAddressActive(provider, owner)) ? OwnerStatus.Active : OwnerStatus.Inactive; // Heuristically check if the owner could be a safe by calling expected functions // This status will overwrite 'active' status try { const potentialGnosisSafe = ISafe__factory.connect(owner, provider); await Promise.all([ potentialGnosisSafe.getThreshold(), potentialGnosisSafe.nonce(), ]); ownerStatus[owner] = OwnerStatus.GnosisSafe; } catch { this.logger.debug(`${owner} may not be a safe`); } // Check Proxy admin and implementation recursively const contractType = (await isProxy(this.provider, address)) ? VerifyContractTypes.Proxy : VerifyContractTypes.Implementation; if (contractType === VerifyContractTypes.Proxy) { const [proxyStatus, implementationStatus] = await Promise.all([ this.getOwnerStatus(chain, await proxyAdmin(provider, address)), this.getOwnerStatus(chain, await proxyImplementation(this.provider, address)), ]); ownerStatus = { ...ownerStatus, ...proxyStatus, ...implementationStatus, }; } return ownerStatus; } async deriveWarpRouteVirtualConfig(chain, address) { const virtualConfig = { contractVerificationStatus: await this.getContractVerificationStatus(chain, address), // Used to check if the top address owner's nonce or code === 0 ownerStatus: await this.getOwnerStatus(chain, address), }; return virtualConfig; } /** * Derives the token type for a given Warp Route address using specific methods * * @param warpRouteAddress - The Warp Route address to derive the token type for. * @returns The derived token type, which can be one of: collateralVault, collateral, native, or synthetic. */ async deriveTokenType(warpRouteAddress) { const contractTypes = { [TokenType.collateralVault]: { factory: HypERC4626OwnerCollateral__factory, method: 'assetDeposited', }, [TokenType.collateralVaultRebase]: { factory: HypERC4626Collateral__factory, method: 'NULL_RECIPIENT', }, [TokenType.XERC20Lockbox]: { factory: HypXERC20Lockbox__factory, method: 'lockbox', }, [TokenType.collateralDepositAddress]: { factory: TokenBridgeDepositAddress__factory, method: 'getDomainConfigs', }, [TokenType.collateralOft]: { factory: TokenBridgeOft__factory, method: 'oft', }, [TokenType.collateralCctp]: { factory: TokenBridgeCctpBase__factory, method: 'messageTransmitter', }, [TokenType.collateral]: { factory: HypERC20Collateral__factory, method: 'wrappedToken', }, [TokenType.syntheticRebase]: { factory: HypERC4626__factory, method: 'collateralDomain', }, }; // Temporarily turn off SmartProvider logging // Provider errors are expected because deriving will call methods that may not exist in the Bytecode this.setSmartProviderLogLevel('silent'); try { // Fetch implementation bytecode once; scanning selectors locally avoids // reverted eth_calls for methods that don't exist on the contract. // Read the EIP-1967 impl slot directly so UUPS proxies (which have // an empty admin slot) are resolved correctly alongside TransparentProxy. // Wrapped in try/catch so EOAs / bad addresses don't throw here — bytecode // will be '0x' and the selector guard falls through to probes as pre-PR. let implAddress = warpRouteAddress; try { const impl = await proxyImplementation(this.provider, warpRouteAddress); if (!isZeroishAddress(impl)) implAddress = impl; } catch { // not a proxy or address has no code — use warpRouteAddress directly } const bytecode = await this.provider.getCode(implAddress); // First, try checking token specific methods for (const [tokenType, { factory, method }] of Object.entries(contractTypes)) { // Skip if selector absent from bytecode — avoids reverted eth_calls. // When bytecode is unavailable ('0x'), fall through to the probe anyway // to preserve pre-optimization behavior on zero-impl / flaky-RPC paths. const selector = factory.createInterface().getSighash(method); if (!isStorageEmpty(bytecode) && !bytecode.includes(strip0x(selector))) continue; try { const warpRoute = factory.connect(warpRouteAddress, this.provider); const result = await warpRoute[method](); if (tokenType === TokenType.collateralDepositAddress) { this.depositAddressDomainConfigsCache.set(warpRouteAddress, result); } if (tokenType === TokenType.collateral) { const wrappedToken = await warpRoute.wrappedToken(); try { const xerc20 = IXERC20__factory.connect(wrappedToken, this.provider); await xerc20['mintingCurrentLimitOf(address)'](warpRouteAddress); return TokenType.XERC20; } catch (error) { throwIfNotMissingSelector(error); this.logger.debug(`Warp route token at address "${warpRouteAddress}" on chain "${this.chain}" is not a ${TokenType.XERC20}`, error); } try { const fiatToken = IFiatToken__factory.connect(wrappedToken, this.provider); // Simulate minting tokens from the warp route contract await fiatToken.callStatic.mint(NON_ZERO_SENDER_ADDRESS, 1, { from: warpRouteAddress, }); return TokenType.collateralFiat; } catch (error) { this.logger.debug(`Warp route token at address "${warpRouteAddress}" on chain "${this.chain}" is not a ${TokenType.collateralFiat}`, error); } try { const maybeEverclearTokenBridge = EverclearTokenBridge__factory.connect(warpRouteAddress, this.provider); await maybeEverclearTokenBridge.callStatic.everclearAdapter(); let everclearTokenType = TokenType.collateralEverclear; try { // if simulating an ETH transfer works this should be the WETH contract await this.provider.estimateGas({ from: NON_ZERO_SENDER_ADDRESS, to: wrappedToken, data: IWETH__factory.createInterface().encodeFunctionData('deposit'), value: 0, }); everclearTokenType = TokenType.ethEverclear; } catch (error) { this.logger.debug(`Warp route token at address "${warpRouteAddress}" on chain "${this.chain}" is not a ${TokenType.collateralEverclear}`, error); } return everclearTokenType; } catch (error) { throwIfNotMissingSelector(error); this.logger.debug(`Warp route token at address "${warpRouteAddress}" on chain "${this.chain}" is not a ${TokenType.collateralEverclear}`, error); } try { const crossCollateralRouter = CrossCollateralRouter__factory.connect(warpRouteAddress, this.provider); await crossCollateralRouter.getCrossCollateralRouters(0); return TokenType.crossCollateral; } catch (error) { throwIfNotMissingSelector(error); this.logger.debug(`Warp route token at address "${warpRouteAddress}" on chain "${this.chain}" is not a ${TokenType.crossCollateral}`, error); } } return tokenType; } catch (error) { throwIfNotMissingSelector(error); continue; } } const packageVersion = await this.fetchPackageVersion(warpRouteAddress); const hasTokenFeeInterface = compareVersions(packageVersion, TOKEN_FEE_CONTRACT_VERSION) >= 0; const isNativeToken = await this.isNativeWarpToken(warpRouteAddress, hasTokenFeeInterface); if (isNativeToken) { return TokenType.native; } const isSyntheticToken = await this.isSyntheticWarpToken(warpRouteAddress, hasTokenFeeInterface); if (isSyntheticToken) { return TokenType.synthetic; } throw new Error(`Error deriving token type for token at address "${warpRouteAddress}" on chain "${this.chain}"`); } finally { this.setSmartProviderLogLevel(getLogLevel()); } } async isNativeWarpToken(warpRouteAddress, hasTokenFeeInterface) { try { if (hasTokenFeeInterface) { const tokenRouter = TokenRouter__factory.connect(warpRouteAddress, this.provider); const tokenAddress = await tokenRouter.token(); // Native token returns address(0) return isZeroishAddress(tokenAddress); } else { // Check native using estimateGas to send 0 wei. Success implies that the Warp Route has a receive() function await this.multiProvider.estimateGas(this.chain, { to: warpRouteAddress, value: BigNumber.from(0), }, NON_ZERO_SENDER_ADDRESS); return true; } } catch (e) { this.logger.debug(`Warp route token at address "${warpRouteAddress}" on chain "${this.chain}" is not a ${TokenType.native}`, e); return false; } } async isSyntheticWarpToken(warpRouteAddress, hasTokenFeeInterface) { try { if (hasTokenFeeInterface) { const tokenRouter = TokenRouter__factory.connect(warpRouteAddress, this.provider); const tokenAddress = await tokenRouter.token(); // HypERC20.token() returns address(this) return eqAddress(tokenAddress, warpRouteAddress); } else { const tokenRouter = HypERC20__factory.connect(warpRouteAddress, this.provider); await tokenRouter.decimals(); return true; } } catch (error) { this.logger.debug(`Warp route token at address "${warpRouteAddress}" on chain "${this.chain}" is not a ${TokenType.synthetic}`, error); return false; } } async fetchXERC20Config(xERC20Address, warpRouteAddress) { // fetch the limits if possible const rateLimitsABI = [ 'function rateLimitPerSecond(address) external view returns (uint128)', 'function bufferCap(address) external view returns (uint112)', ]; const xERC20 = new Contract(xERC20Address, rateLimitsABI, this.provider); let extraBridgesLimits; try { extraBridgesLimits = await getExtraLockBoxConfigs({ chain: this.chain, multiProvider: this.multiProvider, xERC20Address, logger: this.logger, }); } catch (error) { if (!isMissingSelectorCallException(error)) throw error; this.logger.warn(`Skipping extra xERC20 lockbox configs after missing-selector error for token at ${xERC20Address} on chain ${this.chain}`, error); } try { // TODO: fix this such that it fetches from WL's values too return { xERC20: { warpRouteLimits: { type: XERC20Type.Velo, rateLimitPerSecond: (await xERC20.rateLimitPerSecond(warpRouteAddress)).toString(), bufferCap: (await xERC20.bufferCap(warpRouteAddress)).toString(), }, extraBridges: extraBridgesLimits && extraBridgesLimits.length > 0 ? extraBridgesLimits : undefined, }, }; } catch (error) { if (isMissingSelectorCallException(error)) return {}; this.logger.error(`Error fetching xERC20 limits for token at ${xERC20Address} on chain ${this.chain}`, error); throw error; } } /** * Fetches the metadata for a token address. * * @param warpRouteAddress - The address of the token. * @returns A partial ERC20 metadata object containing the token name, symbol, total supply, and decimals. * Throws if unsupported token type */ async fetchTokenConfig(type, warpRouteAddress) { const deriveFunction = this.deriveTokenConfigMap[type]; if (!deriveFunction) { throw new Error(`Provided unsupported token type "${type}" when fetching token metadata on chain "${this.chain}" at address "${warpRouteAddress}"`); } const config = await deriveFunction(warpRouteAddress); config.contractVersion = await this.fetchPackageVersion(warpRouteAddress); // Convert ppm to bps for CCTP V2 contracts that store fees in ppm (>= 10.2.0) if (config.type === TokenType.collateralCctp && config.cctpVersion === 'V2' && config.maxFeeBps !== undefined && config.contractVersion && compareVersions(config.contractVersion, CCTP_PPM_STORAGE_VERSION) >= 0) { config.maxFeeBps = config.maxFeeBps / 100; } return HypTokenConfigSchema.parse(config); } async deriveHypXERC20TokenConfig(hypTokenAddress) { const hypXERC20TokenInstance = HypXERC20__factory.connect(hypTokenAddress, this.provider); const collateralTokenAddress = await hypXERC20TokenInstance.wrappedToken(); const [erc20TokenMetadata, xERC20Metadata, scale] = await Promise.all([ this.fetchERC20Metadata(collateralTokenAddress), this.fetchXERC20Config(collateralTokenAddress, hypTokenAddress), this.fetchScale(hypTokenAddress), ]); return { ...erc20TokenMetadata, type: TokenType.XERC20, token: collateralTokenAddress, xERC20: xERC20Metadata.xERC20, scale, }; } async deriveHypXERC20LockboxTokenConfig(hypTokenAddress) { const hypXERC20TokenLockboxTokenInstance = HypXERC20Lockbox__factory.connect(hypTokenAddress, this.provider); const xerc20TokenAddress = await hypXERC20TokenLockboxTokenInstance.xERC20(); const [erc20TokenMetadata, xERC20Metadata, lockbox, scale] = await Promise.all([ this.fetchERC20Metadata(xerc20TokenAddress), this.fetchXERC20Config(xerc20TokenAddress, hypTokenAddress), hypXERC20TokenLockboxTokenInstance.lockbox(), this.fetchScale(hypTokenAddress), ]); return { ...erc20TokenMetadata, type: TokenType.XERC20Lockbox, token: lockbox, xERC20: xERC20Metadata.xERC20, scale, }; } async deriveHypCollateralCctpTokenConfig(hypToken) { const collateralConfig = await this.deriveHypCollateralTokenConfig(hypToken); const tokenBridge = TokenBridgeCctpBase__factory.connect(hypToken, this.provider); const [messageTransmitter, tokenMessenger, urls] = await Promise.all([ tokenBridge.messageTransmitter(), tokenBridge.tokenMessenger(), tokenBridge.urls(), ]); const onchainCctpVersion = await IMessageTransmitter__factory.connect(messageTransmitter, this.provider).version(); if (onchainCctpVersion === 0) { return { ...collateralConfig, type: TokenType.collateralCctp, cctpVersion: 'V1', messageTransmitter, tokenMessenger, urls, }; } else if (onchainCctpVersion === 1) { const tokenBridgeV2 = TokenBridgeCctpV2__factory.connect(hypToken, this.provider); // Version-gate: >= 11.0.0 uses maxFeePpm(), older uses maxFeeBps() const contractVersion = await this.fetchPackageVersion(hypToken); const usesPpmName = contractVersion !== undefined && compareVersions(contractVersion, CCTP_PPM_PRECISION_VERSION) >= 0; const [minFinalityThreshold, maxFeePpm] = await Promise.all([ tokenBridgeV2.minFinalityThreshold(), usesPpmName ? tokenBridgeV2.maxFeePpm() : tokenBridgeV2.provider .call({ to: hypToken, // maxFeeBps() selector data: '0xbf769a3f', }) .then((result) => BigNumber.from(result)), ]); return { ...collateralConfig, type: TokenType.collateralCctp, cctpVersion: 'V2', messageTransmitter, tokenMessenger, urls, minFinalityThreshold, maxFeeBps: maxFeePpm.toNumber(), }; } else { throw new Error(`Unsupported CCTP version ${onchainCctpVersion}`); } } async deriveHypCollateralDepositAddressTokenConfig(hypToken) { const tokenBridge = TokenBridgeDepositAddress__factory.connect(hypToken, this.provider); const [token, destinationConfigRaw] = await Promise.all([ tokenBridge.token(), this.depositAddressDomainConfigsCache.get(hypToken) ?? tokenBridge.getDomainConfigs(), ]); this.depositAddressDomainConfigsCache.delete(hypToken); const erc20Metadata = await this.fetchERC20Metadata(token); const [domains, depositAddresses, recipients, feeBpsValues] = destinationConfigRaw; const destinationConfigs = {}; for (let i = 0; i < domains.length; i++) { const domain = domains[i].toString(); const recipient = recipients[i].toLowerCase(); destinationConfigs[domain] ??= {}; destinationConfigs[domain][recipient] = { depositAddress: depositAddresses[i], feeBps: feeBpsValues[i].toString(), }; } return { ...erc20Metadata, type: TokenType.collateralDepositAddress, token, destinationConfigs, }; } async deriveHypCollateralOftTokenConfig(hypToken) { const tokenBridge = TokenBridgeOft__factory.connect(hypToken, this.provider); const [oft, token, extraOptions, domainMappingsRaw] = await Promise.all([ tokenBridge.oft(), tokenBridge.token(), tokenBridge.extraOptions(), tokenBridge.getDomainMappings(), ]); const erc20Metadata = await this.fetchERC20Metadata(token); const domainMappings = {}; const [domains, lzEids] = domainMappingsRaw; for (let i = 0; i < domains.length; i++) { domainMappings[domains[i].toString()] = lzEids[i]; } return { ...erc20Metadata, type: TokenType.collateralOft, token, oft, domainMappings, extraOptions: extraOptions !== '0x' ? extraOptions : undefined, }; } async deriveHypCollateralTokenConfig(hypToken) { const hypCollateralTokenInstance = HypERC20Collateral__factory.connect(hypToken, this.provider); const collateralTokenAddress = await hypCollateralTokenInstance.wrappedToken(); const [erc20TokenMetadata, scale] = await Promise.all([ this.fetchERC20Metadata(collateralTokenAddress), this.fetchScale(hypToken), ]); return { ...erc20TokenMetadata, type: TokenType.collateral, token: collateralTokenAddress, scale, }; } async deriveHypCollateralFiatTokenConfig(hypToken) { const erc20TokenMetadata = await this.deriveHypCollateralTokenConfig(hypToken); return { ...erc20TokenMetadata, type: TokenType.collateralFiat, }; } async deriveHypCollateralVaultTokenConfig(hypToken) { const erc20TokenMetadata = await this.deriveHypCollateralTokenConfig(hypToken); return { ...erc20TokenMetadata, token: await HypERC4626OwnerCollateral__factory.connect(hypToken, this.provider).vault(), type: TokenType.collateralVault, }; } async deriveHypCollateralVaultRebaseTokenConfig(hypToken) { const erc20TokenMetadata = await this.deriveHypCollateralTokenConfig(hypToken); return { ...erc20TokenMetadata, token: await HypERC4626Collateral__factory.connect(hypToken, this.provider).vault(), type: TokenType.collateralVaultRebase, }; } async deriveHypSyntheticTokenConfig(hypTokenAddress) { const [erc20TokenMetadata, scale] = await Promise.all([ this.fetchERC20Metadata(hypTokenAddress), this.fetchScale(hypTokenAddress), ]); return { ...erc20TokenMetadata, type: TokenType.synthetic, scale, }; } async deriveHypNativeTokenConfig(tokenRouterAddress) { const chainMetadata = this.multiProvider.getChainMetadata(this.chain); if (!chainMetadata.nativeToken) { throw new Error(`Warp route config specifies native token but chain metadata for chain "${this.chain}" does not provide native token details`); } const { name, symbol, decimals } = chainMetadata.nativeToken; const scale = await this.fetchScale(tokenRouterAddress); return { type: TokenType.native, name, symbol, decimals, isNft: false, scale, }; } async deriveOpL2TokenConfig(_address) { const config = await this.deriveHypNativeTokenConfig(_address); const contract = OpL2NativeTokenBridge__factory.connect(_address, this.multiProvider.getProvider(this.chain)); const l2Bridge = await contract.l2Bridge(); return { ...config, type: TokenType.nativeOpL2, l2Bridge, }; } async deriveOpL1TokenConfig(_address) { const config = await this.deriveHypNativeTokenConfig(_address); const contract = OpL1NativeTokenBridge__factory.connect(_address, this.multiProvider.getProvider(this.chain)); const urls = await contract.urls(); const portal = await contract.opPortal(); return { ...config, type: TokenType.nativeOpL1, urls, portal, // assume version 1 for now version: 1, }; } async deriveHypSyntheticRebaseConfig(hypTokenAddress) { const hypERC4626 = HypERC4626__factory.connect(hypTokenAddress, this.provider); const [erc20TokenMetadata, collateralDomainId, scale] = await Promise.all([ this.fetchERC20Metadata(hypTokenAddress), hypERC4626.collateralDomain(), this.fetchScale(hypTokenAddress), ]); const collateralChainName = this.multiProvider.getChainName(collateralDomainId); return { ...erc20TokenMetadata, type: TokenType.syntheticRebase, collateralChainName, scale, }; } async deriveEverclearBaseBridgeConfig(everclearTokenbridgeInstance) { const [everclearBridgeAddress, domains] = await Promise.all([ everclearTokenbridgeInstance.everclearAdapter(), everclearTokenbridgeInstance.domains(), ]); const outputAssets = await promiseObjAll(objMap(arrayToObject(domains.map(String)), async (domainId, _) => everclearTokenbridgeInstance.outputAssets(domainId))); // Remove unset domains from the output const filteredOutputAssets = objFilter(outputAssets, (_domainId, assetAddress) => !isZeroish(assetAddress)); const feeParamsByDomain = await promiseObjAll(objMap(arrayToObject(domains.map(String)), async (domainId, _) => { const [fee, deadline, signature] = await everclearTokenbridgeInstance.feeParams(domainId); return { deadline: deadline.toNumber(), fee: fee.toNumber(), signature, }; })); // Remove unset fee params from the output const filteredFeeParamsByDomain = objFilter(feeParamsByDomain, (_domainId, feeConfig) => { // if all the fields have their default value then the fee config for the // current domain is unset return !(feeConfig.deadline === 0 && feeConfig.fee === 0 && feeConfig.signature === '0x'); }); return { everclearBridgeAddress, outputAssets: filteredOutputAssets, everclearFeeParams: filteredFeeParamsByDomain, }; } async deriveEverclearEthTokenBridgeConfig(hypTokenAddress) { const everclearTokenbridgeInstance = EverclearTokenBridge__factory.connect(hypTokenAddress, this.provider); const wethAddress = await everclearTokenbridgeInstance.wrappedToken(); const { everclearBridgeAddress, everclearFeeParams, outputAssets } = await this.deriveEverclearBaseBridgeConfig(everclearTokenbridgeInstance); return { type: TokenType.ethEverclear, wethAddress, everclearBridgeAddress, everclearFeeParams, outputAssets, }; } async deriveEverclearCollateralTokenBridgeConfig(hypTokenAddress) { const everclearTokenbridgeInstance = EverclearTokenBridge__factory.connect(hypTokenAddress, this.provider); const collateralTokenAddress = await everclearTokenbridgeInstance.wrappedToken(); const [erc20TokenMetadata, { everclearBridgeAddress, everclearFeeParams, outputAssets }, scale,] = await Promise.all([ this.fetchERC20Metadata(collateralTokenAddress), this.deriveEverclearBaseBridgeConfig(everclearTokenbridgeInstance), this.fetchScale(hypTokenAddress), ]); return { type: TokenType.collateralEverclear, ...erc20TokenMetadata, token: collateralTokenAddress, everclearBridgeAddress, everclearFeeParams, outputAssets, scale, }; } /** * Derives the configuration for a CrossCollateralRouter router. */ async deriveCrossCollateralTokenConfig(hypTokenAddress) { const crossCollateralRouter = CrossCollateralRouter__factory.connect(hypTokenAddress, this.provider); const tokenRouter = TokenRouter__factory.connect(hypTokenAddress, this.provider); const [collateralTokenAddress, remoteDomains, crossCollateralDomains, localDomain, scale,] = await Promise.all([ crossCollateralRouter.wrappedToken(), tokenRouter.domains(), crossCollateralRouter.getCrossCollateralDomains(), crossCollateralRouter.localDomain(), this.fetchScale(hypTokenAddress), ]); const erc20TokenMetadata = await this.fetchERC20Metadata(collateralTokenAddress); // Merge Router._routers domains, MC-enrolled domains, and localDomain const allDomains = [ ...new Set([ ...remoteDomains.map(Number), ...crossCollateralDomains.map(Number), localDomain, ]), ]; const crossCollateralRouters = {}; await Promise.all(allDomains.map(async (domain) => { const routers = await crossCollateralRouter.getCrossCollateralRouters(domain); if (routers.length > 0) { crossCollateralRouters[domain.toString()] = [...routers]; } })); return { ...erc20TokenMetadata, type: TokenType.crossCollateral, token: collateralTokenAddress, scale, crossCollateralRouters: Object.keys(crossCollateralRouters).length > 0 ? crossCollateralRouters : undefined, }; } async fetchERC20Metadata(tokenAddress) { const erc20 = HypERC20__factory.connect(tokenAddress, this.provider); const [name, symbol, decimals] = await Promise.all([ erc20.name(), erc20.symbol(), erc20.decimals(), ]); return { name, symbol, decimals, isNft: false }; } /** * Fetches the scale configuration from a TokenRouter contract. * Handles version compatibility based on contract version - reads scaleNumerator/scaleDenominator * for contracts >= 11.0.0, otherwise reads legacy scale value. * * @param tokenRouterAddress - The address of the TokenRouter contract. * @returns The scale as a NormalizedScale, or undefined when the scale is the identity (1/1). */ async fetchScale(tokenRouterAddress) { const packageVersion = await this.fetchPackageVersion(tokenRouterAddress); const hasScaleFractionInterface = compareVersions(packageVersion, SCALE_FRACTION_VERSION) >= 0; const hasScaleInterface = compareVersions(packageVersion, SCALE_VERSION) >= 0; if (!hasScaleFractionInterface && !hasScaleInterface) { return; } const tokenRouter = TokenRouter__factory.connect(tokenRouterAddress, this.provider); let result; if (hasScaleFractionInterface) { // Read new format (scaleNumerator and scaleDenominator) const [numerator, denominator] = await Promise.all([ tokenRouter.scaleNumerator(), tokenRouter.scaleDenominator(), ]); result = { numerator: numerator.toBigInt(), denominator: denominator.toBigInt(), }; } else { // Read old format (single scale value) using low-level call const legacyScaleABI = [ 'function scale() external view returns (uint256)', ]; const legacyContract =