Token bridging
Bridging an ERC-20 between HyperEVM and HyperCore through a HyperCoreDepositWallet: deposits, withdrawals via sendAsset, and the amounts-and-decimals rules.
Last updated
How to bridge an ERC-20 between HyperEVM and HyperCore through a HyperCoreDepositWallet. This is the Core linkage leg of Token bridging on Elysium. Elysium-native tokens arrive on HyperEVM as mirrors and graduate to HyperCore through this exact flow.
This guide covers the runtime deposit and withdraw flow for a token that is already linked. Linking is a one-time issuer ceremony (buying the ticker, genesis, linking the wallet, and listing the orderbook) covered in HyperCore spot deployment.
The deposit-wallet contracts are pre-launch, so the factory address and first deployments are published at launch. This guide walks a hypothetical deployment (tickerABC, Core token index1234) with named constants for every address. The HyperCore side of the flow (linkage,sendAsset, the info API) is live Hyperliquid protocol behavior today.
Overview
A deposit wallet is a linkage adapter in the Circle/USDC pattern: the wallet, not the token, is the contract HyperCore links to a Core spot token. It escrows the real underlying ERC-20 and exposes only the minimal surface HyperCore needs.
- HyperEVM → HyperCore (deposit). Approve the wallet and call
deposit. The wallet pulls the underlying into escrow and emits aTransferlog that HyperCore reads to credit a recipient on Core spot. No token is minted; escrow backs the Core balance 1:1. - HyperCore → HyperEVM (withdraw). Send the Core spot token to the token's system address. HyperCore calls
transfer(recipient, amount)on the wallet, which pays out the escrowed underlying on HyperEVM.
Invariant: wallet escrow on HyperEVM equals Core circulating supply outside the system address, scaled between the two sides' units.
Amounts use different units on each side, and HyperCore converts at the boundary: EVM base units (the ERC-20's decimals()) on HyperEVM, Core wei (the Core token's weiDecimals) on HyperCore. The example token below is 8/8, so its units map 1:1. See Amounts and decimals for other profiles.
The wallet is not an ERC-20. Don't add it to token lists or call ERC-20 metadata on it; it only implements the deposit and withdraw surface below.
Example deployment (hypothetical)
Value | |
|---|---|
HyperCore ticker |
|
Deposit wallet |
|
Underlying ERC-20 (HyperEVM) |
|
Core token index |
|
Core token id |
|
System address ( |
|
|
|
Factory |
|
Finding the wallet for a token
Wallets are deployed deterministically (CREATE2) by the factory, keyed by (underlying, tickerIndex, hyperCoreDeployer). Read the address off the factory:
// DEPOSIT_WALLET_FACTORY: published at launch.
interface IHyperCoreDepositFactory {
function walletFor(address underlying, uint64 tickerIndex, address hyperCoreDeployer)
external view returns (address); // deployed address, or 0x0 if none
function predictWallet(address underlying, uint64 tickerIndex, address hyperCoreDeployer)
external view returns (address); // deterministic address, whether or not deployed
}address wallet = IHyperCoreDepositFactory(DEPOSIT_WALLET_FACTORY).walletFor(
UNDERLYING, // the underlying ERC-20
1234, // Core ticker index
HYPERCORE_DEPLOYER // the wallet's link finalizer
); // -> the deposit wallet for that tuplewalletFor returns 0x0 if no wallet exists for that tuple; predictWallet returns the address before deployment. Creating a wallet is an issuer step, covered in HyperCore spot deployment.
A token's system address is 0x20 as the first byte followed by the token index, big-endian, in the remaining 19 bytes (index 1234 = 0x4d2 gives 0x20000000…04d2). The wallet also exposes it as systemAddress().
HyperEVM → HyperCore (deposit)
Two transactions on HyperEVM: approve the wallet on the underlying, then deposit.
The Core token must already be linked to this wallet. A deposit to an unlinked wallet reverts TickerNotLinked before any funds move.
Step 1: Approve the wallet
approve is a standard ERC-20 call on the underlying token, not the wallet. Amounts are in the underlying's base units (here 8 dp, so 100 tokens is 10000000000).
// approve the wallet to pull 100 tokens (8 dp underlying)
IERC20(UNDERLYING).approve(DEPOSIT_WALLET, 10_000_000_000);Step 2: Deposit
Use deposit by default, which credits msg.sender on Core. Use depositFor only when the Core recipient should differ from the sender.
interface IHyperCoreDepositWallet {
// destinationDex = 4294967295 (CORE_SPOT_DEX) routes to Core spot
function deposit(uint256 amount, uint32 destinationDex) external;
function depositFor(address recipient, uint256 amount, uint32 destinationDex) external;
}uint32 CORE_SPOT_DEX = type(uint32).max; // 4294967295
IHyperCoreDepositWallet wallet = IHyperCoreDepositWallet(DEPOSIT_WALLET);
// DEFAULT: credit the sender on Core
wallet.deposit(10_000_000_000, CORE_SPOT_DEX);
// OPTIONAL: credit a different Core recipient (RECIPIENT: the Core account to credit)
wallet.depositFor(RECIPIENT, 10_000_000_000, CORE_SPOT_DEX);amountis always in the underlying's EVM base units, never Core wei. For an 18-dp underlying linked to aweiDecimals 8Core token, depositing 100 tokens meansamount = 100e18, credited as1e10Core wei, andamountmust be a multiple of1e10or the deposit revertsAmountNotCoreAligned. See Amounts and decimals.
What happens: the wallet pulls exactly amount into escrow and emits Transfer(recipient, systemAddress, amount). HyperCore parses that log and credits recipient on Core spot in the next Core block. The transfer into escrow must move exactly amount, so fee-on-transfer tokens are rejected.
Confirm the Core credit by reading the recipient's spot balance:
# info = Info(base_url=MAINNET_API_URL, skip_ws=True)
state = info.post("/info", {"type": "spotClearinghouseState", "user": RECIPIENT})
# → balances gain the token's entry for the deposited amount (in Core wei)Common reverts: TickerNotLinked (the token is not linked to this wallet), ZeroAmount, InvalidRecipient (the recipient is zero, the wallet, or the system address), AmountExceedsCoreMax (the scaled credit exceeds the u64 ceiling), AmountNotCoreAligned (the amount is not a multiple of the scaling factor), SystemAddressUnderfunded (the per-block deposit total exceeds the system address's Core balance).
HyperCore → HyperEVM (withdraw)
Send the Core spot token to the token's system address with a sendAsset action. HyperCore then runs a system transaction that calls transfer(recipient, amount) on the wallet, where recipient is the sendAsset sender. You receive the underlying on HyperEVM at the same address; there is no third-party recipient in this direction.
You never call the wallet's transfer directly. It is gated to the system address and is the on-chain effect of your sendAsset.
The sendAsset action
sendAsset is an EIP-712 typed-data action, signed by the user's wallet and submitted to the HyperCore exchange endpoint. Set destination to the system address, sourceDex and destinationDex to "spot", and token to the NAME:0xTOKENID form. The action shape (hypothetical example, withdrawing 1 token; <tokenId> comes from spotMeta):
{
"type": "sendAsset",
"signatureChainId": "0x3e7",
"hyperliquidChain": "Mainnet",
"destination": "0x20000000000000000000000000000000000004d2",
"sourceDex": "spot",
"destinationDex": "spot",
"token": "ABC:<tokenId>",
"amount": "1",
"fromSubAccount": "",
"nonce": 1788610101588
}It is signed as EIP-712 typed data with primaryType HyperliquidTransaction:SendAsset and domain { name: "HyperliquidSignTransaction", version: "1", chainId: <signing chain>, verifyingContract: 0x0 }. The message fields, in order (all string except nonce, which is uint64): hyperliquidChain, destination, sourceDex, destinationDex, token, amount, fromSubAccount, nonce.
destinationis the token's system address (0x…04d2here). This is what routes the balance to HyperEVM.tokenis"<NAME>:<0xTOKENID>", with the name and token id exactly asspotMetareports them.sourceDexanddestinationDexare"spot"(spot balance on both sides;""would be the perp dex).amountis a human-readable decimal string (for example"1"), not base units.nonceis a millisecond timestamp and must equal the request nonce.hyperliquidChainis"Mainnet", the environment guard against cross-environment replay.signatureChainIdis only the chain your wallet signs on and can be any chain, as long as the EIP-712 domainchainIdequals it. The example uses HyperEVM (0x3e7= 999); the Hyperliquid Python SDK defaults to0x66eee; the Hyperliquid docs show Arbitrum (0xa4b1= 42161).
The recommended path is the Hyperliquid front-end transfer UI or the SDKs, which build and sign this correctly (the SDK is the reference encoder). In Python:
# exchange = Exchange(wallet, base_url=MAINNET_API_URL)
exchange.send_asset(
SYSTEM_ADDRESS, # destination = the token's system address (0x20…04d2 here)
"spot", "spot", # sourceDex, destinationDex
CORE_TOKEN, # "<NAME>:<0xTOKENID>" from spotMeta, e.g. "ABC:<tokenId>"
1, # amount (whole tokens)
)The only bridge-specific input is destination, the system address. Once the action settles, HyperCore calls transfer on the wallet and the underlying lands at your HyperEVM address. On an explorer, both directions show up as the system address's activity: deposits emit the wallet's Transfer toward it, and withdrawals are spot sends to it followed by the wallet's payout.
Amounts and decimals
- HyperEVM contract calls (
approve,deposit,depositFor) take base units (wholeTokens × 10^decimals). The example underlying is 8 dp, so 100 tokens is10000000000. - HyperCore
sendAssettakes a human-readable decimal string, such as"1". - Scaling between the two sides uses
evmExtraWeiDecimals = underlyingDecimals − coreWeiDecimals. A deposit ofamountEVM base units creditsamount / 10^extraCore wei, and a withdrawal ofxCore wei pays outx × 10^extraEVM base units.
Underlying | Core |
| Effect |
|---|---|---|---|
8 | 8 |
| units map 1:1, no alignment constraint |
18 | 8 |
| deposits must be a multiple of |
6 | 8 |
| credit is magnified by |
Worked example for an 18-dp underlying linked to a weiDecimals 8 Core token (hypothetical; the example token is 8/8):
// deposit() takes EVM base units; HyperCore credits amount / 10^extra Core wei
wallet.deposit(100e18, CORE_SPOT_DEX); // 100.0 tokens -> credits 1e10 Core wei (100.0 on Core)
wallet.deposit(5e17, CORE_SPOT_DEX); // 0.5 tokens -> credits 5e7 Core wei (0.5 on Core)
wallet.deposit(100e18 + 1, CORE_SPOT_DEX); // reverts AmountNotCoreAligned (not a multiple of 1e10)The sub-10^extra tail of an 18-dp amount has no Core representation. The wallet rejects unaligned amounts up front rather than rounding, so no dust is created or silently dropped.
Reference
Wallet surface
// deposits (HyperEVM -> HyperCore)
function deposit(uint256 amount, uint32 destinationDex) external; // credits msg.sender
function depositFor(address recipient, uint256 amount, uint32 destinationDex) external;
// Core -> EVM payout (system-address only; not user-callable)
function transfer(address to, uint256 amount) external returns (bool);
// views
function underlying() external view returns (address);
function tickerIndex() external view returns (uint64);
function systemAddress() external view returns (address);
// deposit credit signal parsed by HyperCore (from = Core recipient, to = system address)
event Transfer(address indexed from, address indexed to, uint256 amount);
// escrow paid out on a Core -> EVM withdrawal
event Withdraw(address indexed to, uint256 value);Errors: ZeroAmount, InvalidRecipient, AmountExceedsCoreMax, AmountNotCoreAligned, TickerNotLinked, EscrowShortfall, NotSystemAddress, UnsupportedDestinationDex.
Factory getters
function walletFor(address underlying, uint64 tickerIndex, address hyperCoreDeployer) external view returns (address);
function predictWallet(address underlying, uint64 tickerIndex, address hyperCoreDeployer) external view returns (address);
function walletBySalt(bytes32 salt) external view returns (address); // salt = keccak256(abi.encode(underlying, tickerIndex, hyperCoreDeployer))Explorers
- HyperEVM: https://hyperevmscan.io (wallet, factory, and underlying activity)
- HyperCore: https://hypurrscan.io (system-address activity, spot balances) and the official explorer at https://app.hyperliquid.xyz/explorer
For the Core linkage sequence diagrams and trust notes, see Token bridging architecture.