bridge.vy
This module moves funds between HyperEVM and HyperCore: USDC, HYPE and linked ERC-20 tokens. It builds on the other four modules, and a mistake here can lose funds. Each function validates its input, calls one or two external systems and returns. The final result arrives later, in another system, and your contract must check it.
The module docstring
"""
@title HyperEVM Bridge
@notice Sends transfers of USDC, HYPE and linked ERC-20 tokens between HyperEVM
and HyperCore. A successful submission does not prove delivery.
@dev All functions are internal. Import the module:
from hyper_vyper import bridge
bridge.bridge_usdc_to_core(amount_evm, constants.SPOT_DEX)
The importing contract must do access control, reentrancy protection,
reserves, pending-transfer records and checks of the later Core or EVM
result.
EVM -> Core routes. The Core account at the address of this contract is the
recipient:
- USDC (token 0): approve and deposit through the Circle CoreDepositWallet.
- HYPE: native value sent to 0x2222...2222.
- Any other linked token: ERC-20 transfer to its system address 0x20...<index>.
WARNING: CoreWriter actions from an account that is not activated fail
silently. HYPE and linked tokens sent to it wait in an escrow that the
precompiles do not show (core_user_exists is false, spot_balance is 0). A
Core transfer of 1 USDC or more activates it, the sender pays the 1 USDC
fee, and Core releases the escrow to spot. A USDC deposit also activates
it, and Core takes the 1 USDC fee from the deposit. Check
precompiles.core_user_exists(self) in your contract before you bridge.
WARNING: the USDC functions do not restrict the DEX index, and the Circle
CoreDepositWallet accepts any index. A HIP-3 DEX can use collateral that is
not USDC, and a wrong source DEX makes a return fail silently. Hard-code
constants.DEFAULT_PERP_DEX or constants.SPOT_DEX, unless you verified the
other DEX.
Core -> EVM: a sendAsset action to the system address of the token. The EVM
credit goes to the address of this contract (HYPE as native value).
"""
The two warnings cover activation and the DEX index. Security explains both in full.
Routes at a glance
flowchart LR
subgraph EVM["HyperEVM: your contract"]
U["USDC"]
H["HYPE (native)"]
T["Linked ERC-20"]
end
D["Circle CoreDepositWallet"]
HS["0x2222...2222"]
SA["System address<br/>0x20... + token index"]
S["Core spot balance<br/>of your contract"]
U -- "approve + deposit" --> D --> S
H -- "native value" --> HS --> S
T -- "ERC-20 transfer" --> SA --> S
bridge_to_core picks the route from the token index:
- USDC (token 0) goes through the deposit wallet to the spot DEX. To choose a perp DEX or a different recipient, use the USDC functions instead.
- HYPE goes to
0x2222...2222as native value. - Other linked tokens go to their system address as an ERC-20 transfer.
bridge_to_evm sends CoreWriter action 13 (send_asset) from spot to the
token's system address. HYPE arrives on EVM as native value, and other tokens
arrive as an ERC-20 transfer from the system address.
Checks before any transfer
interface ICoreDepositWallet:
def deposit(amount: uint256, destinationDex: uint32): nonpayable
def depositFor(recipient: address, amount: uint256, destinationDex: uint32): nonpayable
# USDC has 6 decimals on EVM and 8 on Core: one EVM unit is 100 Core wei.
USDC_WEI_PER_EVM_UNIT: constant(uint256) = 100
@internal
@view
def _check_usdc_info(info: precompiles.TokenInfo):
# Core links USDC to Circle's CoreDepositWallet, not to the USDC token itself.
assert info.evm_contract == conversions.core_deposit_wallet(), "USDC link mismatch"
assert info.wei_decimals == 8 and info.evm_extra_wei_decimals == -2, "USDC decimals mismatch"
@internal
@view
def _validate_usdc(amount_evm: uint256) -> uint64:
# Returns the Core amount. Check it before any external write.
conversions.usdc_address() # rejects unsupported networks
self._check_usdc_info(precompiles.token_info(constants.USDC_TOKEN_INDEX))
amount_wei: uint64 = convert(amount_evm * USDC_WEI_PER_EVM_UNIT, uint64)
assert amount_wei > 0, "Amount too small"
return amount_wei
Every USDC function first calls _validate_usdc, which returns the amount in
Core wei:
usdc_address()reverts on an unknown network._check_usdc_inforeads token 0 and checks two facts: Core must link USDC to the Circle deposit wallet, and the decimals must be 8 on Core and 6 on EVM. The functions below depend on the factor of 100 between the two.- The amount times 100 must fit in
uint64and must not be zero.
All checks run before the first external call, so if a check fails, nothing moves.
Core links USDC to the deposit wallet, not to the USDC token, so the first check compares the link with the deposit wallet. The tests use replies recorded from the real chains to check this.
USDC from EVM to Core
@internal
def _deposit_usdc(recipient: address, amount_evm: uint256, destination_dex: uint32) -> uint64:
# recipient empty(address) means the Core account of this contract.
amount_wei: uint64 = self._validate_usdc(amount_evm)
token: address = conversions.usdc_address()
wallet: address = conversions.core_deposit_wallet()
assert extcall IERC20(token).approve(wallet, amount_evm, default_return_value=True), "USDC approval failed"
if recipient == empty(address):
extcall ICoreDepositWallet(wallet).deposit(amount_evm, destination_dex)
else:
extcall ICoreDepositWallet(wallet).depositFor(recipient, amount_evm, destination_dex)
# Do not leave spend authority if a wallet ever consumes less than approved.
assert extcall IERC20(token).approve(wallet, 0, default_return_value=True), "USDC approval reset failed"
return amount_wei
@internal
def bridge_usdc_to_core(amount_evm: uint256, destination_dex: uint32):
"""Submit a deposit to the Core account of this contract.
Use constants.DEFAULT_PERP_DEX (0) for perps or constants.SPOT_DEX (max uint32)
for spot, and see the DEX warning in the module docstring. A deposit to an
account that is not activated activates it and pays the 1 USDC fee."""
self._deposit_usdc(empty(address), amount_evm, destination_dex)
@internal
def bridge_usdc_to_core_for(recipient: address, amount_evm: uint256, destination_dex: uint32):
"""Submit a deposit for a recipient. Never take the recipient from untrusted input.
See the DEX warning in the module docstring."""
assert recipient != empty(address), "Zero recipient"
self._deposit_usdc(recipient, amount_evm, destination_dex)
Both deposit functions use _deposit_usdc. It deposits for the Core account of
this contract (deposit) or for a recipient (depositFor), and it makes three
external calls:
sequenceDiagram
participant C as Your contract
participant U as USDC
participant D as Deposit wallet
C->>U: approve(D, amount)
C->>D: deposit(amount, dex)
D->>U: transferFrom(C, D, amount)
C->>U: approve(D, 0)
Note over C,D: Core credits the funds in the next block
approvegives the deposit wallet permission to take the amount.deposittakes the USDC and tells Core to credit this contract.- The second
approvesets the permission back to zero, so no permission remains if the wallet takes less than the approved amount. default_return_value=Trueaccepts tokens that return no value fromapprove, but a token that returnsFalsecauses a revert.
For an activated account, Core credits the exact amount in the next block, with no fee. On testnet, the deposit wallet credits only an address that has a Hyperliquid mainnet account (tutorial 4 explains).
USDC from Core to EVM
@internal
def bridge_usdc_to_evm(amount_evm: uint256, source_dex: uint32):
"""Submit a return from the Core account of this contract to its EVM address.
The amount is in EVM USDC units (6 decimals). The CoreWriter payload uses 8 decimals.
Use constants.DEFAULT_PERP_DEX (0) or constants.SPOT_DEX (max uint32) as the
source, and see the DEX warning in the module docstring. A wrong source DEX
fails silently. A return above the balance or the withdrawable amount fails
silently. Core charges the gas of the transfer in HYPE from Core spot, or in
USDC when the account holds no HYPE. Keep a small HYPE balance, so that the
USDC accounting stays exact. The security page of the docs gives the amounts."""
amount_wei: uint64 = self._validate_usdc(amount_evm)
core_writer.send_asset(
constants.BASE_SYSTEM_ADDRESS,
empty(address),
source_dex,
constants.SPOT_DEX,
constants.USDC_TOKEN_INDEX,
amount_wei,
)
A return is one CoreWriter action: send_asset to the USDC system address
0x2000...0000, from the source DEX to spot. When the USDC arrives at the
system address in spot, Core sends it on to EVM.
This is the payload for a return of 1 USDC from perp DEX 0:
0100000dheader: version 0x01, action 13 (send asset)0000000000000000000000002000000000000000000000000000000000000000destination: USDC system address0000000000000000000000000000000000000000000000000000000000000000sub_account: none0000000000000000000000000000000000000000000000000000000000000000source_dex: 0 (default perp DEX)00000000000000000000000000000000000000000000000000000000ffffffffdestination_dex: spot (max uint32)0000000000000000000000000000000000000000000000000000000000000000token: 0 (USDC)0000000000000000000000000000000000000000000000000000000005f5e100amount_wei: 100,000,000 (1_000_000 × 100)bridge.bridge_usdc_to_evm(1_000_000, 0). The helper multiplies the 6-decimal EVM amount by 100.This is how HyperCore handles each return:
| Source | Result |
|---|---|
| Spot | Arrives on EVM in the same block as the Core debit: the block after the transaction. |
| Perp DEX 0 | Arrives the same way. The Solidity library only supports spot. |
More than the balance or withdrawable |
Fails silently. Nothing changes, and Core charges no gas. |
Each USDC return pays a small gas fee on Core, in HYPE or, without HYPE, in USDC. Security gives the amounts.
HYPE from EVM to Core
@internal
def bridge_hype_to_core(amount_evm: uint256) -> uint64:
"""Send native HYPE (18 decimals) from this contract's balance to the HYPE
system address. The returned Core amount has 8 decimals. Core credits the
spot balance of this contract only if it is activated. Otherwise Core holds
the HYPE in an escrow (see the module docstring).
amount_evm must be a multiple of 10**10, because Core would burn a remainder.
Reverts if the Core amount is 0 or does not fit in uint64, or if the contract
balance is too low. Forwards all gas, like the Solidity library."""
conversions.hype_token_index() # rejects unsupported networks
# HYPE has 18 decimals on EVM and 8 on Core. Both checks use this one rule.
unit: uint256 = 10 ** constants.HYPE_EVM_EXTRA_DECIMALS
assert amount_evm % unit == 0, "Not a whole Core unit"
amount_wei: uint64 = convert(amount_evm // unit, uint64)
assert amount_wei > 0, "Amount too small"
# The system contract only emits Received(sender, value). It has no hooks.
raw_call(constants.HYPE_SYSTEM_ADDRESS, b"", value=amount_evm)
return amount_wei
HYPE is the native token of HyperEVM, like ETH on Ethereum, and the function
sends native value to 0x2222...2222 with raw_call.
- HYPE has 18 decimals on EVM and 8 on Core, so the amount must be a multiple
of
10**10. Core would burn any remainder, so the function reverts instead. - The function spends the contract's own balance, not
msg.value, because internal functions cannot receive value directly. raw_callforwards all gas. The receiving system contract only emits an event, so it cannot call back into your contract.
Linked ERC-20 tokens from EVM to Core
@internal
def bridge_erc20_to_core(token: uint64, amount_evm: uint256) -> uint64:
"""Transfer a linked ERC-20 (not USDC, not HYPE) to its system address.
Core credits the spot balance of this contract with the returned Core amount,
but only if the account is activated. Otherwise Core holds the tokens in an
escrow (see the module docstring).
amount_evm must be a whole number of Core wei, because Core would burn a
remainder. Reverts if the token is not linked, if the Core amount is 0 or does
not fit in uint64, or if the transfer fails or returns false.
The linked contract is chosen by the token deployer and can run arbitrary
code, including reentrant calls into the importing contract. For a token that
takes a fee on transfer, Core can credit less than the returned amount."""
assert token != constants.USDC_TOKEN_INDEX, "Use the USDC route"
assert not conversions.is_hype(token), "Use the HYPE route" # rejects unsupported networks
info: precompiles.TokenInfo = conversions.checked_token_info(token)
# Reverts "Token not linked to EVM" for an unlinked token (HYPE is excluded above).
amount_wei: uint64 = conversions.evm_to_wei_with_info(token, info, amount_evm)
assert amount_wei > 0, "Amount too small"
assert amount_evm % self._evm_units_per_core_unit(False, info) == 0, "Not a whole Core unit"
assert extcall IERC20(info.evm_contract).transfer(
conversions.system_address(token), amount_evm, default_return_value=True
), "Token transfer failed"
return amount_wei
For other tokens, the function transfers the ERC-20 token to its system address. Notice the order: all checks come first, then one external call.
The token's deployer chooses the token contract, and that contract can run any
code during transfer, including a call back into your contract
(reentrancy). Protect your contract, for example with @nonreentrant.
Any token from Core to EVM
@internal
def bridge_to_evm(token: uint64, amount: uint256, is_evm_amount: bool) -> uint64:
"""Submit a sendAsset from this contract's Core spot balance to the token's
system address and return the Core amount sent. With is_evm_amount the amount
is in EVM units, otherwise in Core wei. The amount must convert exactly,
because a remainder would stay on Core or be lost. Reverts if the token is not
linked (HYPE excepted), if the Core amount is 0 or does not fit in uint64, or
if the amount is not a whole unit on both sides.
HYPE arrives as native value, so a contract receiver needs a payable default.
Not checked: this contract must have a Core account that holds the amount, and
the system address must hold enough EVM supply of the token. Core charges the
transfer gas in HYPE from Core spot. A USDC return without HYPE pays the gas
in USDC (see bridge_usdc_to_evm). For any other token, keep HYPE on Core
spot."""
hype: bool = conversions.is_hype(token) # rejects unsupported networks
info: precompiles.TokenInfo = conversions.checked_token_info(token)
if token == constants.USDC_TOKEN_INDEX:
self._check_usdc_info(info)
amount_wei: uint64 = 0
if is_evm_amount:
amount_wei = conversions.evm_to_wei_with_info(token, info, amount)
assert amount % self._evm_units_per_core_unit(hype, info) == 0, "Not a whole Core unit"
else:
assert info.evm_contract != empty(address) or hype, "Token not linked to EVM"
amount_wei = convert(amount, uint64)
# Core has more decimals than EVM: only whole EVM units can arrive.
assert amount % self._core_units_per_evm_unit(info) == 0, "Not a whole EVM unit"
assert amount_wei > 0, "Amount too small"
core_writer.send_asset(
conversions.system_address(token),
empty(address),
constants.SPOT_DEX,
constants.SPOT_DEX,
token,
amount_wei,
)
return amount_wei
bridge_to_evm returns any token from Core spot to EVM. With
is_evm_amount = True the amount is in EVM units, and otherwise it is in Core
wei.
The function rejects amounts that do not convert exactly, and two helpers give the unit size on each side:
@internal
@pure
def _evm_units_per_core_unit(hype: bool, info: precompiles.TokenInfo) -> uint256:
# How many EVM units make one Core wei. 1 when Core has as many or more decimals.
if hype and info.evm_contract == empty(address):
return 10 ** constants.HYPE_EVM_EXTRA_DECIMALS
if info.evm_extra_wei_decimals > 0:
return 10 ** convert(info.evm_extra_wei_decimals, uint256)
return 1
@internal
@pure
def _core_units_per_evm_unit(info: precompiles.TokenInfo) -> uint256:
# How many Core wei make one EVM unit. 1 when EVM has as many or more decimals.
if info.evm_contract != empty(address) and info.evm_extra_wei_decimals < 0:
return 10 ** convert(-info.evm_extra_wei_decimals, uint256)
return 1
For example, UBTC has 8 EVM decimals and 10 Core decimals, so one EVM unit is
100 Core wei. A Core amount of 199 would deliver only 1 EVM unit and lose the
other 99 wei, so the function reverts with "Not a whole EVM unit"
instead.
What your contract must add
The library only submits transfers, and your contract must do the rest:
activation, a fixed DEX, pending records, HYPE for returns, access control and
reentrancy protection. Security
explains each one, and examples/usdc_bridge.vy shows these rules in a short contract.
Check your understanding
- In which order do the deposit's external calls happen, and why does the last
approveset the permission to zero? - A return from perp succeeds on EVM, but no USDC arrives. Name two possible causes.
- Why does
bridge_hype_to_core(10**10 + 5)revert? - Why is reentrancy a risk in
bridge_erc20_to_corebut not inbridge_hype_to_core? - On mainnet, your contract deposits 100 USDC before its Core account is
activated. What does
spot_balanceshow in the next block, and why?
Try it
Remove the second approve (the reset to zero) in _deposit_usdc, run
the command again and read the failure. Then restore the file with
git checkout -- src/.
You have read the whole library. Next, try the tutorials on testnet.