Description:
Multi-signature wallet contract requiring multiple confirmations for transaction execution.
Blockchain: Ethereum
Source Code: View Code On The Blockchain
Solidity Source Code:
{{
"language": "Solidity",
"sources": {
"src/token/MetaERC20Spoke.sol": {
"content": "// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity 0.8.26;
import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import {TypeCasts} from "@hyperlane-xyz/core/contracts/libs/TypeCasts.sol";
import {FinalityState} from "../lib/MetalayerMessage.sol";
import {MetaERC20Message} from "../lib/MetaERC20Message.sol";
import {MetaERC20MessageStruct, MetaERC20MessageType} from "../lib/MetaERC20Types.sol";
import {ReadOperation} from "../interfaces/IMetalayerRecipient.sol";
import {MetaERC20Base} from "./MetaERC20Base.sol";
/// @title MetaERC20Spoke
/// @notice Synthetic bridge-side contract for minting and burning cross-chain ERC20 tokens
/// @dev Mints synthetic tokens on receipt of MintRequest, burns tokens during transferRemote.
/// Uses ERC20's built-in balance tracking for mint/burn operations.
/// Implements smart routing: direct transfers to other Spokes, or via Hub for high-value transfers.
///
/// Spoke behavior:
/// - Mints synthetic tokens when receiving MintRequest messages
/// - Burns synthetic tokens before dispatching transferRemote
/// - Routes to Hub (UnlockRequest) when users want canonical tokens
/// - Routes high-value transfers through Hub (SecurityRelay) for manual approval
/// - Routes normal transfers directly to destination Spokes (MintRequest)
/// - Converts between source and local token decimal precision
/// - Uses ERC20 balance validation to prevent over-burning
///
/// Must be paired with a MetaERC20Hub on the canonical domain.
///
contract MetaERC20Spoke is MetaERC20Base, ERC20Upgradeable {
using TypeCasts for address;
using TypeCasts for bytes32;
/*//////////////////////////////////////////////////////////////
VARIABLES
//////////////////////////////////////////////////////////////*/
/*//////////////////////////////////////////////////////////////
STORAGE LAYOUT (SLOT MAP)
----------------------------------------------------------------
| | MetaERC20Base | | |
|--------|-------------------------|--------|------------------|
| 67 | __reservedSlot67 | 32 B | Reserved |
| 68 | __reservedSlot63 | 28 B | Spoke-specific |
| | hubDomain | 4 B | Spoke-specific |
| 69 | securityThreshold | 32 B | |
| 70–120 | __gap (Spoke) | 50x32B | Reserved (Spoke) |
----------------------------------------------------------------
Total declared slots: 121
//////////////////////////////////////////////////////////////*/
/// @dev Reserved padding - slot 67 previously used for mintedBalance, now available
uint256 private __reservedSlot67;
/// @dev Reserved padding to align `hubDomain` at the low bytes of slot 63
uint224 private __reservedSlot63;
/// @notice The Metalayer domain ID of the canonical Hub contract
/// @dev Used to route messages and validate origin in `_validateOrigin`
uint32 public hubDomain;
/// @notice Transfers >= securityThreshold must route through the Hub for manual approval
/// @dev Threshold is in local token units. High-value transfers use SecurityRelay for additional security.
uint256 public securityThreshold;
/*//////////////////////////////////////////////////////////////
ERRORS
//////////////////////////////////////////////////////////////*/
/// @notice Thrown when the configured hubDomain is zero or otherwise invalid
error InvalidHubDomain();
/*//////////////////////////////////////////////////////////////
EVENTS
//////////////////////////////////////////////////////////////*/
/// @notice Emitted when an admin manually reissues synthetic tokens using EMERGENCY_REISSUE
/// @param transferId The ID of the transfer that was force-reissued
/// @param adminAddress The address of the admin who triggered the reissue
/// @param recipientAddress The address that received the reissued tokens
event MetaERC20AdminReissue(
bytes32 indexed transferId,
address indexed adminAddress,
address recipientAddress
);
/// @notice Emitted when the MetaERC20Spoke is initialized
/// @param localDomain The Metalayer domain ID of this Spoke chain
/// @param hubDomain The Metalayer domain ID of the canonical Hub
/// @param tokenDecimals The number of decimals the synthetic token uses
/// @param metaERC20Version The protocol version used by this Spoke
/// @param securityThreshold Transfers >= this value are routed through the Hub (in local token units)
/// @param name The ERC20 name string
/// @param symbol The ERC20 symbol string
event MetaERC20SpokeInitialized(
uint32 indexed localDomain,
uint32 indexed hubDomain,
uint8 tokenDecimals,
uint8 metaERC20Version,
uint256 securityThreshold,
string name,
string symbol
);
/// @notice Emitted when the security threshold is updated by an admin
/// @param newThreshold The new threshold value for high-value transfer routing (in local token units)
event SecurityThresholdUpdated(uint256 newThreshold);
/// @notice Emitted when the hub domain is updated
/// @param newHubDomain The new hub domain ID
event HubDomainUpdated(uint32 newHubDomain);
/*//////////////////////////////////////////////////////////////
INITIALIZER
//////////////////////////////////////////////////////////////*/
/// @notice Initializes the MetaERC20Spoke with required configuration
/// @dev Callable only once. Initializes ERC20 metadata, router config, and decimal conversion settings.
/// Grants ADMIN_ROLE to the initializer. Sets up 1-day delay for admin role transfers.
/// Validates hubDomain is non-zero and different from localDomain.
/// @param _localDomain The Metalayer domain ID of the current chain
/// @param _metalayerRouter The address of the MetalayerRouter used for message dispatch and receive
/// @param _ttlWindow The time (in seconds) after which transfer records can be pruned
/// @param _metaERC20Version The MetaERC20 protocol version to use for outbound messages
/// @param _hubDomain The Metalayer domain ID of the canonical Hub
/// @param name_ The name of the synthetic ERC20 token
/// @param symbol_ The symbol of the synthetic ERC20 token
/// @param _tokenDecimals The number of decimals the token uses
/// @param _securityThreshold The threshold for high-value transfers (in local token units)
function initialize(
uint32 _localDomain,
address _metalayerRouter,
uint256 _ttlWindow,
uint8 _metaERC20Version,
uint32 _hubDomain,
string memory name_,
string memory symbol_,
uint8 _tokenDecimals,
uint256 _securityThreshold,
address _initialAdmin
) public initializer {
__ERC20_init(name_, symbol_);
__AccessControlDefaultAdminRules_init(1 days, _initialAdmin);
if (_hubDomain == 0 || _hubDomain == _localDomain)
revert InvalidHubDomain();
_initializeBase(
_localDomain,
_metalayerRouter,
_metaERC20Version,
_ttlWindow,
_tokenDecimals
);
hubDomain = _hubDomain;
securityThreshold = _securityThreshold;
isHub = false;
_grantRole(ADMIN_ROLE, _initialAdmin);
emit MetaERC20SpokeInitialized(
_localDomain,
_hubDomain,
_tokenDecimals,
_metaERC20Version,
_securityThreshold,
name_,
symbol_
);
}
/*//////////////////////////////////////////////////////////////
OUTGOING FUNCTIONS (external)
//////////////////////////////////////////////////////////////*/
/// @inheritdoc MetaERC20Base
function transferRemote(
uint32 _recipientDomain,
bytes32 _recipientAddress,
uint256 _amount
) public payable override returns (bytes32 transferId) {
return
super.transferRemote(
_recipientDomain,
_recipientAddress,
_amount
);
}
/*//////////////////////////////////////////////////////////////
INCOMING FUNCTIONS (external)
//////////////////////////////////////////////////////////////*/
/// @inheritdoc MetaERC20Base
function handle(
uint32 _originDomain,
bytes32 _senderAddress,
bytes calldata _message,
ReadOperation[] calldata _reads,
bytes[] calldata _results
) public payable override {
super.handle(_originDomain, _senderAddress, _message, _reads, _results);
}
/*//////////////////////////////////////////////////////////////
INCOMING FUNCTIONS (internal)
//////////////////////////////////////////////////////////////*/
/// @notice Handles a MintRequest to mint synthetic tokens to a user
/// @dev Decodes MetaERC20MessageStruct, converts decimal precision from source to local units,
/// and mints synthetic tokens to the recipient.
/// Uses CEI pattern: validates recipient, converts decimals, checks amount, then mints.
/// @param _originDomain The Metalayer domain ID where the message originated
/// @param _senderAddress The address that sent the message on the origin chain (bytes32 format)
/// @param _metaERC20Version The version of the MetaERC20 protocol
/// @param _writeData The raw encoded message payload (already validated and version-matched)
function _handleMintRequest(
uint32 _originDomain,
bytes32 _senderAddress,
uint8 _metaERC20Version,
bytes calldata _writeData
) internal override {
MetaERC20MessageStruct memory _message = MetaERC20Message.decodeMessage(
_writeData
);
if (_message.recipient == address(0)) revert InvalidRecipient();
uint256 localAmount = _convertDecimals(
_message.amount,
_message.sourceDecimals,
tokenDecimals
);
if (localAmount == 0) revert ZeroAmount();
_mint(_message.recipient, localAmount);
emit MetaERC20Received(
_message.transferId,
_originDomain,
_senderAddress,
localDomain,
_message.recipient.addressToBytes32(),
_message.amount,
_message.sourceDecimals,
_message.messageType,
_metaERC20Version,
metaERC20Addresses[_originDomain], // sourceTokenAddress
address(this).addressToBytes32() // destinationTokenAddress (Spoke contract itself)
);
}
/*//////////////////////////////////////////////////////////////
ERC20 FUNCTIONS
//////////////////////////////////////////////////////////////*/
/// @notice Returns the number of decimals used by the synthetic token
/// @dev Overrides ERC20 to return the value configured at initialization.
/// Critical for proper external contract integration and display.
/// @return The number of decimals for this token
function decimals() public view virtual override returns (uint8) {
return tokenDecimals;
}
/*//////////////////////////////////////////////////////////////
ADMIN FUNCTIONS (external)
//////////////////////////////////////////////////////////////*/
/// @notice Admin-only emergency function to reissue synthetic tokens for a failed or unprocessed transfer
/// @dev Bypasses normal message flow and directly mints synthetic tokens to recipient.
/// Converts amounts from source decimals to local token decimals before minting.
/// Emits MetaERC20Received with originDomain = 0 to signal admin override flow.
/// @param _transferId The ID of the original transfer to reissue from storage
/// @param _recipientAddress The address that will receive the reissued synthetic tokens
function EMERGENCY_REISSUE(
bytes32 _transferId,
address _recipientAddress
) external onlyRole(ADMIN_ROLE) {
if (_recipientAddress == address(0)) revert InvalidRecipient();
MetaERC20MessageStruct memory _message = _getTransferRecord(
_transferId
);
if (_message.amount == 0) revert TransferNotFound();
uint256 localAmount = _convertDecimals(
_message.amount,
_message.sourceDecimals,
tokenDecimals
);
_deleteTransfer(_transferId);
_mint(_recipientAddress, localAmount);
emit MetaERC20Received(
_transferId,
0, // originDomain unknown in admin override
msg.sender.addressToBytes32(),
localDomain,
_recipientAddress.addressToBytes32(),
_message.amount,
_message.sourceDecimals,
MetaERC20MessageType.AdminAction,
metaERC20Version,
bytes32(0), // sourceTokenAddress unknown in admin flow
address(this).addressToBytes32() // destinationTokenAddress (Spoke contract itself)
);
emit MetaERC20AdminReissue(_transferId, msg.sender, _recipientAddress);
}
/// @notice Updates the security threshold for high-value transfers
/// @dev Transfers >= this threshold will be routed to the Hub as SecurityRelay
/// @param newThreshold The new threshold value in local token units
function setSecurityThreshold(
uint256 newThreshold
) external onlyRole(ADMIN_ROLE) {
securityThreshold = newThreshold;
emit SecurityThresholdUpdated(newThreshold);
}
/// @notice Updates the hub domain for cross-chain routing
/// @dev Hub domain must be non-zero and different from local domain. Callable only by ADMIN_ROLE.
/// This setting controls which domain is treated as the canonical Hub for routing decisions.
/// @param newHubDomain The new hub domain ID
function setHubDomain(
uint32 newHubDomain
) external onlyRole(ADMIN_ROLE) {
if (newHubDomain == 0 || newHubDomain == localDomain)
revert InvalidHubDomain();
hubDomain = newHubDomain;
emit HubDomainUpdated(newHubDomain);
}
/*//////////////////////////////////////////////////////////////
HELPER FUNCTIONS (internal)
//////////////////////////////////////////////////////////////*/
/// @notice Determines the dispatch parameters for a Spoke-originating transfer
/// @dev Implements Spoke's routing logic:
/// - High-value transfers (>= securityThreshold) to other spokes route through Hub as SecurityRelay
/// - Transfers to Hub become UnlockRequest
/// - Low-value transfers to other spokes become direct MintRequest
/// The destination domain may differ from recipient domain for SecurityRelay routing.
/// @param _recipientDomain The user's intended final destination domain
/// @param _message The partially constructed MetaERC20MessageStruct containing amount for threshold check
/// @return messageType The type of message to send (MintRequest, UnlockRequest, or SecurityRelay)
/// @return destinationRouterAddress The MetaERC20 contract address on the destination domain
/// @return destinationDomain The actual domain to send to (hubDomain for SecurityRelay, otherwise _recipientDomain)
function _resolveDispatchArguments(
uint32 _recipientDomain,
MetaERC20MessageStruct memory _message
)
internal
view
override
returns (
MetaERC20MessageType messageType,
bytes32 destinationRouterAddress,
uint32 destinationDomain
)
{
if (_recipientDomain == localDomain) revert InvalidDomainLoopback();
if (
_message.amount >= securityThreshold &&
_recipientDomain != hubDomain
) {
// High-value spoke→spoke transfer → route through Hub
messageType = MetaERC20MessageType.SecurityRelay;
destinationRouterAddress = metaERC20Addresses[hubDomain];
destinationDomain = hubDomain;
} else if (_recipientDomain == hubDomain) {
// Standard unlock flow
messageType = MetaERC20MessageType.UnlockRequest;
destinationRouterAddress = metaERC20Addresses[hubDomain];
destinationDomain = hubDomain;
} else {
// Normal MintRequest to remote Spoke
messageType = MetaERC20MessageType.MintRequest;
destinationRouterAddress = metaERC20Addresses[_recipientDomain];
destinationDomain = _recipientDomain;
}
if (destinationRouterAddress == 0) {
revert MetaERC20NotRegistered(destinationDomain);
}
return (messageType, destinationRouterAddress, destinationDomain);
}
/// @notice Burns tokens prior to dispatching a cross-chain transfer
/// @dev Burns synthetic tokens from sender. The ERC20 _burn function handles balance validation.
/// @param _sender The address initiating the transfer
/// @param _amount The token amount to be burned in local token units
function _preDispatchHook(
address _sender,
uint256 _amount
) internal override {
_burn(_sender, _amount);
}
/*//////////////////////////////////////////////////////////////
UPGRADE GAP
//////////////////////////////////////////////////////////////*/
/// @dev Reserved storage space to allow for layout upgrades in the future
uint256[50] private __gap;
}
"
},
"node_modules/@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol": {
"content": "// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.9.0) (token/ERC20/ERC20.sol)
pragma solidity ^0.8.0;
import "./IERC20Upgradeable.sol";
import "./extensions/IERC20MetadataUpgradeable.sol";
import "../../utils/ContextUpgradeable.sol";
import {Initializable} from "../../proxy/utils/Initializable.sol";
/**
* @dev Implementation of the {IERC20} interface.
*
* This implementation is agnostic to the way tokens are created. This means
* that a supply mechanism has to be added in a derived contract using {_mint}.
* For a generic mechanism see {ERC20PresetMinterPauser}.
*
* TIP: For a detailed writeup see our guide
* https://forum.openzeppelin.com/t/how-to-implement-erc20-supply-mechanisms/226[How
* to implement supply mechanisms].
*
* The default value of {decimals} is 18. To change this, you should override
* this function so it returns a different value.
*
* We have followed general OpenZeppelin Contracts guidelines: functions revert
* instead returning `false` on failure. This behavior is nonetheless
* conventional and does not conflict with the expectations of ERC20
* applications.
*
* Additionally, an {Approval} event is emitted on calls to {transferFrom}.
* This allows applications to reconstruct the allowance for all accounts just
* by listening to said events. Other implementations of the EIP may not emit
* these events, as it isn't required by the specification.
*
* Finally, the non-standard {decreaseAllowance} and {increaseAllowance}
* functions have been added to mitigate the well-known issues around setting
* allowances. See {IERC20-approve}.
*/
contract ERC20Upgradeable is Initializable, ContextUpgradeable, IERC20Upgradeable, IERC20MetadataUpgradeable {
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
uint256 private _totalSupply;
string private _name;
string private _symbol;
/**
* @dev Sets the values for {name} and {symbol}.
*
* All two of these values are immutable: they can only be set once during
* construction.
*/
function __ERC20_init(string memory name_, string memory symbol_) internal onlyInitializing {
__ERC20_init_unchained(name_, symbol_);
}
function __ERC20_init_unchained(string memory name_, string memory symbol_) internal onlyInitializing {
_name = name_;
_symbol = symbol_;
}
/**
* @dev Returns the name of the token.
*/
function name() public view virtual override returns (string memory) {
return _name;
}
/**
* @dev Returns the symbol of the token, usually a shorter version of the
* name.
*/
function symbol() public view virtual override returns (string memory) {
return _symbol;
}
/**
* @dev Returns the number of decimals used to get its user representation.
* For example, if `decimals` equals `2`, a balance of `505` tokens should
* be displayed to a user as `5.05` (`505 / 10 ** 2`).
*
* Tokens usually opt for a value of 18, imitating the relationship between
* Ether and Wei. This is the default value returned by this function, unless
* it's overridden.
*
* NOTE: This information is only used for _display_ purposes: it in
* no way affects any of the arithmetic of the contract, including
* {IERC20-balanceOf} and {IERC20-transfer}.
*/
function decimals() public view virtual override returns (uint8) {
return 18;
}
/**
* @dev See {IERC20-totalSupply}.
*/
function totalSupply() public view virtual override returns (uint256) {
return _totalSupply;
}
/**
* @dev See {IERC20-balanceOf}.
*/
function balanceOf(address account) public view virtual override returns (uint256) {
return _balances[account];
}
/**
* @dev See {IERC20-transfer}.
*
* Requirements:
*
* - `to` cannot be the zero address.
* - the caller must have a balance of at least `amount`.
*/
function transfer(address to, uint256 amount) public virtual override returns (bool) {
address owner = _msgSender();
_transfer(owner, to, amount);
return true;
}
/**
* @dev See {IERC20-allowance}.
*/
function allowance(address owner, address spender) public view virtual override returns (uint256) {
return _allowances[owner][spender];
}
/**
* @dev See {IERC20-approve}.
*
* NOTE: If `amount` is the maximum `uint256`, the allowance is not updated on
* `transferFrom`. This is semantically equivalent to an infinite approval.
*
* Requirements:
*
* - `spender` cannot be the zero address.
*/
function approve(address spender, uint256 amount) public virtual override returns (bool) {
address owner = _msgSender();
_approve(owner, spender, amount);
return true;
}
/**
* @dev See {IERC20-transferFrom}.
*
* Emits an {Approval} event indicating the updated allowance. This is not
* required by the EIP. See the note at the beginning of {ERC20}.
*
* NOTE: Does not update the allowance if the current allowance
* is the maximum `uint256`.
*
* Requirements:
*
* - `from` and `to` cannot be the zero address.
* - `from` must have a balance of at least `amount`.
* - the caller must have allowance for ``from``'s tokens of at least
* `amount`.
*/
function transferFrom(address from, address to, uint256 amount) public virtual override returns (bool) {
address spender = _msgSender();
_spendAllowance(from, spender, amount);
_transfer(from, to, amount);
return true;
}
/**
* @dev Atomically increases the allowance granted to `spender` by the caller.
*
* This is an alternative to {approve} that can be used as a mitigation for
* problems described in {IERC20-approve}.
*
* Emits an {Approval} event indicating the updated allowance.
*
* Requirements:
*
* - `spender` cannot be the zero address.
*/
function increaseAllowance(address spender, uint256 addedValue) public virtual returns (bool) {
address owner = _msgSender();
_approve(owner, spender, allowance(owner, spender) + addedValue);
return true;
}
/**
* @dev Atomically decreases the allowance granted to `spender` by the caller.
*
* This is an alternative to {approve} that can be used as a mitigation for
* problems described in {IERC20-approve}.
*
* Emits an {Approval} event indicating the updated allowance.
*
* Requirements:
*
* - `spender` cannot be the zero address.
* - `spender` must have allowance for the caller of at least
* `subtractedValue`.
*/
function decreaseAllowance(address spender, uint256 subtractedValue) public virtual returns (bool) {
address owner = _msgSender();
uint256 currentAllowance = allowance(owner, spender);
require(currentAllowance >= subtractedValue, "ERC20: decreased allowance below zero");
unchecked {
_approve(owner, spender, currentAllowance - subtractedValue);
}
return true;
}
/**
* @dev Moves `amount` of tokens from `from` to `to`.
*
* This internal function is equivalent to {transfer}, and can be used to
* e.g. implement automatic token fees, slashing mechanisms, etc.
*
* Emits a {Transfer} event.
*
* Requirements:
*
* - `from` cannot be the zero address.
* - `to` cannot be the zero address.
* - `from` must have a balance of at least `amount`.
*/
function _transfer(address from, address to, uint256 amount) internal virtual {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(from, to, amount);
uint256 fromBalance = _balances[from];
require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
unchecked {
_balances[from] = fromBalance - amount;
// Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by
// decrementing then incrementing.
_balances[to] += amount;
}
emit Transfer(from, to, amount);
_afterTokenTransfer(from, to, amount);
}
/** @dev Creates `amount` tokens and assigns them to `account`, increasing
* the total supply.
*
* Emits a {Transfer} event with `from` set to the zero address.
*
* Requirements:
*
* - `account` cannot be the zero address.
*/
function _mint(address account, uint256 amount) internal virtual {
require(account != address(0), "ERC20: mint to the zero address");
_beforeTokenTransfer(address(0), account, amount);
_totalSupply += amount;
unchecked {
// Overflow not possible: balance + amount is at most totalSupply + amount, which is checked above.
_balances[account] += amount;
}
emit Transfer(address(0), account, amount);
_afterTokenTransfer(address(0), account, amount);
}
/**
* @dev Destroys `amount` tokens from `account`, reducing the
* total supply.
*
* Emits a {Transfer} event with `to` set to the zero address.
*
* Requirements:
*
* - `account` cannot be the zero address.
* - `account` must have at least `amount` tokens.
*/
function _burn(address account, uint256 amount) internal virtual {
require(account != address(0), "ERC20: burn from the zero address");
_beforeTokenTransfer(account, address(0), amount);
uint256 accountBalance = _balances[account];
require(accountBalance >= amount, "ERC20: burn amount exceeds balance");
unchecked {
_balances[account] = accountBalance - amount;
// Overflow not possible: amount <= accountBalance <= totalSupply.
_totalSupply -= amount;
}
emit Transfer(account, address(0), amount);
_afterTokenTransfer(account, address(0), amount);
}
/**
* @dev Sets `amount` as the allowance of `spender` over the `owner` s tokens.
*
* This internal function is equivalent to `approve`, and can be used to
* e.g. set automatic allowances for certain subsystems, etc.
*
* Emits an {Approval} event.
*
* Requirements:
*
* - `owner` cannot be the zero address.
* - `spender` cannot be the zero address.
*/
function _approve(address owner, address spender, uint256 amount) internal virtual {
require(owner != address(0), "ERC20: approve from the zero address");
require(spender != address(0), "ERC20: approve to the zero address");
_allowances[owner][spender] = amount;
emit Approval(owner, spender, amount);
}
/**
* @dev Updates `owner` s allowance for `spender` based on spent `amount`.
*
* Does not update the allowance amount in case of infinite allowance.
* Revert if not enough allowance is available.
*
* Might emit an {Approval} event.
*/
function _spendAllowance(address owner, address spender, uint256 amount) internal virtual {
uint256 currentAllowance = allowance(owner, spender);
if (currentAllowance != type(uint256).max) {
require(currentAllowance >= amount, "ERC20: insufficient allowance");
unchecked {
_approve(owner, spender, currentAllowance - amount);
}
}
}
/**
* @dev Hook that is called before any transfer of tokens. This includes
* minting and burning.
*
* Calling conditions:
*
* - when `from` and `to` are both non-zero, `amount` of ``from``'s tokens
* will be transferred to `to`.
* - when `from` is zero, `amount` tokens will be minted for `to`.
* - when `to` is zero, `amount` of ``from``'s tokens will be burned.
* - `from` and `to` are never both zero.
*
* To learn more about hooks, head to xref:ROOT:extending-contracts.adoc#using-hooks[Using Hooks].
*/
function _beforeTokenTransfer(address from, address to, uint256 amount) internal virtual {}
/**
* @dev Hook that is called after any transfer of tokens. This includes
* minting and burning.
*
* Calling conditions:
*
* - when `from` and `to` are both non-zero, `amount` of ``from``'s tokens
* has been transferred to `to`.
* - when `from` is zero, `amount` tokens have been minted for `to`.
* - when `to` is zero, `amount` of ``from``'s tokens have been burned.
* - `from` and `to` are never both zero.
*
* To learn more about hooks, head to xref:ROOT:extending-contracts.adoc#using-hooks[Using Hooks].
*/
function _afterTokenTransfer(address from, address to, uint256 amount) internal virtual {}
/**
* @dev This empty reserved space is put in place to allow future versions to add new
* variables without shifting down storage in the inheritance chain.
* See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps
*/
uint256[45] private __gap;
}
"
},
"node_modules/@hyperlane-xyz/core/contracts/libs/TypeCasts.sol": {
"content": "// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.6.11;
library TypeCasts {
// alignment preserving cast
function addressToBytes32(address _addr) internal pure returns (bytes32) {
return bytes32(uint256(uint160(_addr)));
}
// alignment preserving cast
function bytes32ToAddress(bytes32 _buf) internal pure returns (address) {
require(
uint256(_buf) <= uint256(type(uint160).max),
"TypeCasts: bytes32ToAddress overflow"
);
return address(uint160(uint256(_buf)));
}
}
"
},
"src/lib/MetalayerMessage.sol": {
"content": "// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.8.0;
import {TypeCasts} from "@hyperlane-xyz/core/contracts/libs/TypeCasts.sol";
import {ReadOperation} from "../interfaces/IMetalayerRecipient.sol";
enum FinalityState {
INSTANT,
FINALIZED,
ESPRESSO
}
/**
* @title Metalayer Message Library
* @notice Library for formatted messages for the metalayer. These will be the message body of Hyperlane messages.
*
*/
library MetalayerMessage {
using TypeCasts for bytes32;
/**
* @notice Safely casts uint256 to uint32, reverting on overflow
* @param value The value to cast
* @return The value as uint32
*/
function safeCastToUint32(uint256 value) internal pure returns (uint32) {
require(value <= type(uint32).max, "MetalayerMessage: value exceeds uint32 range");
return uint32(value);
}
uint256 private constant VERSION_OFFSET = 0;
uint256 private constant FINALITY_STATE_FLAG_OFFSET = 1;
uint256 private constant NONCE_OFFSET = 2;
uint256 private constant ORIGIN_OFFSET = 6;
uint256 private constant SENDER_OFFSET = 10;
uint256 private constant DESTINATION_OFFSET = 42;
uint256 private constant RECIPIENT_OFFSET = 46;
uint256 private constant BODY_OFFSET = 78;
// Constants for read operation parsing within message body
uint256 private constant READ_COUNT_OFFSET = 0;
uint256 private constant READS_OFFSET = 4; // uint32 size for read count
/**
* @notice Returns formatted message supporting multiple read operations
* @param _version The version of the origin and destination Mailboxes
* @param _finalityState What sort of finality we should wait for before the message is valid. Currently only have 0 for instant, 1 for final.
* @param _nonce A nonce to uniquely identify the message on its origin chain
* @param _originDomain Domain of origin chain
* @param _sender Address of sender
* @param _destinationDomain Domain of destination chain
* @param _recipient Address of recipient on destination chain
* @param _reads Array of read operations to perform
* @param _writeCallData The call data for the final write operation
* @return Formatted message with reads
*/
function formatMessageWithReads(
uint8 _version,
FinalityState _finalityState,
uint32 _nonce,
uint32 _originDomain,
bytes32 _sender,
uint32 _destinationDomain,
bytes32 _recipient,
ReadOperation[] memory _reads,
bytes memory _writeCallData
) internal pure returns (bytes memory) {
uint256 _readsLength = _reads.length;
bytes memory messageBody = abi.encodePacked(safeCastToUint32(_readsLength));
// this is n^2 -> optimize later by just preallocating the appropriate size
// can keep slow for test
for (uint256 i = 0; i < _readsLength; i++) {
messageBody = abi.encodePacked(
messageBody, _reads[i].domain, _reads[i].target, safeCastToUint32(_reads[i].callData.length), _reads[i].callData
);
}
messageBody = abi.encodePacked(messageBody, _writeCallData);
return abi.encodePacked(
_version, uint8(_finalityState), _nonce, _originDomain, _sender, _destinationDomain, _recipient, messageBody
);
}
/**
* @notice Returns the message ID.
* @param _message ABI encoded Metalayer message.
* @return ID of `_message`
*/
function id(bytes memory _message) internal pure returns (bytes32) {
return keccak256(_message);
}
/**
* @notice Returns the message version
* @param _message ABI encoded Metalayer message
* @return Version of `_message`
*/
function version(bytes calldata _message) internal pure returns (uint8) {
return uint8(bytes1(_message[VERSION_OFFSET:FINALITY_STATE_FLAG_OFFSET]));
}
/**
* @notice Returns the message nonce
* @param _message ABI encoded Metalayer message
* @return Nonce of `_message`
*/
function nonce(bytes calldata _message) internal pure returns (uint32) {
return uint32(bytes4(_message[NONCE_OFFSET:ORIGIN_OFFSET]));
}
/**
* @notice Returns whether the message should use finalized ISM
* @param _message ABI encoded Metalayer message
* @return Whether to use finalized ISM
*/
function finalityState(bytes calldata _message) internal pure returns (FinalityState) {
return FinalityState(uint8(bytes1(_message[FINALITY_STATE_FLAG_OFFSET:NONCE_OFFSET])));
}
/**
* @notice Returns the message origin domain
* @param _message ABI encoded Metalayer message
* @return Origin domain of `_message`
*/
function origin(bytes calldata _message) internal pure returns (uint32) {
return uint32(bytes4(_message[ORIGIN_OFFSET:SENDER_OFFSET]));
}
/**
* @notice Returns the message sender as address
* @param _message ABI encoded Metalayer message
* @return Sender of `_message` as a bytes32-encoded address
*/
function senderAddress(bytes calldata _message) internal pure returns (bytes32) {
return bytes32(_message[SENDER_OFFSET:DESTINATION_OFFSET]);
}
/**
* @notice Returns the message destination domain
* @param _message ABI encoded Metalayer message
* @return Destination domain of `_message`
*/
function destination(bytes calldata _message) internal pure returns (uint32) {
return uint32(bytes4(_message[DESTINATION_OFFSET:RECIPIENT_OFFSET]));
}
/**
* @notice Returns the message recipient as address. We only support evm chains for now, so address only.
* @param _message ABI encoded Metalayer message
* @return Recipient of `_message` as address
*/
function recipientAddress(bytes calldata _message) internal pure returns (bytes32) {
return bytes32(_message[RECIPIENT_OFFSET:BODY_OFFSET]);
}
/**
* @notice Returns the message body
* @param _message ABI encoded Metalayer message
* @return Body of `_message`
*/
function body(bytes calldata _message) internal pure returns (bytes calldata) {
return bytes(_message[BODY_OFFSET:]);
}
/**
* @notice Returns the number of read operations in the message
* @param _message ABI encoded Metalayer message
* @return Number of read operations
*/
function readCount(bytes calldata _message) internal pure returns (uint32) {
return uint32(bytes4(body(_message)[READ_COUNT_OFFSET:READS_OFFSET]));
}
/**
* @notice Returns the read operation at the specified index
* @param _message ABI encoded Metalayer message
* @param _index Index of the read operation to retrieve
* @return The read operation at the specified index
*/
function getRead(bytes calldata _message, uint256 _index) internal pure returns (ReadOperation memory) {
require(_index < readCount(_message), "Index out of bounds");
bytes calldata messageBody = body(_message);
uint256 currentOffset = READS_OFFSET;
for (uint256 i = 0; i < _index; i++) {
uint256 lesserCallDataLength = uint32(bytes4(messageBody[currentOffset + 24:currentOffset + 28]));
currentOffset += 28 + lesserCallDataLength; // 4 (domain) + 20 (contract) + 4 (length) + lesserCallDataLength
}
// first 4 bytes are the uint32 sourceChainId.
uint32 sourceChainId = uint32(bytes4(messageBody[currentOffset:currentOffset + 4]));
// next 20 bytes are the address of the contract to read from. Offset computed as starting after the previous 4 bytes, ending 20 bytes later (==24)
address sourceContract = address(bytes20(messageBody[currentOffset + 4:currentOffset + 24]));
// next 4 bytes are the uint32 length of the call data. Offset computed as starting after the previous 24 bytes, ending 4 bytes later (==28)
uint256 callDataLength = uint32(bytes4(messageBody[currentOffset + 24:currentOffset + 28]));
// next callDataLength bytes are the call data. Offset computed as starting after the previous 28 bytes, ending callDataLength bytes later (==28 + callDataLength)
bytes calldata callData = messageBody[currentOffset + 28:currentOffset + 28 + callDataLength];
return ReadOperation({domain: sourceChainId, target: sourceContract, callData: callData});
}
/**
* @notice Returns all read operations from the message. Use this instead of getRead repeatedly if you want all operations as that is O(n) to find one read, O(n^2) total.
* @param _message ABI encoded Metalayer message
* @return Array of all read operations
*/
function reads(bytes calldata _message) internal pure returns (ReadOperation[] memory) {
uint256 numReads = readCount(_message);
ReadOperation[] memory tempReads = new ReadOperation[](numReads);
bytes calldata messageBody = body(_message);
uint256 currentOffset = READS_OFFSET;
for (uint256 i = 0; i < numReads; i++) {
// first 4 bytes are the uint32 sourceChainId. Offset computed as starting at the current offset, ending 4 bytes later (==4)
uint32 sourceChainId = uint32(bytes4(messageBody[currentOffset:currentOffset + 4]));
// next 20 bytes are the address of the contract to read from. Offset computed as starting after the previous 4 bytes, ending 20 bytes later (==24)
address sourceContract = address(bytes20(messageBody[currentOffset + 4:currentOffset + 24]));
// next 4 bytes are the uint32 length of the call data. Offset computed as starting after the previous 24 bytes, ending 4 bytes later (==28)
uint256 callDataLength = uint32(bytes4(messageBody[currentOffset + 24:currentOffset + 28]));
// next callDataLength bytes are the call data. Offset computed as starting after the previous 28 bytes, ending callDataLength bytes later (==28 + callDataLength)
bytes calldata callData = messageBody[currentOffset + 28:currentOffset + 28 + callDataLength];
tempReads[i] = ReadOperation({domain: sourceChainId, target: sourceContract, callData: callData});
currentOffset += 28 + callDataLength; // 4 (domain) + 20 (contract) + 4 (length) + callDataLength
}
return tempReads;
}
/**
* @notice Returns the write call data from the message
* @param _message ABI encoded Metalayer message
* @return The write call data
*/
function writeCallData(bytes calldata _message) internal pure returns (bytes calldata) {
bytes calldata messageBody = body(_message);
uint256 currentOffset = READS_OFFSET;
uint256 numReads = readCount(_message);
for (uint256 i = 0; i < numReads; i++) {
// an encoded read operation starts with 4 bytes for the sourceChainId, 20 bytes for the contract address, 4 bytes for the callDataLength, and then the callDataLength bytes for the callData.
// calldata length can be extracted as the uint32 beginning after the sourceChainId and contract address (20+4=24 bytes in total), and ending 4 bytes later (==28).
uint256 callDataLength = uint32(bytes4(messageBody[currentOffset + 24:currentOffset + 28]));
// the next callDataLength bytes are the call data. This read ends after the 28 byte header and the callDataLength bytes.
currentOffset += 28 + callDataLength;
}
return messageBody[currentOffset:];
}
}
"
},
"src/lib/MetaERC20Message.sol": {
"content": "// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity 0.8.26;
import {MetaERC20MessageStruct, MetaERC20MessageType} from "./MetaERC20Types.sol";
/// @title MetaERC20Message
/// @notice Library for encoding and decoding MetaERC20 protocol messages
/// @dev Provides low-level serialization for MetaERC20MessageStruct with fixed 128-byte layout.
/// Uses raw amounts with source decimal metadata instead of pre-scaled values for maximum
/// precision and cross-chain compatibility. Supports tokens with any decimal count.
///
/// Message layout (128 bytes total):
/// - transferId (32B) + timestamp (32B) + version (1B) + messageType (1B)
/// - padding (5B) + sourceDecimals (1B) + recipientDomain (4B)
/// - recipient (20B) + amount (32B)
///
/// Key features:
/// - Fixed-size encoding for predictable gas costs
/// - Comprehensive validation during encoding
/// - Raw amount preservation with decimal metadata
/// - Block explorer friendly field ordering (smaller fields grouped)
/// - Zero-tolerance validation for critical fields
///
library MetaERC20Message {
/*//////////////////////////////////////////////////////////////
CONSTANTS
//////////////////////////////////////////////////////////////*/
/// @dev Byte offsets for message field decoding
uint8 private constant TRANSFERID_OFFSET = 0;
uint8 private constant TRANSFERID_SIZE = 32;
uint8 private constant TIMESTAMP_OFFSET = 32;
uint8 private constant TIMESTAMP_SIZE = 32;
uint8 private constant VERSION_OFFSET = 64;
uint8 private constant VERSION_SIZE = 1;
uint8 private constant MESSAGETYPE_OFFSET = 65;
uint8 private constant MESSAGETYPE_SIZE = 1;
// 5 bytes padding here (66-70)
uint8 private constant SOURCEDECIMALS_OFFSET = 71;
uint8 private constant SOURCEDECIMALS_SIZE = 1;
uint8 private constant RECIPIENTDOMAIN_OFFSET = 72;
uint8 private constant RECIPIENTDOMAIN_SIZE = 4;
uint8 private constant RECIPIENT_OFFSET = 76;
uint8 private constant RECIPIENT_SIZE = 20;
uint8 private constant AMOUNT_OFFSET = 96;
uint8 private constant AMOUNT_SIZE = 32;
/// @dev Fixed size of a MetaERC20MessageStruct when encoded (128 bytes)
uint8 private constant MESSAGE_SIZE = 128;
/*//////////////////////////////////////////////////////////////
ERRORS
//////////////////////////////////////////////////////////////*/
/// @notice Thrown when attempting to decode a message shorter than MESSAGE_SIZE
error IncompleteMessage();
/// @notice Thrown when encoding a message with an unknown or out-of-bounds messageType
error InvalidMessageType(uint8 messageType);
/// @notice Thrown when encoding a message with version == 0 (unsupported)
error UnsupportedVersion(uint8 version);
/// @notice Thrown when encoding a message with recipientDomain == 0
error ZeroRecipientDomain();
/// @notice Thrown when encoding a message with timestamp == 0
error ZeroTimestamp();
/// @notice Thrown when attempting to encode a message with zero transferId
error ZeroTransferId();
/// @notice Thrown when encoding a message with a zero recipient address
error ZeroRecipient();
/*//////////////////////////////////////////////////////////////
DECODERS
//////////////////////////////////////////////////////////////*/
/// @notice Decodes a MetaERC20 message from raw calldata
/// @param _data ABI-encoded message bytes (expected length = MESSAGE_SIZE)
/// @return msgStruct Decoded MetaERC20MessageStruct
function decodeMessage(
bytes calldata _data
) internal pure returns (MetaERC20MessageStruct memory msgStruct) {
if (_data.length < MESSAGE_SIZE) revert IncompleteMessage();
msgStruct.transferId = bytes32(
_data[TRANSFERID_OFFSET:TRANSFERID_OFFSET + TRANSFERID_SIZE]
);
msgStruct.timestamp = uint256(
bytes32(_data[TIMESTAMP_OFFSET:TIMESTAMP_OFFSET + TIMESTAMP_SIZE])
);
msgStruct.metaERC20Version = uint8(_data[VERSION_OFFSET]);
msgStruct.messageType = MetaERC20MessageType(
uint8(_data[MESSAGETYPE_OFFSET])
);
msgStruct.sourceDecimals = uint8(_data[SOURCEDECIMALS_OFFSET]);
msgStruct.recipientDomain = uint32(
bytes4(
_data[RECIPIENTDOMAIN_OFFSET:RECIPIENTDOMAIN_OFFSET +
RECIPIENTDOMAIN_SIZE]
)
);
msgStruct.recipient = address(
bytes20(_data[RECIPIENT_OFFSET:RECIPIENT_OFFSET + RECIPIENT_SIZE])
);
msgStruct.amount = uint256(
bytes32(_data[AMOUNT_OFFSET:AMOUNT_OFFSET + AMOUNT_SIZE])
);
return msgStruct;
}
/*//////////////////////////////////////////////////////////////
ENCODERS
//////////////////////////////////////////////////////////////*/
/// @notice Encodes a MetaERC20MessageStruct into bytes
/// @param _message The fully populated message struct
/// @return Encoded bytes representation of the message (length = MESSAGE_SIZE)
function encodeMessage(
MetaERC20MessageStruct memory _message
) internal pure returns (bytes memory) {
if (_message.transferId == bytes32(0)) revert ZeroTransferId();
if (_message.timestamp == 0) revert ZeroTimestamp();
if (_message.metaERC20Version == 0) revert UnsupportedVersion(0);
if (
uint8(_message.messageType) >=
uint8(MetaERC20MessageType.__MessageTypeCount)
) revert InvalidMessageType(uint8(_message.messageType));
if (_message.recipientDomain == 0) revert ZeroRecipientDomain();
if (_message.recipient == address(0)) revert ZeroRecipient();
return
abi.encodePacked(
_message.transferId, // 32 bytes (0-31)
_message.timestamp, // 32 bytes (32-63)
_message.metaERC20Version, // 1 byte (64)
uint8(_message.messageType), // 1 byte (65)
bytes5(0), // 5 bytes padding (66-70)
_message.sourceDecimals, // 1 byte (71)
_message.recipientDomain, // 4 bytes (72-75)
_message.recipient, // 20 bytes (76-95)
_message.amount // 32 bytes (96-127)
); // Total: 128 bytes
}
}
"
},
"src/lib/MetaERC20Types.sol": {
"content": "// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity 0.8.26;
/// @title MetaERC20MessageType
/// @notice Enumerates supported MetaERC20 message intents for cross-chain dispatch
enum MetaERC20MessageType {
/// @notice Mint synthetic tokens on the destination chain
MintRequest,
/// @notice Unlock canonical tokens on the destination chain
UnlockRequest,
/// @notice Relays a Spoke-to-Spoke transfer via the Hub for security inspection
SecurityRelay,
/// @notice Admin-triggered reissuance or override action
AdminAction,
/// @dev Sentinel value for bounds checking and enum size
__MessageTypeCount
}
/// @title MetaERC20MessageStruct
/// @notice Fully packed message used for MetaERC20 cross-chain communication
/// @dev Serialized as a 128-byte abi.encodePacked payload with no dynamic fields.
/// Layout: transferId(32) + timestamp(32) + version(1) + messageType(1) +
/// padding(5) + sourceDecimals(1) + recipientDomain(4) + recipient(20) + amount(32)
struct MetaERC20MessageStruct {
/// @notice Unique ID for this transfer, generated deterministically at source
bytes32 transferId;
/// @notice Local block.timestamp when the message was created at source
uint256 timestamp;
/// @notice Version of the MetaERC20 message protocol used
uint8 metaERC20Version;
/// @notice Type of message intent (MintRequest, UnlockRequest, SecurityRelay, AdminAction)
MetaERC20MessageType messageType;
// 5 bytes padding (bytes 66-70)
/// @notice Number of decimal places used by the source token (e.g., 6 for USDC, 18 for WETH)
uint8 sourceDecimals;
/// @notice Metalayer domain ID of the intended final destination chain
uint32 recipientDomain;
/// @notice Address that will receive the tokens on the destination chain
address recipient;
/// @notice Amount of tokens in source token's native units (no decimal scaling applied)
uint256 amount;
}
"
},
"src/interfaces/IMetalayerRecipient.sol": {
"content": "// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.6.11;
/**
* @title IMetalayerRecipient
* @notice Interface for contracts that can receive messages through the Metalayer protocol
* @dev Implement this interface to receive cross-chain messages and read results from Metalayer
*/
interface IMetalayerRecipient {
/**
* @notice Handles an incoming message from another chain via Metalayer
* @dev This function is called by the MetalayerRouter when a message is delivered
* @param _origin The domain ID of the chain where the message originated
* @param _sender The address of the contract that sent the message on the origin chain
* @param _message The payload of the message to be handled
* @param _reads Array of read operations that were requested in the original message
* @param _readResults Array of results from the read operations, provided by the relayer
* @custom:security The caller must be the MetalayerRouter contract
*/
function handle(
uint32 _origin,
bytes32 _sender,
bytes calldata _message,
ReadOperation[] calldata _reads,
bytes[] calldata _readResults
) external payable;
}
/**
* @notice Represents a cross-chain read operation
* @dev Used to specify what data should be read from other chains.
* The read operations are only compatible with EVM chains, so the
* target is packed as an address to save bytes.
*/
struct ReadOperation {
/// @notice The domain ID of the chain to read from
uint32 domain;
/// @notice The address of the contract to read from
address target;
/// @notice The calldata to execute on the target contract
bytes callData;
}
"
},
"src/token/MetaERC20Base.sol": {
"content": "// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity 0.8.26;
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {AccessControlDefaultAdminRulesUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlDefaultAdminRulesUpgradeable.sol";
// for sweep functions
import {IERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol";
import {SafeERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol";
import {TypeCasts} from "@hyperlane-xyz/core/contracts/libs/TypeCasts.sol";
import {IMetalayerRouter} from "../interfaces/IMetalayerRouter.sol";
import {MetalayerMessage, FinalityState} from "../lib/MetalayerMessage.sol";
import {MetaERC20Message} from "../lib/MetaERC20Message.sol";
import {MetaERC20MessageStruct, MetaERC20MessageType} from "../lib/MetaERC20Types.sol";
import {IMetalayerRecipient, ReadOperation} from "../interfaces/IMetalayerRecipient.sol";
/// @title MetaERC20Base
/// @notice Abstract base contract for MetaERC20Hub and MetaERC20Spoke
/// @dev Implements shared storage, decimal conversion logic, message dispatch, and upgradeable behavior.
/// Inheriting contracts must override validation and message handling hooks to enforce custom routing logic.
/// Uses raw amounts with source decimal metadata instead of pre-scaling for maximum precision and flexibility.
///
/// Key features:
/// - Configurable gas limits with admin controls
/// - Efficient O(1) domain registration using dual-mapping pattern
/// - CEI pattern throughout for reentrancy protection
/// - Transfer record management with TTL-based pruning
/// - Replay protection using transferId tracking
///
/// Skipped features:
/// - supportsInterface(...) - Not needed for current use case
/// - reclaimETH or sweep() - No ETH handling planned
/// - quoteDispatch(...) - Gas estimation handled externally
///
/// @custom:oz-upgrades-unsafe-allow constructor
abstract contract MetaERC20Base is
Initializable,
AccessControlDefaultAdminRulesUpgradeable,
IMetalayerRecipient
{
using SafeERC20Upgradeable for IERC20Upgradeable;
using TypeCasts for bytes32;
using TypeCasts for address;
/*//////////////////////////////////////////////////////////////
CONSTANTS
//////////////////////////////////////////////////////////////*/
bytes32 public constant ADMIN_ROLE = keccak256("METAERC20ADMIN_ROLE");
/// @notice Maximum number of transfer IDs allowed in a batch prune
/// @dev Used in batchPruneTransfers to limit gas usage
uint256 public constant MAX_PRUNE_BATCH = 100;
/// @notice Maximum number of transfer IDs allowed in a batch query
/// @dev Used in getTransferRecords to prevent excessive RPC response sizes
uint256 public constant MAX_QUERY_BATCH = 500;
/// @notice Maximum number of domains that can be registered in a single batch call
/// @dev Used in setDomainAddressBatch to prevent out-of-gas errors
uint256 public constant MAX_DOMAIN_BATCH = 50;
/// @notice Default maximum gas limit (can be adjusted by admin)
uint256 public constant DEFAULT_MAX_GAS_LIMIT = 10_000_000; // 10M = 1/3 of block
/*//////////////////////////////////////////////////////////////
VARIABLES
//////////////////////////////////////////////////////////////*/
/*//////////////////////////////////////////////////////////////
STORAGE LAYOUT (SLOT MAP)
---------------------------------------------------------------
| Slot | Field | Size | Notes |
|-------|--------------------------|--------|-----------------|
| 0 | _initialized | 1 B | |
| | _initializing | 1 B | |
| | unused | 30 B | |
| 1 | _supportedInterfaces | 32 B | |
| 2 | _roles | 32 B | |
| 3 | _pendingDefaultAdmin | 26 B | |
| | unused | 6 B | |
| 4 | _currentDelay | 6 B | |
| | _currentAdmin | 20 B | |
| | unused | 8 B | |
| 5 | _pendingDelay | 12 B | |
|-------|--------------------------|--------|-----------------|
| 6 | metalayerRouter | 20 B | |
| | localDomain | 4 B | |
| 7 | ttlWindow | 32 B | |
| 8 | transferNonce | 32 B | |
| 9 | maxGasLimit | 32 B | |
| 10 | __reservedSlot5 | 30 B | Padding |
| | tokenDecimals | 1 B | |
| | metaERC20Version | 1 B | |
| 11 | _transferRecords | — | Mapping anchor |
| 12 | executedTransfers | — | Mapping anchor |
| 13 | metaERC20Addresses | — | Mapping anchor |
| 14 | registeredDomainByIndex | — | Mapping anchor |
| 15 | registeredDomainIndex | — | Mapping anchor |
| 16 | registeredDomainCount | 32 B | |
| 17–66 | __gap | 50x32B | Reserved (Base) |
---------------------------------------------------------------
Total declared slots: 67 (17 + 50 gap)
//////////////////////////////////////////////////////////////*/
/// @notice Domain ID of this contract's local chain (Hyperlane domain, not EVM chainid)
uint32 public localDomain;
/// @notice Address of the MetalayerRouter used to dispatch and receive cross-chain messages
IMetalayerRouter public metalayerRouter;
/// @notice Number of seconds after which a transfer is eligible for pruning
uint256 public ttlWindow;
/// @notice Monotonically increasing nonce used for transfer ID generation
uint256 public transferNonce;
/// @notice Maximum gas limit allowed for cross-chain dispatch
/// @dev Prevents accidental or malicious consumption of entire block gas
uint256 public maxGasLimit;
/// @notice Pad top of slot 5 for alignment; tokenDecimals and metaERC20Version share the remaining bytes
uint240 private __reservedSlot5;
/// @notice Number of decimals the token uses for unit conversion
uint8 public tokenDecimals;
/// @notice Current MetaERC20 message version used for outbound messages
uint8 public metaERC20Version;
// Track tokens locked or burnt for handling failed mints
mapping(bytes32 => MetaERC20MessageStruct) internal _transferRecords;
/// @notice Tracks whether a given MetaERC20 transferId has been successfully executed
/// @dev Used to enforce idempotent handling of incoming messages and prevent double execution
mapping(bytes32 => bool) public executedTransfers;
/// @notice Maps a Metalayer domain ID to the expected MetaERC20 contract address on that domain
/// @dev Used for dynamic routing and message validation
mapping(uint32 domain => bytes32 metaERC20Address)
public metaERC20Addresses;
/// @notice Mapping from index to domain ID for efficient enumeration of registered domains
/// @dev Used with registeredDomainIndex to provide O(1) add operations while preserving enumeration capability
mapping(uint256 => uint32) public registeredDomainByIndex; // index → domain
/// @notice Mapping from domain ID to index for efficient lookups and validation
/// @dev Used to check if a domain is already registered and to locate its position for potential future operations
mapping(uint32 => uint256) public registeredDomainIndex; // domain → index
/// @notice Total number of domains currently registered in the system
/// @dev Incremented when new domains are added. Used as the next available index and for bounds checking.
uint256 public registeredDomainCount;
/*//////////////////////////////////////////////////////////////
ERRORS
//////////////////////////////////////////////////////////////*/
/// @notice Thrown when a duplicate MetaERC20 message is received and already processed
error AlreadyExecuted();
/// @notice Thrown when domain and address arrays differ in length
error ArrayLengthMismatch();
/// @notice Thrown when setDomainAddressBatch exceeds MAX_DOMAIN_BATCH
error ExceedsDomainBatchLimit();
/// @notice Thrown when batchPruneTransfers is called with more than MAX_PRUNE_BATCH IDs
error ExceedsPruneBatchLimit();
/// @notice Thrown when getTransferRecords is called with too many IDs
error ExceedsQueryBatchLimit();
/// @notice Thrown when a provided domain ID is zero or otherwise invalid
error InvalidDomain();
/// @notice Thrown when attempting to dispatch to the local domain (loopback)
error InvalidDomainLoopback();
/// @notice Thrown when an invalid or unsupported finality state is provided
/// @param state The invalid finality state value
error InvalidFinalityState(uint8 state);
/// @notice T
Submitted on: 2025-10-24 15:41:17
Comments
Log in to comment.
No comments yet.