Skip to content

4. Move USDC between EVM and Core

In this tutorial a contract moves USDC between HyperEVM and HyperCore. It returns USDC from Core to EVM with bridge.bridge_usdc_to_evm, and deposits it back through Circle's CoreDepositWallet with bridge.bridge_usdc_to_core_for. Tutorial 3 moves USDC between spot and perp inside HyperCore.

You'll learn:

  • How to return USDC from Core spot and Core perp to EVM, and deposit it back.
  • How to keep each transfer pending until a later block shows it, and confirm it then. Both directions arrive in the next block.
  • What a return costs in gas: about 0.00002 HYPE, or about 0.0009 USDC on testnet when the contract holds no HYPE.
  • What a silent failure looks like, and how to clear its pending record safely.

Prerequisites

Item Amount
HYPE on HyperEVM less than 0.003 HYPE for gas
USDC in the spot balance of your Core account 7 USDC: 6 USDC to the contract and 1 USDC activation fee
HYPE in the spot balance of your Core account 0.01 HYPE
A Hyperliquid mainnet account on the same address for the deposit step only (see below)
HYPER_VYPER_TESTNET_KEY for all steps with --execute
HYPER_VYPER_CORE_FUNDER_KEY optional, for the fund step

If you have no Core HYPE, buy some as testnet setup describes.

Deposits on testnet and on mainnet

On testnet, Circle's CoreDepositWallet credits only an address that already has a Hyperliquid mainnet account. A new contract never has one, so on testnet the deposit step deposits into your own Core account with bridge_usdc_to_core_for. The testnet faucet has the same condition, so if you used it, your address qualifies.

On mainnet, a contract deposits into its own Core account in the same way, with bridge.bridge_usdc_to_core. A production contract uses this route. Security gives the rules for the activation fee and the DEX index.

sequenceDiagram
    participant C as Contract
    participant U as USDC (EVM)
    participant D as CoreDepositWallet
    participant K as HyperCore
    C->>K: return: send_asset to the USDC system address
    K-->>C: next block: Core debit and EVM credit
    C->>U: deposit: approve(D, amount)
    C->>D: depositFor(recipient, amount, dex)
    D->>U: transferFrom(C, D, amount)
    C->>U: approve(D, 0)
    D-->>K: next block: Core credit

HYPE for gas on Core

A return to EVM is a Core action that makes an EVM transfer. When the sender holds HYPE, Core charges the EVM gas of that transfer in HYPE, and it takes the HYPE from the Core spot balance of the sender, which is the contract.

  • The charge is about 200,000 gas. At a base fee of 0.1 gwei, that is 0.00002 HYPE, or about 2,000 Core wei (8 decimals).
  • If the contract holds no HYPE, a spot return still succeeds, and Core takes the gas in USDC from spot instead: 0.001 to 0.002 USDC per return on mainnet, and about 0.0009 USDC on testnet. The amount changes with the HYPE price and the base fee.
  • A perp return without HYPE also succeeds. Core takes the gas in USDC from spot, or from perp when spot has no USDC.
  • A deposit, a HYPE return to EVM and a failed action cost no Core gas.

Keep a small HYPE balance on Core, so the fee comes from HYPE and the USDC amounts stay exact. The fund step sends the contract 0.01 HYPE.

The contract

tutorials/evm_core/evm_core.vy is based on examples/usdc_bridge.vy:

# pragma version 0.4.3
# pragma evm-version cancun
# pragma optimize gas
# SPDX-License-Identifier: MIT

"""
@title Tutorial: move USDC between EVM and Core
@notice The owner returns USDC from the Core spot or perp balance of this
        contract to its EVM balance, and deposits EVM USDC back to Core through
        the Circle CoreDepositWallet. Each transfer stays pending until a later
        block shows it at its destination.
@dev Tutorial code, based on examples/usdc_bridge.vy. Nobody has audited it.
     Core charges the gas of each USDC return in HYPE from the spot balance of
     this contract when the contract holds HYPE (about 0.00002 HYPE). Without
     HYPE, Core takes the gas in USDC: from spot (about 0.0009 USDC on testnet),
     or from perp for a perp return when spot has no USDC. A return that Core rejects,
     for example above the balance, fails silently, and the transfer stays
     pending. On testnet, the CoreDepositWallet credits only an address that
     has a Hyperliquid mainnet account, so deposits go to the owner. On mainnet,
     bridge.bridge_usdc_to_core deposits into the contract's own Core account.
"""

from ethereum.ercs import IERC20

from hyper_vyper import bridge
from hyper_vyper import constants
from hyper_vyper import conversions
from hyper_vyper import core_writer
from hyper_vyper import precompiles


struct Pending:
    to_core: bool  # True for a deposit, False for a return
    from_perp: bool  # the source of a return
    amount: uint256  # EVM USDC, 6 decimals
    baseline: uint256  # destination balance at submission: EVM USDC, or the owner's Core spot USDC
    source_baseline: int256  # Core source of a return at submission (spot: 8 decimals, perp: 6)
    evm_block: uint256
    l1_block: uint64


struct Status:
    evm_block: uint256
    core_exists: bool
    spot_usdc: uint64  # Core USDC, 8 decimals
    spot_hype: uint64  # Core HYPE, 8 decimals
    withdrawable: uint64  # perp USD, 6 decimals
    evm_usdc: uint256  # EVM USDC, 6 decimals
    owner_spot_usdc: uint64  # Core USDC of the owner, 8 decimals
    has_pending: bool
    observed: bool


event Submitted:
    to_core: bool
    from_perp: bool
    amount: uint256


event Confirmed:
    to_core: bool
    amount: uint256


event Abandoned:
    to_core: bool
    amount: uint256


# Core handles an action in an L1 block after the EVM block that sent it. After
# this many L1 blocks, a transfer that has not happened will not happen.
PROCESSED_AFTER_L1_BLOCKS: constant(uint64) = 200

OWNER: public(immutable(address))

# One transfer at a time keeps the accounting simple.
pending: public(Pending)
has_pending: public(bool)


@deploy
def __init__():
    OWNER = msg.sender


@internal
@view
def _only_owner():
    assert msg.sender == OWNER, "Only the owner"


@internal
@view
def _evm_usdc() -> uint256:
    return staticcall IERC20(conversions.usdc_address()).balanceOf(self)


@internal
@view
def _owner_spot_usdc() -> uint256:
    return convert(precompiles.spot_balance(OWNER, constants.USDC_TOKEN_INDEX).total, uint256)


@internal
@view
def _source_balance(from_perp: bool) -> int256:
    # Perp raw USD does not change with unrealized profit or loss.
    if from_perp:
        return convert(
            precompiles.account_margin_summary(constants.DEFAULT_PERP_DEX, self).raw_usd, int256
        )
    return convert(precompiles.spot_balance(self, constants.USDC_TOKEN_INDEX).total, int256)


@internal
@view
def _observed() -> bool:
    if not self.has_pending or block.number <= self.pending.evm_block:
        return False
    if self.pending.to_core:
        return self._owner_spot_usdc() >= self.pending.baseline + self.pending.amount * 100
    return self._evm_usdc() >= self.pending.baseline + self.pending.amount


@external
@view
def observed() -> bool:
    """Whether a later block shows the pending transfer at its destination. Other
    transfers to the same balance can fake a result. A real contract must account
    for them."""
    return self._observed()


@external
@view
def status() -> Status:
    usdc: precompiles.SpotBalance = precompiles.spot_balance(self, constants.USDC_TOKEN_INDEX)
    return Status(
        evm_block=block.number,
        core_exists=precompiles.core_user_exists(self),
        spot_usdc=usdc.total,
        spot_hype=precompiles.spot_balance(self, conversions.hype_token_index()).total,
        withdrawable=precompiles.withdrawable(self),
        evm_usdc=self._evm_usdc(),
        owner_spot_usdc=precompiles.spot_balance(OWNER, constants.USDC_TOKEN_INDEX).total,
        has_pending=self.has_pending,
        observed=self._observed(),
    )


@external
def move_usdc(ntl: uint64, to_perp: bool):
    """Move ntl perp USD (6 decimals) between Core spot and Core perp."""
    self._only_owner()
    assert precompiles.core_user_exists(self), "Core account not activated"
    assert ntl > 0, "Zero amount"
    core_writer.transfer_usd_class(ntl, to_perp)


@external
@nonreentrant
def return_usdc(amount: uint256, from_perp: bool):
    """Return amount EVM USDC (6 decimals) from Core perp (from_perp) or Core spot."""
    self._only_owner()
    assert not self.has_pending, "Transfer pending"
    # An account that is not activated cannot send actions. They fail silently.
    assert precompiles.core_user_exists(self), "Core account not activated"
    source: uint32 = constants.SPOT_DEX
    if from_perp:
        source = constants.DEFAULT_PERP_DEX
        assert convert(precompiles.withdrawable(self), uint256) >= amount, "Not withdrawable"
    else:
        spot: precompiles.SpotBalance = precompiles.spot_balance(self, constants.USDC_TOKEN_INDEX)
        assert convert(spot.total - spot.hold, uint256) >= amount * 100, "Spot balance too low"
    self._submit_return(amount, from_perp, source)


@external
@nonreentrant
def return_spot_unchecked(amount: uint256):
    """Tutorial only: return amount EVM USDC (6 decimals) from Core spot without
    the balance check. A return above the spot balance shows a silent failure:
    the EVM transaction succeeds, Core moves nothing, and the return stays
    pending. A real contract checks the balance, as return_usdc does."""
    self._only_owner()
    assert not self.has_pending, "Transfer pending"
    assert precompiles.core_user_exists(self), "Core account not activated"
    self._submit_return(amount, False, constants.SPOT_DEX)


@internal
def _submit_return(amount: uint256, from_perp: bool, source: uint32):
    self.pending = Pending(
        to_core=False,
        from_perp=from_perp,
        amount=amount,
        baseline=self._evm_usdc(),
        source_baseline=self._source_balance(from_perp),
        evm_block=block.number,
        l1_block=precompiles.l1_block_number(),
    )
    self.has_pending = True
    log Submitted(to_core=False, from_perp=from_perp, amount=amount)
    # A fixed source DEX. Never take the DEX index from untrusted input.
    bridge.bridge_usdc_to_evm(amount, source)


@external
@nonreentrant
def deposit(amount: uint256):
    """Deposit amount EVM USDC (6 decimals) into the owner's Core spot balance."""
    self._only_owner()
    assert not self.has_pending, "Transfer pending"
    assert amount > 0, "Zero amount"
    assert self._evm_usdc() >= amount, "EVM balance too low"
    # The owner has a Core account, so the deposit pays no activation fee.
    assert precompiles.core_user_exists(OWNER), "Owner has no Core account"
    self.pending = Pending(
        to_core=True,
        from_perp=False,
        amount=amount,
        baseline=self._owner_spot_usdc(),
        source_baseline=0,
        evm_block=block.number,
        l1_block=precompiles.l1_block_number(),
    )
    self.has_pending = True
    log Submitted(to_core=True, from_perp=False, amount=amount)
    # A fixed recipient and a fixed DEX. Never take either from untrusted input.
    bridge.bridge_usdc_to_core_for(OWNER, amount, constants.SPOT_DEX)


@external
@nonreentrant
def confirm():
    """Clear the pending transfer after a later block shows it at its destination."""
    self._only_owner()
    assert self.has_pending, "No transfer pending"
    assert self._observed(), "Not observed"
    self.has_pending = False
    log Confirmed(to_core=self.pending.to_core, amount=self.pending.amount)


@external
@nonreentrant
def abandon():
    """Clear a pending transfer that Core processed without doing it, for example a
    return above the balance. Core does not report a failure, so this waits until
    Core has processed the action. For a return, it also checks that Core never
    took the USDC, so a return that Core has already paid out cannot be abandoned."""
    self._only_owner()
    assert self.has_pending, "No transfer pending"
    assert precompiles.l1_block_number() > self.pending.l1_block + PROCESSED_AFTER_L1_BLOCKS, "Too early"
    assert not self._observed(), "Observed: confirm it"
    if not self.pending.to_core:
        assert self._source_balance(self.pending.from_perp) >= self.pending.source_baseline, "Core took the USDC"
    self.has_pending = False
    log Abandoned(to_core=self.pending.to_core, amount=self.pending.amount)


@external
@nonreentrant
def withdraw_usdc(amount: uint256):
    """Send EVM USDC (6 decimals) of this contract to the owner (cleanup)."""
    self._only_owner()
    assert not self.has_pending, "Transfer pending"
    assert extcall IERC20(conversions.usdc_address()).transfer(
        OWNER, amount, default_return_value=True
    ), "USDC transfer failed"


@external
def sweep(token: uint64, amount_wei: uint64):
    """Send Core spot funds of this contract to the owner on Core (cleanup).
    amount_wei is in Core wei: 8 decimals for USDC and for HYPE."""
    self._only_owner()
    assert precompiles.core_user_exists(self), "Core account not activated"
    core_writer.send_asset(
        OWNER, empty(address), constants.SPOT_DEX, constants.SPOT_DEX, token, amount_wei
    )
  • return_usdc checks the owner, a free pending slot, the activation and the source balance. It records the pending return, with the EVM USDC balance as the baseline, and then sends the action. The source DEX is fixed: perp DEX 0 or spot.
  • deposit checks the owner, a free pending slot, the EVM balance and that the owner has a Core account. It records the pending deposit, with the owner's Core spot USDC as the baseline, and then deposits for the owner to spot.
  • observed is true in a later block if the destination grew by the amount: the EVM USDC balance for a return, or the owner's Core spot USDC by amount * 100 for a deposit (6 decimals on EVM, 8 on Core spot).
  • confirm clears the pending transfer after it is observed.
  • return_spot_unchecked exists only for the failure demo. It sends a spot return without the balance check, whereas a real contract checks the balance, as return_usdc does.
  • abandon clears a pending transfer that did not arrive. Core does not report failures, so abandon waits until Core has processed the action: 200 L1 blocks, about 15 seconds. A successful transfer shows in the next EVM block. For a return, abandon also refuses if Core took the USDC, so you can't abandon a return that Core has already paid out.

Other transfers to the same balance can make a transfer look done, so don't move other funds in or out of the contract while a transfer is pending.

Steps

  1. Deploy the contract:

    uv run --locked tutorials/evm_core/run.py deploy --execute
    
  2. Fund the contract with 6 USDC and 0.01 HYPE on Core. The USDC send activates the account:

    uv run --locked tutorials/evm_core/run.py fund --execute
    
    Core transfer: 6 USDC from your Core account to 0x...
      block N: exists True, spot USDC 600000000 (6 USDC, 8 decimals)
    Core transfer: 0.01 HYPE from your Core account to 0x...
      block N+7: spot HYPE 1000000 (0.01 HYPE, 8 decimals)
    
  3. Return 2 USDC from Core spot to EVM:

    uv run --locked tutorials/evm_core/run.py return-spot --amount 2 --execute
    
    return 2000000 (2 USDC, 6 decimals) from Core spot to EVM
    plan: return_usdc(2000000, False) value 0
    sent return_usdc: 0x... in block N
      block N+10: EVM USDC 2, spot USDC 4, withdrawable 0, spot HYPE 0.00998
    change: EVM USDC 2000000 (2 USDC, 6 decimals)
    change: Core spot USDC -200000000 (-2 USDC, 8 decimals)
    change: withdrawable 0 (0 USD, 6 decimals)
    change: Core spot HYPE -2000 (-0.00002 HYPE, 8 decimals)
    observed: the USDC is on EVM. Confirm the pending return.
    plan: confirm() value 0
    sent confirm: 0x... in block N+35
    

    The EVM credit and the Core debit show up together, and the gas came from Core HYPE.

  4. Move 2 USDC to perp, then return it from perp:

    uv run --locked tutorials/evm_core/run.py to-perp --amount 2 --execute
    uv run --locked tutorials/evm_core/run.py return-perp --amount 2 --execute
    
    return 2000000 (2 USDC, 6 decimals) from Core perp to EVM
    plan: return_usdc(2000000, True) value 0
    sent return_usdc: 0x... in block N
      block N+11: EVM USDC 4, spot USDC 2, withdrawable 0, spot HYPE 0.00996
    change: EVM USDC 2000000 (2 USDC, 6 decimals)
    change: Core spot USDC 0 (0 USDC, 8 decimals)
    change: withdrawable -2000000 (-2 USD, 6 decimals)
    change: Core spot HYPE -2000 (-0.00002 HYPE, 8 decimals)
    observed: the USDC is on EVM. Confirm the pending return.
    
  5. Optional: see a silent failure. This step returns 1 USDC more than the spot balance:

    uv run --locked tutorials/evm_core/run.py return-too-much --execute
    
    Failure demo: this return is 1 USDC more than the Core spot balance.
    return 3000000 (3 USDC, 6 decimals) from Core spot to EVM
    plan: return_spot_unchecked(3000000) value 0
    sent return_spot_unchecked: 0x... in block N
      block N+11: EVM USDC 4, spot USDC 2, withdrawable 0, spot HYPE 0.00996
      ...
      block N+33 (+22): EVM USDC 4, spot USDC 2, withdrawable 0, spot HYPE 0.00996
    change: EVM USDC 0 (0 USDC, 6 decimals)
    change: Core spot USDC 0 (0 USDC, 8 decimals)
    change: withdrawable 0 (0 USD, 6 decimals)
    change: Core spot HYPE 0 (0 HYPE, 8 decimals)
    NOT observed: Core rejected the return. The EVM transaction succeeded, and the return stays pending. After 200 L1 blocks (about 15 seconds), run the abandon step.
    

    Nothing moved and no gas was charged. Clear the pending record:

    uv run --locked tutorials/evm_core/run.py abandon --execute
    

    If the step stops with Too early, wait a few seconds and run it again.

  6. Optional: return without HYPE. Send the contract's Core HYPE to you, then return 1 USDC from spot:

    uv run --locked tutorials/evm_core/run.py remove-hype --execute
    uv run --locked tutorials/evm_core/run.py return-spot --amount 1 --execute
    
    Note: the contract has no Core HYPE, so Core takes the gas in USDC: about 92700 (0.000927 USDC, 8 decimals).
    return 1000000 (1 USDC, 6 decimals) from Core spot to EVM
    ...
    change: EVM USDC 1000000 (1 USDC, 6 decimals)
    change: Core spot USDC -100092100 (-1.000921 USDC, 8 decimals)
    change: withdrawable 0 (0 USD, 6 decimals)
    change: Core spot HYPE 0 (0 HYPE, 8 decimals)
    observed: the USDC is on EVM. Confirm the pending return.
    

    Core took 1.000921 USDC from spot: the amount plus 0.000921 USDC of gas.

  7. Deposit 1 USDC from the contract's EVM balance back to Core, into the spot balance of your own Core account:

    uv run --locked tutorials/evm_core/run.py deposit --amount 1 --execute
    
    deposit 1000000 (1 USDC, 6 decimals) into the Core spot balance of 0x...
    plan: deposit(1000000) value 0
    sent deposit: 0x... in block N
      block N+9: owner Core spot USDC 1001.0
    change: EVM USDC -1000000 (-1 USDC, 6 decimals)
            owner Core spot USDC 100000000 (1 USDC, 8 decimals)
    observed: the USDC is on Core. Confirm the pending deposit.
    sent confirm: 0x... in block N+15
    

    The contract's EVM USDC fell by exactly 1 USDC and your Core spot USDC grew by exactly 1 USDC, so the deposit paid no fee.

    If the step prints NOT observed, your address has no mainnet account, so the testnet deposit wallet didn't credit it. Wait about 15 seconds and run the abandon step. Either way, the deposit wallet has taken the EVM USDC.

  8. Clean up. Send the contract's EVM USDC to you on EVM, then its Core USDC and HYPE to you on Core:

    uv run --locked tutorials/evm_core/run.py withdraw --execute
    uv run --locked tutorials/evm_core/run.py sweep --execute
    

The USDC that came back to EVM stays as EVM USDC in your address. You lose the 1 USDC activation fee and the Core gas of the returns.

What can go wrong

  • A transfer is pending. The contract allows one pending transfer at a time. Run the confirm step, or run abandon after Core has processed it.
  • Spot balance too low or Not withdrawable. The contract checks the source before it sends. Run the fund or to-perp step first.
  • Too early. abandon waits 200 L1 blocks, about 15 seconds.
  • Core took the USDC. Core took the USDC, so the return succeeded. Run the confirm step.
  • Owner has no Core account. Activate your address on testnet Core first, as testnet setup describes.