TokenFactory (Casper)
The TokenFactory contract manages CEP-18 token operations on Casper Network for cross-chain bridging.
Overview
| Property | Value |
|---|---|
| Language | Rust (Odra Framework) |
| License | MIT |
| Token Standard | CEP-18 |
| Security | M-of-N relayer signatures, verified by the ERC-7786 gateway |
Core Functions
lock_native
Lock a Casper-origin CEP-18 on a lock-mintWrapped route. TokenFactory records the transfer and sends it to the route's EVM chain through the linked ERC-7786 gateway in the same deploy.
#[odra(payable, non_reentrant)]
pub fn lock_native(&mut self, route_id: H256, asset_amount: U256, recipient: H160) -> H256
Parameters:
| Name | Type | Description |
|---|---|---|
route_id |
H256 | A registered lock-mintWrapped route (its asset is the backing domain's origin CEP-18) |
asset_amount |
U256 | Amount to lock, in base units |
recipient |
H160 | Nonzero EVM recipient |
The amount argument is not named amount: Casper reserves that name for the motes attached to a payable call.
Returns: transferId, the transfer's canonical identity. The gateway message carries the same transfer as its ABT1 payload.
Requirements:
- The caller has approved TokenFactory on the CEP-18.
- The domain and the route are
ACTIVE, the contract is not paused, and the domain's lock pause flag is clear. - The committed amount is what TokenFactory actually receives. It must lie within the route's
minimum_commitment_amountandmaximum_commitment_amount. - 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 deploy reverts, fee included.
- The attached value equals the bridge fee exactly. The fee goes to the fee recipient on commitment and is not refunded.
- The route's EVM chain has a link whose counterpart is the route's EVM bridge.
Events:
struct CustodyAccountingChanged { backing_domain_id: H256, accepted_custody: U256, reason: u8 }
struct RepresentationAccountingChanged { backing_domain_id: H256, route_id: H256, route_outstanding: U256, reason: u8 }
struct FeeCollected { user: Address, amount: U512, token: Address }
struct TransferCommitted {
transfer_id: H256,
route_id: H256,
source_sequence: u64,
amount: U256,
policy_version: u64,
source_commitment_time_ms: u64,
send_id: H256,
}
The gateway also emits the standard ERC-7786 MessageSent.
burn
Burn the Casper representation of an EVM-origin asset on a burn-unlock route, and send the transfer to the origin chain through the route's linked gateway.
#[odra(payable, non_reentrant)]
pub fn burn(&mut self, route_id: H256, asset_amount: U256, recipient: H160) -> H256
Same parameters, fee and link rules as lock_native. The burned token is the domain's representation token (deploy_representation_token). A burn is accepted while the domain and route are ACTIVE or DEPOSIT_PAUSED, since it returns representation rather than taking in custody. It keeps no local custody or representation accounting: the origin EVM locker keeps that. It emits FeeCollected and TransferCommitted.
Example:
// Burn 100 USDC on its burn-unlock route to Ethereum
let fee = token_factory.get_bridge_fee();
let transfer_id = token_factory
.with_tokens(fee)
.burn(route_id, U256::from(100_000_000), eth_recipient_h160);
receive_message
The ERC-7786 recipient. The gateway linked for the sender's EVM chain calls it with a relayer-verified message; nobody else can.
#[odra(non_reentrant)]
pub fn receive_message(&mut self, receive_id: H256, sender: Bytes, payload: Bytes) -> [u8; 4]
The message must come from the linked gateway and its sender must be that chain's linked counterpart. The payload is the twelve-word ABT1 transfer, its transferId must hash from its fields, and its route must be an inbound route whose source is the sender. Each refusal reverts and records nothing, so the same message can be delivered again later.
A verified transfer is never dropped:
- Mint (lock-mint route of an EVM-origin domain): mints the domain's representation token to the recipient, within the route's local
representation_allocation. Mints are never queued and never touch the release limiter. - Release (burnWrapped-unlock route of a Casper-origin domain): pays the origin CEP-18 out of TokenFactory's custody. It executes in the same deploy when the amount is at or under the domain's
large_release_threshold, the queue is empty and the release limiter admits it. Otherwise it joins the domain's queue with a delay fixed at that moment. - Held: a transfer that cannot execute now is recorded as held with a reason:
1domain or routeUNCONFIGUREDorFULLY_PAUSED,2execution paused (globally or for the domain),3no representation token,4mint above the local allocation,5a release to TokenFactory itself, which a CEP-18 refuses,6a release from a domain with a recovery shortfall (seeemergency_withdraw).
A release can never claim more than its paired lock-mintWrapped route has outstanding and not already claimed by pending releases; above that it reverts.
Events: TransferExecuted { transfer_id, amount, immediate }, TransferQueued { transfer_id, backing_domain_id, queue_position, queued_at_ms }, TransferHeld { transfer_id, reason }, plus RateLimitConsumed and the accounting events for an executed release. The gateway emits MessageDelivered.
complete_queued_release
Completes the head of a backing domain's release queue, first in, first out. Anyone may call it, and it charges no fee.
#[odra(non_reentrant)]
pub fn complete_queued_release(&mut self, backing_domain_id: H256) -> bool
Reverts with QueueEmpty, QueuedTransferNotReady before the head's delay has passed, or ReleaseNotExecutable while the domain or route is paused or not executing, or the domain has a recovery shortfall. Returns false without paying when the release limiter cannot cover the head yet; any debt repayment it made is kept. Every release is charged through the limiter when it completes.
A head that a transfer-specific repair upgrade marked TERMINAL is skipped whatever its ready time: the call pays nothing, emits QueuedTransferSkipped { transfer_id, backing_domain_id } and returns false.
retry_held
Re-evaluates a held transfer once its condition clears. Anyone may call it, and it charges no fee.
#[odra(non_reentrant)]
pub fn retry_held(&mut self, transfer_id: H256)
Reverts with TransferNotHeld, or with TransferStillHeld while the condition remains. A release then executes or joins the queue like a new arrival. It always pays the original recipient the original amount.
restore_backing
Returns backing to a Casper-origin domain after an emergency withdrawal created a recovery shortfall. Anyone may call it, after approving TokenFactory on the CEP-18.
#[odra(non_reentrant)]
pub fn restore_backing(&mut self, token: Address, asset_amount: U256) -> U256
Pulls at most the shortfall and reduces it by the amount TokenFactory actually received, which it returns. Reverts with NoRecoveryShortfall when the token's domain has none. It never adds custody capacity or representation and pays no transfer: releases held by the shortfall execute through retry_held and complete_queued_release once it is zero and governance has unpaused the domain. Emits BackingRestored { backing_domain_id, asset, amount, recovery_shortfall }.
Admin Functions
deploy_token
Deploy a new CEP-18 wrapped token.
pub fn deploy_token(
&mut self,
chain_id: u64,
erc20_address: H160,
name: String,
symbol: String,
decimals: u8,
) -> Address
Access: Admin only
Events:
struct TokenDeployed {
erc20_address: H160,
cep18_address: Address,
name: String,
symbol: String,
decimals: u8,
}
register_token_mapping
Register mapping between EVM token and CEP-18.
pub fn register_token_mapping(
&mut self,
chain_id: u64,
erc20_address: H160,
cep18_address: Address,
)
Access: Admin only
Events:
struct TokenMappingRegistered {
erc20_address: H160,
cep18_address: Address,
}
register_native_token
Register a Casper-native CEP-18 token for bridging to EVM.
pub fn register_native_token(
&mut self,
cep18_address: Address,
chain_id: u64,
wrapped_token: H160,
name: String,
symbol: String,
decimals: u8,
)
Access: Admin only
Reverts with InvalidDecimals (60049) if decimals > 36.
Events:
struct NativeTokenRegistered {
cep18_address: Address,
chain_id: u64,
wrapped_token: H160,
}
set_eth_client
Update the EthClient contract reference.
pub fn set_eth_client(&mut self, eth_client: Address)
Access: Admin only
set_chain_name
Sets this network's name, which Casper-origin backing domain IDs and attestation domains commit to.
pub fn set_chain_name(&mut self, chain_name: String)
Access: Admin only, 24-hour lane. Once a Casper-origin backing domain is registered, only the same name is accepted (ChainNameFixed): a different name would change the domain ID that its custody, recovery shortfall and holds are recorded under.
set_bridge_fee
Update the bridge fee.
pub fn set_bridge_fee(&mut self, fee: U512)
Access: Admin only
Reverts with FeeExceedsMaximum (60050) if the fee exceeds 10,000 CSPR
(MAX_BRIDGE_FEE_MOTES) - a hard ceiling against fat-fingered values that would
make every burn/lock_native revert until corrected. The same ceiling
applies to set_fee_parameters and repair_fee_config.
Exact fee:
lock_nativeandburnmust attach exactly the bridge fee; any other value reverts withInsufficientBridgeFee(60035). When the fee is 0 they must attach no CSPR, and attached value reverts withUnexpectedAttachedValue(60048) instead of being absorbed.
pause / unpause
Emergency pause mechanism.
pub fn pause(&mut self)
pub fn unpause(&mut self)
Access: Admin only
set_link
Link TokenFactory to its counterpart on one EVM chain: the gateway that carries messages to and from it, and the counterpart bridge as a canonical ERC-7930 EVM address. Replaces an existing link for that chain.
pub fn set_link(&mut self, gateway: Address, counterpart: Bytes)
Access: Admin only, through the governance controller's 24-hour, veto-cancellable lane. The gateway must be a contract other than TokenFactory; the counterpart must be a canonical EVM address. Emits LinkChanged.
deploy_representation_token
Deploy the CEP-18 that represents an EVM-origin backing domain on Casper, and map the domain to it.
pub fn deploy_representation_token(&mut self, backing_domain_id: H256, name: String, symbol: String) -> Address
Access: Admin only, immediate lane. TokenFactory deploys the token through the bridged-token factory, with itself as minter and admin, the domain's decimals and zero supply. A token from outside is never accepted: what it reports about its minter or supply is its own answer. A token already set can be replaced only while it has zero supply. Emits RepresentationTokenSet.
set_recovery_address
Registers the one address emergency withdrawals pay.
pub fn set_recovery_address(&mut self, new_address: Address)
Access: Admin only, through the governance controller's 24-hour, veto-cancellable lane. Refuses the zero account and TokenFactory itself (InvalidRecoveryAddress). Emits RecoveryAddressChanged { previous_address, new_address }.
emergency_withdraw
Moves CEP-18 out of TokenFactory to the registered recovery address, to rescue backing from a contract believed unsafe.
#[odra(non_reentrant)]
pub fn emergency_withdraw(&mut self, token: Address, amount: U256) -> U256
Access: Admin only, immediate lane, and only after TokenFactory has been globally paused for 24 hours (NotPaused, EmergencyWithdrawTooEarly). Pausing again does not restart the clock; an unpause clears it. The caller chooses no beneficiary (RecoveryAddressNotSet until one is registered).
A withdrawal never cancels or rewrites a transfer. The part of the measured debit above the unencumbered balance (TokenFactory's balance minus the domain's accepted custody, before the withdrawal) is added to the domain's recovery_shortfall, and the domain is paused in its own scope. While the shortfall is positive:
- every release from that domain is held with reason
6: a new delivery is recorded held,retry_heldandcomplete_queued_releaserevert, and nothing is charged; unpause_domainreverts withRecoveryShortfallOutstandingin both governance lanes, so a later global unpause leaves the domain stopped;- only
restore_backingreduces it.
The governance controller counts a withdrawal that takes encumbered backing as a pause, even if the domain was already fully paused. Every unpause scheduled before it fails with StalePauseCount, including after restore_backing, and governance must schedule a new one.
Withdrawing donations creates no shortfall. A token that charges its sender takes more than amount; the whole debit counts. Returns the encumbered amount. Emits EmergencyWithdrawal { backing_domain_id, asset, amount, recovery_address, encumbered_amount, recovery_shortfall }.
View Functions
get_total_bridged
Get total bridged amount for a token.
pub fn get_total_bridged(&self, erc20_address: H160) -> U256
get_locked_per_chain
Get locked amount per source chain.
pub fn get_locked_per_chain(&self, chain_id: u64, erc20_address: H160) -> U256
is_event_processed
Check if an event has been processed.
pub fn is_event_processed(&self, event_hash: H256) -> bool
Source state
pub fn get_custody_state(&self, backing_domain_id: H256) -> (U256, U256, U256) // accepted, ceiling, remaining
pub fn get_representation_state(&self, backing_domain_id: H256) -> (U256, U256) // outstanding, ceiling
pub fn get_route_state(&self, route_id: H256) -> (u64, U256) // source sequence, route outstanding
pub fn get_source_commitment(&self, transfer_id: H256) -> Option<SourceCommitment>
pub fn get_link(&self, chain_id: H256) -> Option<Link> // chain id as a big-endian word
pub fn get_representation_token(&self, backing_domain_id: H256) -> Option<Address>
Destination and recovery state
pub fn get_transfer_status(&self, transfer_id: H256) -> (u8, u8) // status, blocking reason
pub fn get_inbound_transfer(&self, transfer_id: H256) -> Option<InboundTransfer>
pub fn get_queue_state(&self, backing_domain_id: H256) -> QueueState
pub fn get_recovery_shortfall(&self, backing_domain_id: H256) -> U256
pub fn get_recovery_address(&self) -> Option<Address>
get_transfer_status returns the transfer's live status from current state, with the same rules retry_held and complete_queued_release apply: 0 unknown, 1 unconfigured, 2 paused, 3 held, 4 queued waiting for its delay, 5 queued waiting for limiter capacity, 6 queued behind the head, 7 queued and completable now, 8 held by a recovery shortfall, 9 executed, 10 terminal. A shortfall, pause or status outranks the queue states. The second value is the hold reason blocking it now, 0 if none; a held transfer whose condition has cleared reports 0 and can be retried, except a refused recipient, which stays 5.
get_bridge_fee
Get current bridge fee.
pub fn get_bridge_fee(&self) -> U512
get_cep18_for_chain
Get CEP-18 token for a chain/EVM token pair.
pub fn get_cep18_for_chain(&self, chain_id: u64, erc20_address: H160) -> Option<Address>
is_native_token_for_chain
Check if a token is a registered Casper-native token on a specific EVM chain.
pub fn is_native_token_for_chain(&self, cep18_address: Address, chain_id: u64) -> bool
Error Codes
| Error | Code | Description |
|---|---|---|
Unauthorized |
60021 | Caller not authorized (not admin) |
Paused |
60022 | Contract is paused |
TokenAlreadyExists |
60023 | Token already registered for this chain |
TokenNotFound |
60024 | Token mapping not found |
EventNotAttested |
60025 | Event attestation failed verification |
EventAlreadyProcessed |
60026 | Event already processed |
InvalidAmount |
60027 | Invalid token amount (zero or overflow) |
MintFailed |
60028 | CEP-18 token minting failed |
BurnFailed |
60029 | CEP-18 token burning failed |
InvalidAttestation |
60030 | Attestation failed M-of-N signature verification |
ReceiptsRootMismatch |
60031 | Receipts root in attestation doesn't match EthClient |
InsufficientSignatures |
60032 | Not enough valid signatures provided |
BlockNotFound |
60033 | Block header not found in EthClient |
EthClientNotSet |
60034 | EthClient contract not configured |
InsufficientBridgeFee |
60035 | Insufficient bridge fee provided |
InvalidFeeRecipient |
60036 | Invalid fee recipient address |
NativeTokenAlreadyRegistered |
60037 | Native token already registered for chain |
NativeTokenNotRegistered |
60038 | Native token not registered for chain |
TransferFailed |
60039 | CSPR transfer failed (lock/unlock) |
InsufficientChainLiquidity |
60040 | Not enough liquidity on target chain for unlock |
DecimalMismatch |
60041 | Token decimal configuration mismatch |
InvalidAdmin |
60042 | Invalid admin address |
InvalidAddress |
60043 | Invalid (zero) address argument |
ChainNameNotSet |
60044 | Chain name not configured (required for EIP-712 domain) |
Erc20LockerNotSet |
60045 | No ERC20Locker configured for the source chain - seed via admin casper-set-erc20-locker (common after an in-place v7 upgrade, which leaves the mapping empty) |
AttestedAssetMismatch |
60046 | The signed attestation's cep18_contract_hash does not match the live wrapped→native mapping - the unlock is refused rather than releasing an asset the relayers never attested to |
WrongOperation … RepresentationTokenNotSet |
60077–60090 | Source-commitment refusals (#217): wrong route operation, status or pause, amount outside the route bounds, custody, allocation or representation ceiling, missing or mismatched link, sequence overflow, representation-token rules. bridge-cli prints the name and reason for each code |
NotLinkedGateway … AccountingOverflow |
60091–60103 | Destination refusals (#217): caller or sender not linked, malformed payload, transferId mismatch, route source not the sender, transfer already recorded, release above outstanding, not held or still held, empty queue, head not ready or not executable |
RecoveryShortfallOutstanding … ChainNameFixed |
60104–60110 | Recovery (#217): domain unpause during a shortfall, restore_backing without one, invalid or missing recovery address, withdrawal without the global pause or before its 24 hours, set_chain_name to a different name once a Casper-origin domain is registered |
Codes 60025 to 60033 and 60045 to 60046 belong to the signature-based mint and unlock entry points, which the exposure-control TokenFactory no longer has; they stay reserved.
Integration Examples
TypeScript (via bridge-cli)
import { CasperClient } from './casper';
const client = new CasperClient(config);
// Deploy a new wrapped token
await client.deployToken(
11155111, // Sepolia
'0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', // USDC on Sepolia
'Bridged USDC',
'bUSDC',
6
);
// Register token mapping
await client.registerTokenMapping(
11155111,
'0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238',
cep18Address
);
// Query bridged totals
const total = await client.getTotalBridged(erc20Address);
Rust (Direct Contract Call)
use odra::host::Deployer;
// Burn 1 USDC on its burn-unlock route
let token_factory = TokenFactoryRef::at(token_factory_address);
token_factory.burn(route_id, U256::from(1_000_000), eth_recipient_h160);
Security Considerations
- Single transport: every inbound transfer arrives through the linked ERC-7786 gateway, which verifies the relayer quorum and the source block;
receive_messageaccepts only that gateway and the linked counterpart - Replay protection: the gateway records each delivered message, and TokenFactory records each
transferId - Exposure controls: route bounds, custody and representation ceilings bound every source commitment; the release limiter, the large-release queue and local mint allocations bound every destination execution
- No lost transfers: a verified transfer that cannot execute is held or queued, never dropped, and executes later to its original recipient
- Pause mechanism: emergency stop for new commitments and destination execution
- Emergency withdrawal: only after a 24-hour global pause, only to the delayed recovery address, and it never rewrites a transfer: the domain's releases wait until
restore_backingreturns what was taken