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_amount and maximum_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: 1 domain or route UNCONFIGURED or FULLY_PAUSED, 2 execution paused (globally or for the domain), 3 no representation token, 4 mint above the local allocation, 5 a release to TokenFactory itself, which a CEP-18 refuses, 6 a release from a domain with a recovery shortfall (see emergency_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_native and burn must attach exactly the bridge fee; any other value reverts with InsufficientBridgeFee (60035). When the fee is 0 they must attach no CSPR, and attached value reverts with UnexpectedAttachedValue (60048) instead of being absorbed.


pause / unpause

Emergency pause mechanism.

pub fn pause(&mut self)
pub fn unpause(&mut self)

Access: Admin only


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_held and complete_queued_release revert, and nothing is charged;
  • unpause_domain reverts with RecoveryShortfallOutstanding in both governance lanes, so a later global unpause leaves the domain stopped;
  • only restore_backing reduces 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

  1. Single transport: every inbound transfer arrives through the linked ERC-7786 gateway, which verifies the relayer quorum and the source block; receive_message accepts only that gateway and the linked counterpart
  2. Replay protection: the gateway records each delivered message, and TokenFactory records each transferId
  3. 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
  4. No lost transfers: a verified transfer that cannot execute is held or queued, never dropped, and executes later to its original recipient
  5. Pause mechanism: emergency stop for new commitments and destination execution
  6. 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_backing returns what was taken

results matching ""

    No results matching ""