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
minimumCommitmentAmountandmaximumCommitmentAmount. - 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.valueequalsbridgeFee()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
restrictedby an OpenZeppelinAccessManager, 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 theGovernanceRouter, 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
_tokenToCasperand_casperToTokenmappings - Removes token from
_registeredTokensarray - 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 and Links
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.
setLink
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'sTransferCommitted, joined bysendId) and deliver through the destination gateway, instead of watchingLockedandWrappedBurnedand submitting signedunlockandmintWrappedcalls. 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
- Verification: Inbound transfers arrive only through the linked ERC-7786 gateway, which requires M-of-N relayer signatures; the locker recomputes each
transferId - Replay Protection: Each
transferIdexecutes at most once; the gateway also refuses a repeatedsendId - 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
- Fee Protection: Bridge fees must be paid to prevent spam
- Pause Mechanism: Any one pauser key can pause in emergencies; unpausing is a delayed governance action that a later pause invalidates
- Token Registration: Only registered tokens can be bridged
- 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.