Kinetiq

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 to 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 orderbook — 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 a Transfer log 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 outside the system address (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

ABC (hypothetical)

Deposit wallet

DEPOSIT_WALLET (from the factory, below)

Underlying ERC-20 (HyperEVM)

UNDERLYING (decimals 8)

Core token index

1234 (weiDecimals 8, szDecimals 2)

Core token id

<tokenId> (from spotMeta)

System address (0x20‖1234)

0x20000000000000000000000000000000000004d2

CORE_SPOT_DEX

4294967295 (type(uint32).max)

Factory

DEPOSIT_WALLET_FACTORY (published at launch)

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:

solidity
// 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
}
solidity
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 tuple

walletFor 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 = 0x4d20x20000000…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 decimal profile (e.g. an 18-dp underlying with weiDecimals 8extra = 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).

solidity
// 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.

solidity
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;
}
solidity
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);
amount is always in the underlying's EVM base units (the ERC-20's decimals()), never Core wei. HyperCore credits amount / 10^extra Core wei, where extra = underlyingDecimals − coreWeiDecimals. For an 18-dp underlying linked to a weiDecimals 8 Core token (extra = 10), depositing 100 tokens means amount = 100e18, credited as 1e10 Core wei. amount must also be a multiple of 10^extra, or the deposit reverts AmountNotCoreAligned. The hypothetical example token's extra is 0, 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:

python
# 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 sendAsset sender, 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):

json
{
  "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…04d2 here). This is what routes the balance to HyperEVM.
  • token = "<NAME>:<0xTOKENID>" — the name and token id exactly as spotMeta reports them.
  • sourceDex / destinationDex = "spot" (spot balance on both sides; "" would be the perp dex).
  • amount is a human-readable decimal string (e.g. "1"), not base units.
  • nonce is a millisecond timestamp and must equal the request nonce.
  • hyperliquidChain = "Mainnet" is the environment guard (prevents cross-environment replay).
  • signatureChainId is only the chain your wallet signs on and can be any chain, as long as the EIP-712 domain chainId equals it. The example uses HyperEVM (0x3e7 = 999); the Hyperliquid Python SDK defaults to 0x66eee; the docs show Arbitrum (0xa4b1 = 42161). Any is valid as long as domain.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:

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

solidity
// 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

solidity
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

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 sendAsset takes 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 8extra = 10): deposit amounts must be a multiple of 10^extra; the credited Core amount is amount / 10^extra.
    • extra < 0 (e.g. 6-dp underlying, weiDecimals 8extra = −2): the credit is magnified by 10^|extra|.

Worked example: an 18-dp underlying linked to a weiDecimals 8 Core token (extra = 18 − 8 = 10; hypothetical — the example token is 8/8):

solidity
// 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.