MultiSigERC20Locker

The MultiSigERC20Locker contract manages token locking and unlocking on EVM chains for cross-chain bridging to Casper Network.

Overview

Property Value
Solidity Version ^0.8.24
License MIT
Upgradeability UUPS Proxy
Security M-of-N Multi-signature

Core Functions

lock

Lock an EVM-origin ERC-20 on a lock-mint route. The locker records the transfer and sends it to Casper through the route's linked ERC-7786 gateway in the same transaction.

function lock(
    bytes32 routeId,
    uint256 amount,
    uint8 recipientKind,
    bytes32 recipientValue
) external payable returns (bytes32 transferId);

Parameters:

Name Type Description
routeId bytes32 A registered lock-mint route (its asset is the backing domain's origin ERC-20)
amount uint256 Amount to lock, in base units
recipientKind uint8 2 for a Casper account hash, 3 for a Casper contract-package hash
recipientValue bytes32 Nonzero 32-byte account or package hash, never a public key

Returns: transferId, the transfer's canonical identity. The gateway message carries the same transfer as its ABT1 payload.

Requirements:

  • The domain and the route are ACTIVE, and the lock pause flag is clear in the global scope and the domain's scope.
  • The committed amount is what the locker actually receives. It must lie within the route's minimumCommitmentAmount and maximumCommitmentAmount.
  • Custody accepted plus the committed amount stays within the domain's custody ceiling, and the route's outstanding representation stays within its allocation and the domain's representation ceiling. Otherwise the whole transaction reverts, fee included.
  • msg.value equals bridgeFee() exactly. The fee goes to the fee recipient on commitment and is not refunded.
  • The route's Casper network has a link whose counterpart is the route's Casper bridge package.

Example:

const routeId = '0x...'; // from backingDomain(domainId).routeIds
const amount = ethers.parseUnits("100", 6); // 100 USDC
await (await token.approve(await locker.getAddress(), amount)).wait();
const fee = await locker.bridgeFee();
const tx = await locker.lock(routeId, amount, 2, casperAccountHash, { value: fee });
const receipt = await tx.wait();
// TransferCommitted carries transferId, sourceSequence and the gateway sendId.

Events Emitted:

event CustodyAccountingChanged(bytes32 indexed backingDomainId, uint256 acceptedCustody, uint8 reason);
event RepresentationAccountingChanged(bytes32 indexed backingDomainId, bytes32 indexed routeId, uint256 routeOutstanding, uint8 reason);
event FeeCollected(address indexed token, address indexed user, uint256 fee);
event TransferCommitted(
    bytes32 indexed transferId,
    bytes32 indexed routeId,
    uint64 sourceSequence,
    uint256 amount,
    uint256 policyVersion,
    uint64 sourceCommitmentTimeMs,
    bytes32 sendId
);

The gateway also emits the standard ERC-7786 MessageSent.


receiveMessage (inbound transfers)

Transfers from Casper arrive as ERC-7786 messages. There is no signature-based unlock or mintWrapped: the linked gateway verifies the relayer quorum and calls the locker.

function receiveMessage(bytes32 receiveId, bytes calldata sender, bytes calldata payload)
    external payable returns (bytes4);

Accepted only from the linked gateway, with the registered Casper counterpart as sender. The payload is the twelve-word ABT1 transfer. The locker recomputes its transferId, and checks that the route runs from that Casper package to this locker, that decimals match and that the recipient is an EVM address. A message that fails any check reverts and records nothing, so a corrected delivery of the same message still works.

A verified transfer then ends in one of three states:

Outcome When
Executed A backing release (burn-unlock route) at or under largeReleaseThreshold, with an empty queue and room in the release limiter, pays out at once. A mint (lock-mintWrapped route) within the route's local allocation mints at once.
Queued A backing release above the threshold, or behind a non-empty queue, or without limiter room, waits at least queueDelaySeconds at the tail of its domain's queue.
Held The domain or route is UNCONFIGURED or FULLY_PAUSED, execution is paused, the domain has no representation token, a mint would exceed the local allocation, or the asset refuses the payout to the recipient.

A backing release can never return more than its lock-mint route has outstanding and not already claimed by other pending releases. Executing it returns that amount of custody and representation.

Events Emitted:

event TransferExecuted(bytes32 indexed transferId, uint256 amount, bool immediate);
event TransferQueued(bytes32 indexed transferId, bytes32 indexed backingDomainId, uint256 queuePosition, uint64 queuedAtMs);
event TransferHeld(bytes32 indexed transferId, uint8 reason);
event QueuedTransferSkipped(bytes32 indexed transferId, bytes32 indexed backingDomainId);
event RateLimitConsumed(bytes32 indexed backingDomainId, uint256 amount, uint256 availableAfter, uint256 releaseDebtAfter);

Held reasons: 1 status, 2 paused, 3 no representation token, 4 local allocation, 5 recipient refused.


completeQueuedRelease

Complete the head of a backing domain's release queue. Permissionless; no fee.

function completeQueuedRelease(bytes32 backingDomainId) external returns (bool released);

Completes strictly first in, first out. Reverts with QueuedTransferNotReady before the head's delay, and ReleaseNotExecutable while the domain or route does not execute. Returns false without reverting when the release limiter cannot cover the head yet; any debt repayment it made persists, and the head keeps its place. If the asset refuses the payout, the head moves to held and the queue advances, so one refused recipient cannot stall the domain.


retryHeld

Retry a held transfer once its condition clears. Permissionless; no fee.

function retryHeld(bytes32 transferId) external;

Pays the original recipient the original amount. A backing release goes through the same execute-or-queue decision as a new arrival. Reverts with TransferStillHeld(transferId, reason) and changes nothing while a hold condition remains.


burnWrapped

Burn the wrapped representation of a Casper-origin asset on a burnWrapped-unlock route, and send the transfer to Casper through the route's linked gateway.

function burnWrapped(
    bytes32 routeId,
    uint256 amount,
    uint8 recipientKind,
    bytes32 recipientValue
) external payable returns (bytes32 transferId);

The parameters, fee rule and TransferCommitted event are as for lock. The burned token is the domain's representation token (setRepresentationToken); approve the locker for amount first, because the burn consumes that allowance. The domain and route must be ACTIVE or DEPOSIT_PAUSED, and the wrapped-burn pause flag clear. A burn returns representation, so it is not charged against this chain's custody or representation ceilings: the Casper custody holder keeps that accounting.


Admin Functions

Governance (V18, #212). From locker V18 there is no single admin. Every privileged function is restricted by an OpenZeppelin AccessManager, which assigns it a role. Immediate actions are signed by the 2-of-3 governance Safe; pause by any one pauser key (stop-only); delayed actions (upgrades, links, relayer set, recovery address, unpause) are scheduled by the governance Safe through the GovernanceRouter, wait 24 hours (30 minutes for unpause) and can be cancelled by any two of the five veto keyholders. See bridge administration governance for the full map.

registerToken

Register a new ERC-20 token for bridging.

function registerToken(
    address token,
    bytes32 casperContractHash,
    uint8 casperDecimals
) external;

Access (V18): governance Safe, immediate (GOVERNOR role). Pre-V18 lockers: admin only.

Reverts with ZeroCasperHash if casperContractHash is zero (it collides with the "unregistered" mapping sentinel - V15+; also enforced on updateTokenMapping), and with TokenDecimalsOutOfRange if the token reports decimals() > 36 (MAX_TOKEN_DECIMALS, V15+).

Events:

event TokenRegistered(
    address indexed token,
    bytes32 indexed casperContractHash,
    string name,
    string symbol,
    uint8 decimals
);

unregisterToken

Remove an ERC-20 token from the bridge registry. Added in V6.

function unregisterToken(address token) external;

Access (V18): governance Safe, immediate (GOVERNOR role). Pre-V18 lockers: admin only.

Parameters:

Name Type Description
token address ERC-20 token address to unregister

Behavior:

  • Clears _tokenToCasper and _casperToToken mappings
  • Removes token from _registeredTokens array
  • Uses swap-and-pop for gas-efficient array removal

Use Case: Remove incorrectly registered tokens or deprecated tokens from the bridge.

Events:

event TokenUnregistered(
    address indexed token,
    bytes32 indexed casperContractHash
);

updateTokenMapping

Update the Casper contract hash for an already registered token.

function updateTokenMapping(
    address token,
    bytes32 newCasperContractHash
) external;

Access (V18): governance Safe, immediate (GOVERNOR role). Pre-V18 lockers: admin only.

Parameters:

Name Type Description
token address ERC-20 token address (must be registered)
newCasperContractHash bytes32 New Casper CEP-18 contract hash

Use Case: Update the Casper-side contract mapping if a token migrates to a new CEP-18 contract.


updateRelayerSet

Update the relayer set and threshold.

function updateRelayerSet(
    address[] calldata relayers,
    uint256 threshold
) external;

Parameters:

Name Type Description
relayers address[] New relayer addresses
threshold uint256 Required signatures (M)

Access (V18): delayed: scheduled through the GovernanceRouter, 24 hours, veto-cancellable (MEMBERSHIP role). Pre-V18 lockers: admin only.

Validation: threshold >= 2, threshold <= relayers.length, and threshold * 2 > relayers.length (strict majority) - a minority quorum such as 2-of-100 is rejected with InvalidThreshold.

Events:

event RelayerSetUpdated(
    address[] relayers,
    uint256 threshold
);

deployWrappedToken

Deploy a wrapped ERC-20 for Casper-native tokens.

function deployWrappedToken(
    bytes32 casperTokenHash,
    string calldata name,
    string calldata symbol,
    uint8 decimals
) external returns (address wrappedToken);

Returns:

  • wrappedToken - Address of deployed wrapped token

Events:

event WrappedTokenDeployed(
    address indexed wrappedToken,
    bytes32 indexed casperTokenHash,
    string name,
    string symbol,
    uint8 decimals
);

replaceWrappedToken

V13 provides a one-step mapping rotation for zero-supply Casper-native wrappers deployed before the bridge-only-burn hardening. It is admin-only and requires the locker to be fully paused. The function rejects a zero or unknown Casper hash, inconsistent wrapper mappings/immutables/enumeration, and any non-zero old-token total supply. It copies token metadata and the configured minimum burn amount; there is no holder snapshot or balance migration.

function replaceWrappedToken(
    bytes32 casperTokenHash
) external returns (address newWrappedToken);

On success, the forward and reverse mappings and the existing enumeration slot point to the new wrapper atomically. The old reverse mapping is deleted, so the old wrapper is no longer recognized by isWrappedToken or burnWrapped.

event WrappedTokenReplaced(
    address indexed oldWrappedToken,
    address indexed newWrappedToken,
    bytes32 indexed casperTokenHash
);

New V13 wrappers do not expose holder-callable burn(uint256). Their only burn entry point is burnFrom(account, amount), restricted to the locker and consuming the allowance granted to the locker. Users must call burnWrapped so every supply reduction emits WrappedBurned for the Casper unlock path.


Source limits live in each route's policy: minimumCommitmentAmount and maximumCommitmentAmount (see configurePolicy), with the domain's custody and representation ceilings. There is no separate lock or burn minimum.

Link the locker to one Casper network: the ERC-7786 gateway that carries its messages and the counterpart bridge package there.

function setLink(address gateway, bytes calldata counterpart) external;

Access: a delayed governance action (24 hours, veto-cancellable), scheduled through the GovernanceRouter. counterpart is a canonical ERC-7930 Casper contract-package address. A new link replaces the old one for that network.

setRepresentationToken

Set the wrapped ERC-20 that represents a Casper-origin backing domain on this chain.

function setRepresentationToken(bytes32 domainId, address token) external;

Access: governance Safe, immediate (GOVERNOR role). The token must be minted and burned only by this locker, carry the domain's decimals, represent no other domain and have zero supply, so every unit in circulation was minted under the route's local allocation. A token already set can be replaced only while its supply is zero.

custodyState, representationState, routeState

function custodyState(bytes32 domainId) external view
    returns (uint256 acceptedCustody, uint256 custodyCeiling, uint256 remaining);
function representationState(bytes32 domainId) external view
    returns (uint256 outstanding, uint256 representationCeiling, address representationToken);
function routeState(bytes32 routeId) external view
    returns (uint64 sourceSequence, uint256 routeOutstanding);

Custody accepted through commitments (direct donations never count), outstanding representation and the last source sequence of a route. remaining is zero when a lowered ceiling is below use. On a lock-mintWrapped route, routeOutstanding is the representation minted here and not yet burned.

inboundTransfer, queueState, releaseLimiterState

function inboundTransfer(bytes32 transferId) external view returns (InboundTransfer memory);
function queueState(bytes32 backingDomainId) external view
    returns (uint256 head, uint256 length, bytes32 headTransferId, uint256 queuedOrHeld);
function releaseLimiterState(bytes32 domainId) external view
    returns (uint256 used, uint256 available, uint256 releaseDebt);

An inbound transfer's state (NONE, QUEUED, HELD, EXECUTED, or TERMINAL after a transfer-specific repair), the reason stored at its last hold, recipient, route, amount, queue time, ready time and queue position; the domain's queue; and its release limiter.

transferStatus

function transferStatus(bytes32 transferId) external view returns (uint8 status, uint8 reason);

An inbound transfer's status from current conditions, not from the stored record: 0 unknown, 1 unconfigured, 2 paused, 3 held, 4 queued_waiting_delay, 5 queued_waiting_capacity, 6 queued_behind_head, 7 queued_ready, 8 recovery_held, 9 executed, 10 terminal. reason is the condition blocking it now: 1 domain or route not executing, 2 execution paused, 3 no representation token, 4 local mint allocation, 5 recipient refused or short-paid, 6 recovery shortfall; 0 if none. A paused or recovery-held head is reported as such, never as queued_ready.

Emergency withdrawal and restoreBacking

function emergencyWithdraw(address token, uint256 amount) external;          // governance, fully paused, after 24 hours
function restoreBacking(address token, uint256 amount) external returns (uint256 received);  // anyone
function recoveryShortfall(bytes32 backingDomainId) external view returns (uint256);

emergencyWithdraw pays only the registered recovery address. The part of what actually left the locker (the amount, or more if the token charges its sender a fee) above the balance not held for accepted custody is added to the token's domain recoveryShortfall, and the domain is paused in its own scope. While the shortfall is positive, no release of that domain pays out (recovery_held), and unpausing the domain reverts with RecoveryShortfallOutstanding. Withdrawing donations creates no shortfall. restoreBacking pulls at most the shortfall from the caller and reduces it by the amount received; it never adds custody capacity or pays a transfer. Once the shortfall is zero and governance has unpaused the domain, held and queued releases execute through retryHeld and completeQueuedRelease, exactly once each.


View Functions

getLockedBalance

The V17 locked-balance counter for a token. Exposure-control lockers no longer update it; custody is custodyState(domainId).

function getLockedBalance(address token) external view returns (uint256);

isDepositProcessed

Whether a deposit was processed by the signature-based releases of V17 and earlier, by its signed depositId. Inbound transfers on this version use inboundTransfer.

function isDepositProcessed(bytes32 depositId) external view returns (bool);

getRelayerSet

Get current relayer configuration.

function getRelayerSet() external view returns (
    address[] memory relayers,
    uint256 threshold
);

isRelayer

Check if address is a relayer.

function isRelayer(address account) external view returns (bool);

bridgeFee

Get current bridge fee.

function bridgeFee() external view returns (uint256 fee);

getWrappedToken

Get wrapped token for Casper token.

function getWrappedToken(bytes32 casperTokenHash) external view returns (address);

Events Reference

Token Operations

event TransferCommitted(bytes32 indexed transferId, bytes32 indexed routeId, uint64 sourceSequence, uint256 amount, uint256 policyVersion, uint64 sourceCommitmentTimeMs, bytes32 sendId);
event TransferExecuted(bytes32 indexed transferId, uint256 amount, bool immediate);
event TransferQueued(bytes32 indexed transferId, bytes32 indexed backingDomainId, uint256 queuePosition, uint64 queuedAtMs);
event TransferHeld(bytes32 indexed transferId, uint8 reason);
event QueuedTransferSkipped(bytes32 indexed transferId, bytes32 indexed backingDomainId);
event EmergencyWithdrawal(bytes32 indexed backingDomainId, address indexed asset, uint256 amount, address recoveryAddress, uint256 encumberedAmount, uint256 recoveryShortfall);
event BackingRestored(bytes32 indexed backingDomainId, address indexed asset, uint256 amount, uint256 recoveryShortfall);

Relayer compatibility: transfers travel as ERC-7786 messages in both directions. Relayers observe the gateway's MessageSent (or the locker's TransferCommitted, joined by sendId) and deliver through the destination gateway, instead of watching Locked and WrappedBurned and submitting signed unlock and mintWrapped calls. Deployed V17 lockers still use the older events and calls.

Administration

event TokenRegistered(address indexed token, bytes32 indexed casperContractHash, string name, string symbol, uint8 decimals);
event TokenUnregistered(address indexed token, bytes32 indexed casperContractHash);
event RelayerSetUpdated(address[] relayers, uint256 threshold);
event WrappedTokenDeployed(address indexed wrappedToken, bytes32 indexed casperTokenHash, string name, string symbol, uint8 decimals);
event FeeParametersUpdated(uint256 bridgeFee, address feeRecipient);

Error Codes

Error Description
InsufficientBridgeFee(provided, required) msg.value is not exactly the bridge fee
AmountOutOfRange(amount, minimum, maximum) Committed amount outside the route's bounds
CustodyCeilingExceeded / RouteAllocationExceeded / RepresentationCeilingExceeded Admission would exceed a ceiling or allocation
CommitmentsNotActive(routeId) / CommitmentsPaused(scope, flag) The domain or route is not open for new commitments
LinkNotSet(chain) / LinkMismatch(routeId) No link for the route's Casper network, or it points at another package
TransferIdMismatch / InvalidPayload / RouteSenderMismatch An inbound message does not match its transferId, format or route
TransferAlreadyRecorded(transferId) The transfer was already delivered
ReleaseExceedsOutstanding(routeId, available, amount) A release exceeds what its route has locked and not already claimed
QueuedTransferNotReady / ReleaseNotExecutable / QueueEmpty The queue head cannot complete yet
TransferNotHeld / TransferStillHeld(transferId, reason) retryHeld on a transfer that is not held, or whose condition remains
ReleaseOutOfGas A payout ran out of gas; resend with more gas
TokenNotRegistered Token not registered for bridging
InsufficientLockedBalance Not enough tokens locked
Paused Contract is paused
AccessManagedUnauthorized(caller) Caller holds no role for this function (V18)
UpgradeNotChecked / UpgradeCodeHashMismatch / UpgradeCodeMissing Upgrade did not go through upgradeToAndCallChecked with the approved code hash (V18)
StalePauseCount(scope, expected, current) A pause happened after this unpause was scheduled (V18)
RecoveryAddressNotSet Emergency withdrawal needs a registered recovery address (V18)
RecoveryShortfallOutstanding(backingDomainId, recoveryShortfall) A domain cannot be unpaused while backing taken by emergency withdrawal is not restored
NoRecoveryShortfall(backingDomainId) restoreBacking on a domain with nothing to restore
RecipientRefused The asset refused the payout, or delivered a different amount; the release is held

Integration Examples

JavaScript/TypeScript

import { ethers } from 'ethers';

// Connect to contract
const locker = new ethers.Contract(
    lockerAddress,
    MultiSigERC20LockerABI,
    signer
);

// Check if token is registered
const isRegistered = await locker.isTokenRegistered(usdcAddress);

// Lock tokens on a lock-mint route
async function lockTokens(routeId: string, token: string, amount: bigint, recipient: string) {
    // Accept account hash bytes only; never pad or truncate a public key.
    const hex = recipient.replace(/^account-hash-/, '').replace(/^0x/, '');
    if (!/^[0-9a-fA-F]{64}$/.test(hex)) throw new Error('Expected a 32-byte account hash');

    const tokenContract = new ethers.Contract(token, ERC20ABI, signer);
    await (await tokenContract.approve(await locker.getAddress(), amount)).wait();

    // The fee is exact; a later governance change can still make this revert.
    const fee = await locker.bridgeFee();
    const tx = await locker.lock(routeId, amount, 2, `0x${hex}`, { value: fee });
    return tx.wait();
}

Web3.js

const locker = new web3.eth.Contract(abi, lockerAddress);

// Get locked balance
const balance = await locker.methods.getLockedBalance(tokenAddress).call();

// Get relayer info
const { relayers, threshold } = await locker.methods.getRelayerSet().call();
console.log(`Threshold: ${threshold} of ${relayers.length}`);

Security Considerations

  1. Verification: Inbound transfers arrive only through the linked ERC-7786 gateway, which requires M-of-N relayer signatures; the locker recomputes each transferId
  2. Replay Protection: Each transferId executes at most once; the gateway also refuses a repeated sendId
  3. Release Limits: Every backing release is charged to the domain's release limiter, and releases above the threshold wait in a first-in, first-out queue
  4. Fee Protection: Bridge fees must be paid to prevent spam
  5. Pause Mechanism: Any one pauser key can pause in emergencies; unpausing is a delayed governance action that a later pause invalidates
  6. Token Registration: Only registered tokens can be bridged
  7. Source Limits: Every route has a positive minimum and a maximum commitment amount, and every domain custody and representation ceilings, so dust and oversized commitments revert before anything is sent

initializeV7

function initializeV7() external;

Access: permissionless from V18 (one-shot, version-guarded, and only after initialize set the authority). It advances the initializer version and has no other effect, so upgrade sequences that call it keep working.

results matching ""

    No results matching ""