@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
558 lines • 24.1 kB
JavaScript
import { compareVersions } from 'compare-versions';
import { z } from 'zod';
import { CONTRACTS_PACKAGE_VERSION } from '@hyperlane-xyz/core';
import { isAddressEvm, objMap } from '@hyperlane-xyz/utils';
import { TokenFeeType } from '../fee/types.js';
import { HookType } from '../hook/types.js';
import { IsmType, OffchainLookupIsmConfigSchema, } from '../ism/types.js';
import { ZBigNumberish, ZHash } from '../metadata/customZodTypes.js';
import { GasRouterConfigSchema, RemoteRouterDomainOrChainNameSchema, } from '../router/types.js';
import { isCompliant } from '../utils/schemas.js';
import { TokenType } from './config.js';
export const WarpRouteDeployConfigSchemaErrors = {
ONLY_SYNTHETIC_REBASE: `Config with ${TokenType.collateralVaultRebase} must be deployed with ${TokenType.syntheticRebase}`,
NO_SYNTHETIC_ONLY: `Config must include Native or Collateral OR all synthetics must define token metadata`,
};
export const contractVersionMatchesDependency = (version) => {
return compareVersions(version, CONTRACTS_PACKAGE_VERSION) === 0;
};
export const VERSION_ERROR_MESSAGE = `Contract version must match the @hyperlane-xyz/core dependency version (${CONTRACTS_PACKAGE_VERSION})`;
/**
* Coerces string to bigint at runtime with positivity check.
* Needed because bigints are serialized as strings in JSON/YAML
* and must be converted back on parse.
*/
const PositiveZBigNumberish = ZBigNumberish.refine((n) => n > 0n, {
message: 'Must be positive',
});
export const TokenMetadataSchema = z.object({
name: z.string(),
symbol: z.string(),
decimals: z.number().gt(0).optional(),
scale: z
.union([
z.number().int().gt(0),
z.object({
numerator: z.number().int().gt(0),
denominator: z.number().int().gt(0),
}),
z.object({
numerator: PositiveZBigNumberish,
denominator: PositiveZBigNumberish,
}),
])
.optional(),
isNft: z.boolean().optional(),
contractVersion: z.string().optional(),
});
export const isTokenMetadata = isCompliant(TokenMetadataSchema);
const MovableTokenRebalancingBridgeConfigSchema = z.object({
bridge: ZHash,
approvedTokens: z
.array(ZHash)
.transform((rawRebalancers) => Array.from(new Set(rawRebalancers)))
.optional(),
});
const BaseEverclearTokenBridgeConfigSchema = z.object({
everclearBridgeAddress: ZHash,
outputAssets: z.record(RemoteRouterDomainOrChainNameSchema, ZHash),
everclearFeeParams: z.record(RemoteRouterDomainOrChainNameSchema, z.object({
fee: z.number().int(),
deadline: z.number().int(),
signature: z.string(),
})),
});
export const isEverclearTokenBridgeConfig = isCompliant(BaseEverclearTokenBridgeConfigSchema);
export const BaseMovableTokenConfigSchema = z.object({
allowedRebalancingBridges: z
.record(RemoteRouterDomainOrChainNameSchema, z.array(MovableTokenRebalancingBridgeConfigSchema))
.optional(),
allowedRebalancers: z
.array(ZHash)
.transform((rawRebalancers) => Array.from(new Set(rawRebalancers)))
.optional(),
});
// Predicate wrapper configuration for compliance-gated transfers
export const PredicateWrapperConfigSchema = z.object({
predicateRegistry: ZHash.describe('Predicate registry contract address'),
policyId: z
.string()
.min(1)
.describe('Predicate policy ID for attestation validation'),
owner: ZHash.describe('Owner of the predicate wrapper contract (controls setPolicyID, setRegistry, withdrawETH)'),
});
export const isPredicateWrapperConfig = isCompliant(PredicateWrapperConfigSchema);
export const NativeTokenConfigSchema = TokenMetadataSchema.partial().extend({
type: z.enum([TokenType.native, TokenType.nativeScaled]),
...BaseMovableTokenConfigSchema.shape,
predicateWrapper: PredicateWrapperConfigSchema.optional(),
});
export const isNativeTokenConfig = isCompliant(NativeTokenConfigSchema);
export const OpL2TokenConfigSchema = NativeTokenConfigSchema.omit({
type: true,
}).extend({
type: z.literal(TokenType.nativeOpL2),
l2Bridge: z.string(),
});
export const OpL1TokenConfigSchema = NativeTokenConfigSchema.omit({
type: true,
})
.extend({
type: z.literal(TokenType.nativeOpL1),
portal: z.string(),
version: z.number(),
})
.merge(OffchainLookupIsmConfigSchema.omit({ type: true, owner: true }));
export const isOpL1TokenConfig = isCompliant(OpL1TokenConfigSchema);
export const isOpL2TokenConfig = isCompliant(OpL2TokenConfigSchema);
export const CollateralTokenConfigSchema = TokenMetadataSchema.partial().extend({
type: z.enum([
TokenType.collateral,
TokenType.collateralVault,
TokenType.collateralVaultRebase,
TokenType.collateralFiat,
TokenType.collateralUri,
]),
token: z
.string()
.describe('Existing token address to extend with Warp Route functionality'),
...BaseMovableTokenConfigSchema.shape,
predicateWrapper: PredicateWrapperConfigSchema.optional(),
});
export const isCollateralTokenConfig = isCompliant(CollateralTokenConfigSchema);
export var XERC20Type;
(function (XERC20Type) {
XERC20Type["Velo"] = "velo";
XERC20Type["Standard"] = "standard";
})(XERC20Type || (XERC20Type = {}));
// Velo variant
const XERC20VSLimitConfigSchema = z.object({
type: z.literal(XERC20Type.Velo),
bufferCap: z.string().optional(),
rateLimitPerSecond: z.string().optional(),
});
const XERC20StandardLimitConfigSchema = z.object({
type: z.literal(XERC20Type.Standard),
mint: z.string().optional(),
burn: z.string().optional(),
});
const xERC20Limits = z.discriminatedUnion('type', [
XERC20VSLimitConfigSchema,
XERC20StandardLimitConfigSchema,
]);
const xERC20ExtraBridgesLimitConfigsSchema = z.object({
lockbox: z.string(),
limits: xERC20Limits,
});
const xERC20TokenMetadataSchema = z.object({
xERC20: z
.object({
extraBridges: z.array(xERC20ExtraBridgesLimitConfigsSchema).optional(),
warpRouteLimits: xERC20Limits,
})
.optional(),
});
export const XERC20TokenConfigSchema = CollateralTokenConfigSchema.omit({
type: true,
})
.extend({
type: z.enum([TokenType.XERC20, TokenType.XERC20Lockbox]),
})
.merge(xERC20TokenMetadataSchema);
export const isXERC20TokenConfig = isCompliant(XERC20TokenConfigSchema);
export const CctpTokenConfigSchema = TokenMetadataSchema.partial()
.extend({
type: z.literal(TokenType.collateralCctp),
token: z.string().describe('CCTP enabled token'),
messageTransmitter: z
.string()
.describe('CCTP Message Transmitter contract address'),
tokenMessenger: z
.string()
.describe('CCTP Token Messenger contract address'),
cctpVersion: z.enum(['V1', 'V2']),
minFinalityThreshold: z.number().optional(),
maxFeeBps: z
.number()
.min(0)
.max(9_999.99)
.optional()
.describe('Maximum fee in basis points (bps), supports decimals for fractional bps. 1 bps = 0.01%. Examples: 1.3 bps for Circle Optimism/Arbitrum/Base fee, 1.5 bps for Circle Unichain fee. Internally converted to ppm (parts per million) for contract precision.'),
predicateWrapper: PredicateWrapperConfigSchema.optional(),
})
.merge(OffchainLookupIsmConfigSchema.omit({ type: true, owner: true }));
export const isCctpTokenConfig = isCompliant(CctpTokenConfigSchema);
export const DepositAddressRecipientConfigSchema = z.object({
depositAddress: z
.string()
.refine(isAddressEvm, 'depositAddress must be a valid EVM address'),
feeBps: z
.string()
.or(z.number())
.optional()
.refine((value) => {
if (value === undefined)
return true;
if (typeof value === 'string' && value.trim() === '')
return false;
try {
const feeBps = BigInt(value);
return feeBps >= 0n && feeBps < 10000n;
}
catch {
return false;
}
}, {
message: 'feeBps must be a valid number >= 0 and < 10000',
})
.describe('Bridge fee in basis points for this destination'),
});
export const DepositAddressDestinationConfigSchema = z.record(ZHash.describe('Expected destination recipient as bytes32'), DepositAddressRecipientConfigSchema);
export const DepositAddressTokenConfigSchema = TokenMetadataSchema.partial().extend({
type: z.literal(TokenType.collateralDepositAddress),
token: z.string().describe('Underlying ERC20 token address'),
destinationConfigs: z.record(RemoteRouterDomainOrChainNameSchema, DepositAddressDestinationConfigSchema),
predicateWrapper: PredicateWrapperConfigSchema.optional(),
});
export const isDepositAddressTokenConfig = isCompliant(DepositAddressTokenConfigSchema);
export const OftTokenConfigSchema = TokenMetadataSchema.partial().extend({
type: z.literal(TokenType.collateralOft),
token: z
.string()
.describe('Underlying ERC20 token address (resolved from OFT)'),
oft: z.string().describe('OFT / OFTAdapter / OFTWrapper contract address'),
domainMappings: z
.record(RemoteRouterDomainOrChainNameSchema, z.number().int().nonnegative().max(4294967295))
.describe('Mapping of Hyperlane domain (or chain name) to LayerZero endpoint ID'),
extraOptions: z.string().optional().describe('LayerZero extra options (hex)'),
predicateWrapper: PredicateWrapperConfigSchema.optional(),
});
export const isOftTokenConfig = isCompliant(OftTokenConfigSchema);
export const CollateralRebaseTokenConfigSchema = TokenMetadataSchema.partial().extend({
type: z.literal(TokenType.collateralVaultRebase),
});
export const isCollateralRebaseTokenConfig = isCompliant(CollateralRebaseTokenConfigSchema);
export const SyntheticTokenConfigSchema = TokenMetadataSchema.partial().extend({
type: z.enum([TokenType.synthetic, TokenType.syntheticUri]),
initialSupply: z.string().or(z.number()).optional(),
predicateWrapper: PredicateWrapperConfigSchema.optional(),
metadataUri: z.string().url().optional(),
});
export const isSyntheticTokenConfig = isCompliant(SyntheticTokenConfigSchema);
export const SyntheticRebaseTokenConfigSchema = TokenMetadataSchema.partial().extend({
type: z.literal(TokenType.syntheticRebase),
collateralChainName: z.string(),
predicateWrapper: PredicateWrapperConfigSchema.optional(),
});
export const isSyntheticRebaseTokenConfig = isCompliant(SyntheticRebaseTokenConfigSchema);
/**
* Configuration for CrossCollateralRouter (multi-router collateral routing).
* Direct 1-message atomic transfers between collateral routers.
*/
export const CrossCollateralTokenConfigSchema = TokenMetadataSchema.partial().extend({
type: z.literal(TokenType.crossCollateral),
token: z.string().describe('Collateral token address'),
/** Map of domain → router addresses to enroll */
crossCollateralRouters: z
.record(RemoteRouterDomainOrChainNameSchema, z.array(ZHash))
.optional(),
...BaseMovableTokenConfigSchema.shape,
predicateWrapper: PredicateWrapperConfigSchema.optional(),
});
export const isCrossCollateralTokenConfig = isCompliant(CrossCollateralTokenConfigSchema);
export const EverclearCollateralTokenConfigSchema = z.object({
type: z.literal(TokenType.collateralEverclear),
...CollateralTokenConfigSchema.omit({ type: true }).shape,
...BaseEverclearTokenBridgeConfigSchema.shape,
});
export const isEverclearCollateralTokenConfig = isCompliant(EverclearCollateralTokenConfigSchema);
export const EverclearEthBridgeTokenConfigSchema = z.object({
type: z.literal(TokenType.ethEverclear),
wethAddress: ZHash,
...NativeTokenConfigSchema.omit({ type: true }).shape,
...BaseEverclearTokenBridgeConfigSchema.shape,
});
export const isEverclearEthBridgeTokenConfig = isCompliant(EverclearEthBridgeTokenConfigSchema);
export var ContractVerificationStatus;
(function (ContractVerificationStatus) {
ContractVerificationStatus["Verified"] = "verified";
ContractVerificationStatus["Unverified"] = "unverified";
ContractVerificationStatus["Error"] = "error";
ContractVerificationStatus["Skipped"] = "skipped";
})(ContractVerificationStatus || (ContractVerificationStatus = {}));
export var OwnerStatus;
(function (OwnerStatus) {
OwnerStatus["Active"] = "active";
OwnerStatus["Inactive"] = "inactive";
OwnerStatus["GnosisSafe"] = "gnosisSafe";
OwnerStatus["Error"] = "error";
OwnerStatus["Skipped"] = "skipped";
})(OwnerStatus || (OwnerStatus = {}));
export const HypTokenRouterVirtualConfigSchema = z.object({
contractVerificationStatus: z.record(z.enum([
ContractVerificationStatus.Error,
ContractVerificationStatus.Skipped,
ContractVerificationStatus.Verified,
ContractVerificationStatus.Unverified,
])),
ownerStatus: z.record(z.enum([
OwnerStatus.Error,
OwnerStatus.Skipped,
OwnerStatus.Active,
OwnerStatus.Inactive,
OwnerStatus.GnosisSafe,
])),
});
export const UnknownTokenConfigSchema = TokenMetadataSchema.partial()
.extend({
type: z.literal(TokenType.unknown),
predicateWrapper: PredicateWrapperConfigSchema.optional(),
})
.passthrough();
export const isUnknownTokenConfig = isCompliant(UnknownTokenConfigSchema);
const KnownTokenTypes = Object.values(TokenType).filter((t) => t !== TokenType.unknown);
const AllHypTokenConfigSchema = z.discriminatedUnion('type', [
NativeTokenConfigSchema,
OpL2TokenConfigSchema,
OpL1TokenConfigSchema,
CollateralTokenConfigSchema,
XERC20TokenConfigSchema,
SyntheticTokenConfigSchema,
SyntheticRebaseTokenConfigSchema,
CctpTokenConfigSchema,
OftTokenConfigSchema,
EverclearCollateralTokenConfigSchema,
EverclearEthBridgeTokenConfigSchema,
DepositAddressTokenConfigSchema,
CrossCollateralTokenConfigSchema,
UnknownTokenConfigSchema,
]);
/**
* @remarks
* The discriminatedUnion is basically a switch statement for zod schemas
* It uses the 'type' key to pick from the array of schemas to validate
*/
export const HypTokenConfigSchema = z.preprocess((val) => {
if (typeof val === 'object' && val !== null && 'type' in val) {
const obj = val;
if (typeof obj.type === 'string' &&
!KnownTokenTypes.includes(obj.type)) {
return { ...obj, type: TokenType.unknown };
}
}
return val;
}, AllHypTokenConfigSchema);
export const HypTokenRouterConfigSchema = z.preprocess(preprocessWarpRouteDeployConfig, HypTokenConfigSchema.and(GasRouterConfigSchema).and(HypTokenRouterVirtualConfigSchema.partial()));
export function derivedHookAddress(config) {
return typeof config.hook === 'string' ? config.hook : config.hook.address;
}
export function derivedIsmAddress(config) {
return typeof config.interchainSecurityModule === 'string'
? config.interchainSecurityModule
: config.interchainSecurityModule.address;
}
export const HypTokenRouterConfigMailboxOptionalBaseSchema = HypTokenConfigSchema.and(GasRouterConfigSchema.extend({
mailbox: z.string().optional(),
})).and(HypTokenRouterVirtualConfigSchema.partial());
export const HypTokenRouterConfigMailboxOptionalSchema = z.preprocess(preprocessWarpRouteDeployConfig, HypTokenRouterConfigMailboxOptionalBaseSchema);
function preprocessWarpRouteDeployConfig(value) {
const mutatedConfig = value;
return populateFeeOwner({
tokenConfig: mutatedConfig,
feeConfig: mutatedConfig.tokenFee,
});
}
function populateFeeOwner(params) {
const { tokenConfig, feeConfig, inheritedOwner } = params;
if (!feeConfig)
return tokenConfig;
const resolvedOwner = feeConfig.owner ?? inheritedOwner ?? tokenConfig.owner;
feeConfig.owner = resolvedOwner;
if (feeConfig.type === TokenFeeType.RoutingFee) {
objMap(feeConfig.feeContracts, (_, innerConfig) => {
populateFeeOwner({
tokenConfig,
feeConfig: innerConfig,
inheritedOwner: resolvedOwner,
});
});
}
if (feeConfig.type === TokenFeeType.CrossCollateralRoutingFee) {
objMap(feeConfig.feeContracts, (_, destinationConfig) => {
objMap(destinationConfig, (_, innerConfig) => {
populateFeeOwner({
tokenConfig,
feeConfig: innerConfig,
inheritedOwner: resolvedOwner,
});
});
});
}
return tokenConfig;
}
export const WarpRouteDeployConfigSchema = z
.record(HypTokenRouterConfigMailboxOptionalSchema)
.refine((configMap) => {
const entries = Object.entries(configMap);
return (entries.some(([_, config]) => isCollateralTokenConfig(config) ||
isCollateralRebaseTokenConfig(config) ||
isCctpTokenConfig(config) ||
isXERC20TokenConfig(config) ||
isNativeTokenConfig(config) ||
isEverclearTokenBridgeConfig(config) ||
isDepositAddressTokenConfig(config) ||
isCrossCollateralTokenConfig(config) ||
isOftTokenConfig(config)) || entries.every(([_, config]) => isTokenMetadata(config)));
}, WarpRouteDeployConfigSchemaErrors.NO_SYNTHETIC_ONLY)
// Verify synthetic rebase tokens config
.transform((warpRouteDeployConfig, ctx) => {
const collateralRebaseEntry = Object.entries(warpRouteDeployConfig).find(([_, config]) => isCollateralRebaseTokenConfig(config));
const syntheticRebaseEntry = Object.entries(warpRouteDeployConfig).find(([_, config]) => isSyntheticRebaseTokenConfig(config));
// Require both collateral rebase and synthetic rebase to be present in the config
if (!collateralRebaseEntry && !syntheticRebaseEntry) {
// Pass through for other token types
return warpRouteDeployConfig;
}
if (collateralRebaseEntry &&
isCollateralRebasePairedCorrectly(warpRouteDeployConfig)) {
const collateralChainName = collateralRebaseEntry[0];
return objMap(warpRouteDeployConfig, (_, config) => {
if (config.type === TokenType.syntheticRebase)
config.collateralChainName = collateralChainName;
return config;
});
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: WarpRouteDeployConfigSchemaErrors.ONLY_SYNTHETIC_REBASE,
});
return z.NEVER; // Causes schema validation to throw with above issue
})
// Verify that CCIP hooks are paired with CCIP ISMs
.transform((warpRouteDeployConfig, ctx) => {
const { ccipHookMap, ccipIsmMap } = getCCIPConfigMaps(warpRouteDeployConfig);
// Check hooks have corresponding ISMs
const hookConfigHasMissingIsms = Object.entries(ccipHookMap).some(([originChain, destinationChains]) => Array.from(destinationChains).some((chain) => {
if (!ccipIsmMap[originChain]?.has(chain)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [chain, 'interchainSecurityModule', '...'],
message: `Required CCIP ISM not found in config for CCIP Hook with origin chain ${originChain} and destination chain ${chain}`,
});
return true;
}
return false;
}));
// Check ISMs have corresponding hooks
const ismConfigHasMissingHooks = Object.entries(ccipIsmMap).some(([originChain, destinationChains]) => Array.from(destinationChains).some((chain) => {
if (!ccipHookMap[originChain]?.has(chain)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [originChain, 'hook', '...'],
message: `Required CCIP Hook not found in config for CCIP ISM with origin chain ${originChain} and destination chain ${chain}`,
});
return true;
}
return false;
}));
return hookConfigHasMissingIsms || ismConfigHasMissingHooks
? z.NEVER
: warpRouteDeployConfig;
})
// Verify that xERC20 are only with xERC20s or collateral
.transform((warpRouteDeployConfig, ctx) => {
const isXERC20Route = Object.values(warpRouteDeployConfig).some(isXERC20TokenConfig);
if (!isXERC20Route) {
return warpRouteDeployConfig;
}
const isAllXERC20sOrCollateral = Object.values(warpRouteDeployConfig).every((config) => isXERC20TokenConfig(config) || isCollateralTokenConfig(config));
if (!isAllXERC20sOrCollateral) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `xERC20 warp routes must only contain xERC20 or collateral token types`,
});
return z.NEVER;
}
return warpRouteDeployConfig;
});
const _RequiredMailboxSchema = z.record(z.object({
mailbox: z.string(),
}));
export const WarpRouteDeployConfigMailboxRequiredSchema = WarpRouteDeployConfigSchema.and(_RequiredMailboxSchema);
function isCollateralRebasePairedCorrectly(warpRouteDeployConfig) {
// Filter out all the non-collateral rebase configs to check if they are only synthetic rebase tokens
const otherConfigs = Object.entries(warpRouteDeployConfig).filter(([_, config]) => !isCollateralRebaseTokenConfig(config));
if (otherConfigs.length === 0)
return false;
// The other configs MUST be synthetic rebase
const allOthersSynthetic = otherConfigs.every(([_, config], _index) => isSyntheticRebaseTokenConfig(config));
return allOthersSynthetic;
}
function getCCIPConfigMaps(warpRouteDeployConfig) {
const ccipHookMap = {};
const ccipIsmMap = {};
Object.entries(warpRouteDeployConfig).forEach(([chainName, config]) => {
extractCCIPHookMap(chainName, config.hook, ccipHookMap);
extractCCIPIsmMap(chainName, config.interchainSecurityModule, ccipIsmMap);
});
return { ccipHookMap, ccipIsmMap };
}
function extractCCIPHookMap(currentChain, hookConfig, existsCCIPHookMap) {
if (!hookConfig || typeof hookConfig === 'string') {
return;
}
switch (hookConfig.type) {
case HookType.AGGREGATION:
hookConfig.hooks.forEach((hook) => extractCCIPHookMap(currentChain, hook, existsCCIPHookMap));
break;
case HookType.ARB_L2_TO_L1:
extractCCIPHookMap(currentChain, hookConfig.childHook, existsCCIPHookMap);
break;
case HookType.CCIP:
if (!existsCCIPHookMap[currentChain]) {
existsCCIPHookMap[currentChain] = new Set();
}
existsCCIPHookMap[currentChain].add(hookConfig.destinationChain);
break;
case HookType.FALLBACK_ROUTING:
case HookType.ROUTING:
Object.entries(hookConfig.domains).forEach(([_, hook]) => {
extractCCIPHookMap(currentChain, hook, existsCCIPHookMap);
});
break;
default:
break;
}
}
function extractCCIPIsmMap(currentChain, ismConfig, existsCCIPIsmMap) {
if (!ismConfig || typeof ismConfig === 'string') {
return;
}
switch (ismConfig.type) {
case IsmType.AGGREGATION:
case IsmType.STORAGE_AGGREGATION:
ismConfig.modules.forEach((hook) => extractCCIPIsmMap(currentChain, hook, existsCCIPIsmMap));
break;
case IsmType.CCIP:
if (!existsCCIPIsmMap[ismConfig.originChain]) {
existsCCIPIsmMap[ismConfig.originChain] = new Set();
}
existsCCIPIsmMap[ismConfig.originChain].add(currentChain);
break;
case IsmType.FALLBACK_ROUTING:
case IsmType.ROUTING:
Object.entries(ismConfig.domains).forEach(([_, hook]) => {
extractCCIPIsmMap(currentChain, hook, existsCCIPIsmMap);
});
break;
default:
break;
}
}
const MovableTokenSchema = z.discriminatedUnion('type', [
CollateralTokenConfigSchema,
CrossCollateralTokenConfigSchema,
NativeTokenConfigSchema,
]);
export const isMovableCollateralTokenConfig = isCompliant(MovableTokenSchema);
//# sourceMappingURL=types.js.map