@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
992 lines • 53 kB
JavaScript
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 =