Elysium
Bridging an ERC-20 between HyperEVM and HyperCore through a HyperCoreDepositWallet: deposits, withdrawals via sendAsset, and the amounts-and-decimals rules.
Last updated
A developer guide for bridging an ERC-20 between HyperEVM and HyperCore through a HyperCoreDepositWallet.
The deposit-wallet contracts are pre-launch: the factory address and the first deployments are published at launch, so the guide walks a hypothetical deployment (ticker ABC, Core token index 1234) with named constants for every address — fill them from the published values. The HyperCore side of the flow (linkage, sendAsset, the info API) is live Hyperliquid protocol behavior today, and the factory getters make the same flow reusable for any token that has a deposit wallet.
This is the Core linkage leg of the token-bridging series. See Token bridging on Elysium for how it composes with the Elysium ↔ HyperEVM legs. Elysium-native tokens arrive on HyperEVM as bridge mirrors and graduate onto HyperCore through this exact flow. The issuer-side ceremony that sets all of this up — buying the ticker, genesis, linking the wallet, and listing the spot book — is HyperCore spot deployment.
1. 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): you 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): you 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.
Units differ per side, and HyperCore converts at the boundary. On HyperEVM, amounts are in the underlying's EVM base units (the ERC-20's decimals()); on HyperCore, they are in Core wei (the Core token's weiDecimals). The scaling factor is 10^extra where extra = underlyingDecimals − coreWeiDecimals: a deposit of amount EVM base units credits amount / 10^extra Core wei, and a withdrawal of x Core wei pays out x × 10^extra EVM base units. The hypothetical example token below is 8/8 (extra = 0, so units map 1:1); an 18-dp underlying linked to a weiDecimals 8 Core token converts at 10^10. Alignment rules and worked numbers: Amounts & decimals.
Invariant: wallet escrow (HyperEVM) == Core circulating supply (unit-scaled).
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/withdraw surface below.
Example deployment at a glance (hypothetical)
Value | |
|---|---|
HyperCore ticker |
|
Deposit wallet |
|
Underlying ERC-20 (HyperEVM) |
|
Core token index |
|
Core token id |
|
System address ( |
|
|
|
Factory |
|
2. 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. Use predictWallet for the address before deployment. Creating a wallet is the issuer-side step of the deployment ceremony — HyperCore spot deployment. The system address for any token is 0x20 as the first byte and the token index big-endian in the remaining 19 bytes (index 1234 = 0x4d2 → 0x20000000…04d2); the wallet also exposes systemAddress().
Decimals. Amounts scale by evmExtraWeiDecimals = underlyingDecimals − coreWeiDecimals. For the example token that's 8 − 8 = 0, so EVM base units map 1:1 to Core wei. For a token with a different shape (e.g. 18-dp underlying vs weiDecimals 8 → extra = 10), deposit amounts must be a multiple of 10^extra. See Amounts & decimals.
3. HyperEVM → HyperCore (deposit)
Two transactions on HyperEVM: approve the wallet on the underlying, then deposit.
Prerequisite: the Core token must be linked to this wallet. An unlinked deposit reverts TickerNotLinked before moving funds. Linking is an issuer-side, one-time ceremony: HyperCore spot deployment.Step 1: Approve on the underlying ERC-20
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 = 10000000000).
// approve the wallet to pull 100 tokens (8 dp underlying)
IERC20(UNDERLYING).approve(DEPOSIT_WALLET, 10_000_000_000);Step 2: Deposit on the wallet
Use deposit by default; it credits msg.sender on Core. Only use depositFor 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 (the ERC-20'sdecimals()), never Core wei. HyperCore creditsamount / 10^extraCore wei, whereextra = underlyingDecimals − coreWeiDecimals. For an 18-dp underlying linked to aweiDecimals 8Core token (extra = 10), depositing 100 tokens meansamount = 100e18, credited as1e10Core wei.amountmust also be a multiple of10^extra, or the deposit revertsAmountNotCoreAligned. The hypothetical example token'sextrais0, so its amounts map 1:1 and no alignment constraint applies.
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 on the next Core block. The deposit ERC-20 approve/transferFrom must move exactly amount (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 (token not linked to this wallet), ZeroAmount, InvalidRecipient (recipient is zero / the wallet / the system address), AmountExceedsCoreMax (scaled credit exceeds the u64 ceiling), AmountNotCoreAligned (amount not a multiple of 10^extra; only when extra > 0), SystemAddressUnderfunded (per-block deposit total exceeds the system address's Core balance).
4. HyperCore → HyperEVM (withdraw)
Send the Core spot token to the token's system address with a sendAsset action. HyperCore then runs a system transaction calling transfer(recipient, amount) on the wallet, where recipient is the sender of the sendAsset, so 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 system-address-gated 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/ 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 = uint64): hyperliquidChain, destination, sourceDex, destinationDex, token, amount, fromSubAccount, nonce.
Notes:
destination= the token's system address (0x…04d2here). This is what routes the balance to HyperEVM.token="<NAME>:<0xTOKENID>"— the name and token id exactly asspotMetareports them.sourceDex/destinationDex="spot"(spot balance on both sides;""would be the perp dex).amountis a human-readable decimal string (e.g."1"), not base units.nonceis a millisecond timestamp and must equal the request nonce.hyperliquidChain="Mainnet"is the environment guard (prevents 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 docs show Arbitrum (0xa4b1= 42161). Any is valid as long asdomain.chainId == signatureChainId.
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). For example, 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. After it settles, HyperCore emits the system transfer on the wallet and the underlying lands in your HyperEVM address. On an explorer, both directions are visible as the system address's activity: deposits emit the wallet's Transfer toward it, withdrawals are spot sends to it followed by the wallet's payout.
5. 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
6. Amounts & decimals
- HyperEVM contract calls (
approve,deposit,depositFor) take base units (wholeTokens × 10^decimals). The example underlying is 8 dp, so 100 tokens =10000000000. - HyperCore
sendAssettakes a human-readable decimal string, e.g."1". - Scaling between the two sides is
evmExtraWeiDecimals = underlyingDecimals − coreWeiDecimals:- The example token:
8 − 8 = 0→ 1 EVM base unit = 1 Core wei, no alignment constraint. extra > 0(e.g. 18-dp underlying,weiDecimals 8→extra = 10): deposit amounts must be a multiple of10^extra; the credited Core amount isamount / 10^extra.extra < 0(e.g. 6-dp underlying,weiDecimals 8→extra = −2): the credit is magnified by10^|extra|.
- The example token:
Worked example: an 18-dp underlying linked to a weiDecimals 8 Core token (extra = 18 − 8 = 10; 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.
On this page