Skip to content

core_writer.vy

This module lets a contract send actions to HyperCore: orders, transfers, staking and account settings. precompiles.vy asks questions, and this module gives instructions.

If you know Python, think of each function as building a request body and putting it on a queue. The function returns at once. A worker processes the queue later, and it does not tell you when a request fails.

The module docstring

"""
@title HyperEVM CoreWriter
@notice Typed Vyper wrappers for HyperEVM CoreWriter actions.
        CoreWriter sends actions from HyperEVM to HyperCore.
        Order and vault actions have a built-in few-second delay to prevent frontrunning.
        One action costs about 24,000 to 30,000 gas when a contract
        sends it.
@dev All functions are internal so this file can be imported as a module:

        from hyper_vyper import core_writer

        core_writer.spot_send(recipient, token_index, amount_wei)

     CoreWriter actions are asynchronous. The EVM transaction succeeds when the
     action goes into the queue, and HyperCore runs the action after the block.
     A failed Core action does not revert the EVM transaction, so read the
     result with the precompiles in a later block.

     WARNING: spot_send, send_asset, vault_transfer, delegate_token,
     approve_builder_fee and add_api_wallet move funds or authority to the
     address that you give. Never take that address from untrusted input.

     Units:
     - Token amounts (amount_wei) use the token's Core wei decimals.
     - usd_amount and ntl are perp USD with 6 decimals.
     - limit_px and sz in place_limit_order are 10**8 times the human-readable value.
     Use the conversions module to convert from EVM amounts.
"""

HyperCore runs each action after the EVM block, so the EVM transaction succeeds even when HyperCore later rejects the action, and your contract must read the result in a later block (concepts explains this model). A transfer shows in the next block. The Hyperliquid documentation says orders and vault transfers can take a few seconds more.

The one function that sends

# The largest payload is a limit order: 4-byte header + 7 ABI words = 228 bytes.
MAX_ACTION_BYTES: constant(uint256) = 228

interface ICoreWriter:
    def sendRawAction(data: Bytes[MAX_ACTION_BYTES]): nonpayable


@internal
def _send(data: Bytes[MAX_ACTION_BYTES]):
    extcall ICoreWriter(constants.CORE_WRITER).sendRawAction(data)
  • interface ICoreWriter tells the compiler the function signature of the CoreWriter contract, much like a type stub in Python.
  • extcall calls another contract that can change state. The reads in precompiles.vy are static calls, which cannot change state.
  • Bytes[MAX_ACTION_BYTES] is a byte string with a maximum length of 228, because the largest action, a limit order, is exactly 228 bytes.

Every other function in the module calls _send, so all actions go to the same address, constants.CORE_WRITER.

A limit order, byte by byte

@internal
def place_limit_order(
    asset: uint32,
    is_buy: bool,
    limit_px: uint64,
    sz: uint64,
    reduce_only: bool,
    tif: uint8,
    cloid: uint128,
):
    """Place a limit order on HyperCore.
    tif is constants.TIF_ALO, constants.TIF_GTC or constants.TIF_IOC.
    Use cloid = 0 for no client order ID.
    limit_px and sz are 10**8 times the human-readable price and size.
    For spot markets, asset = spot_index + constants.SPOT_ASSET_OFFSET."""
    self._send(concat(
        constants.LIMIT_ORDER_HEADER,
        abi_encode(asset, is_buy, limit_px, sz, reduce_only, tif, cloid),
    ))

concat(header, abi_encode(...)) builds the payload:

Bytes Content
4 Header 0x01000001: version 1, action 1 (limit order)
32 asset (uint32, padded)
32 is_buy
32 limit_px (uint64)
32 sz (uint64)
32 reduce_only
32 tif (uint8)
32 cloid (uint128)

Pay attention to the units:

  • limit_px and sz are 10**8 times the human-readable value. A price of 47.203 USDC is 4_720_300_000, and a size of 0.25 is 25_000_000.
  • For a spot market, asset is 10000 + the spot index.
  • Hyperliquid has a minimum order value of 10 USDC.

The spot order tutorial places IOC orders (TIF_IOC) with this function.

Concepts shows a complete spot_send payload with each byte marked.

A check before the action

@internal
def vault_transfer(vault: address, is_deposit: bool, usd_amount: uint64):
    """Deposit or withdraw USDC from a HyperCore vault. usd_amount is in perp USD (6 decimals).
    The deposit comes from the perp balance and shows after a few seconds. Each
    deposit starts a lock (4 days for HLP). A withdrawal reverts while the vault
    lock of this contract is active.
    WARNING: moves funds to the given vault. Never take it from untrusted input."""
    if not is_deposit:
        equity: precompiles.UserVaultEquity = precompiles.user_vault_equity(self, vault)
        # Match the strict boundary of the Solidity library. Core timestamps are milliseconds.
        # Computed inline so that core_writer does not import conversions.
        timestamp_ms: uint64 = convert(block.timestamp, uint64) * 1000
        assert timestamp_ms > equity.locked_until_timestamp, "Vault withdrawal locked"
    self._send(concat(
        constants.VAULT_TRANSFER_HEADER,
        abi_encode(vault, is_deposit, usd_amount),
    ))

vault_transfer moves USDC into or out of a HyperCore vault. Before a withdrawal, it reads the vault lock (see precompiles.vy) and reverts if the lock is still active. The check is useful because a locked withdrawal would otherwise fail silently on Core.

  • block.timestamp is in seconds and Core uses milliseconds, so the code multiplies by 1000.
  • > (not >=) matches the Solidity library exactly.

Transfers

@internal
def spot_send(to: address, token: uint64, amount_wei: uint64):
    """Send spot tokens to another address on HyperCore.
    A send to the system address of a token bridges it back to EVM.
    Reverts for a send to this contract, because Core always rejects it.
    A send to an account that is not activated fails unless it is 1 USDC or more.
    In unified account mode, Core refuses a user-signed spotSend but accepts a
    CoreWriter spot_send. send_asset is the general transfer, and the library
    uses it for every route.
    The address warning in the module docstring applies."""
    assert to != self, "Self transfer"
    self._send(concat(
        constants.SPOT_SEND_HEADER,
        abi_encode(to, token, amount_wei),
    ))

spot_send sends a token to another address on Core. It reverts when the recipient is the contract itself, because Core always rejects that action.

@internal
def send_asset(
    destination: address,
    sub_account: address,
    source_dex: uint32,
    destination_dex: uint32,
    token: uint64,
    amount_wei: uint64,
):
    """Move an asset between DEXes and addresses on HyperCore.
    Use constants.SPOT_DEX (max uint32) for spot and constants.DEFAULT_PERP_DEX (0)
    for the default perp DEX.
    The address warning in the module docstring applies."""
    self._send(concat(
        constants.SEND_ASSET_HEADER,
        abi_encode(destination, sub_account, source_dex, destination_dex, token, amount_wei),
    ))

send_asset is the general transfer. It moves funds between addresses and between DEXes (spot, perp DEX 0, other perp DEXes), and the library uses it for every route. spot_send moves spot tokens only. In unified account mode, Core refuses a user-signed spotSend but accepts a CoreWriter spot_send.

A send_asset to a token's system address returns the token to EVM, and the bridge module uses this route (see bridge.vy).

@internal
def transfer_usd_class(ntl: uint64, to_perp: bool):
    """Move USDC between the spot and perp balances. ntl is in perp USD (6 decimals).
    In unified account or portfolio margin mode, balance reads can show perp
    funds through spot, so read both balances to check the result. See
    set_abstraction."""
    self._send(concat(
        constants.USD_CLASS_TRANSFER_HEADER,
        abi_encode(ntl, to_perp),
    ))

transfer_usd_class moves USDC between the spot balance and the perp balance of the same address, and ntl uses perp units (6 decimals). For example, moving 3 USDC from spot to perp decreases spot by 300_000_000 (8 decimals) and increases the perp account value by 3_000_000 (6 decimals).

Functions that give away funds or authority

Several functions move funds or authority to an address that you supply, and each one has a warning in its docstring. Never take these addresses from untrusted input, for example from a function argument that any user can set.

@internal
def add_api_wallet(wallet: address, name: String[32]):
    """Add or replace an API wallet (Hyperliquid also calls it an agent). An empty
    name selects the unnamed API wallet. The function does not remove a wallet.
    The change takes effect in the next block, and Core then refuses the replaced
    address. An API wallet added this way expires after 90 days (validUntil in the
    info API request extraAgents). The unnamed one does not show in extraAgents,
    so query userRole for its address.
    WARNING: the API wallet can trade with the whole Core account of this
    contract, so never take its address from untrusted input. It stays active when
    you pause the EVM contract. To revoke it, replace it with a new address under
    the same name. Never reuse a replaced address, because Hyperliquid can prune
    its nonce state and old signed actions could then replay."""
    self._send(concat(
        constants.ADD_API_WALLET_HEADER,
        abi_encode(wallet, name),
    ))

add_api_wallet is the most powerful one. An API wallet (Hyperliquid also calls it an agent) can trade with the contract's whole Core account, and pausing the EVM contract does not stop it.

# --- Borrow/Lend ---

@internal
def borrow_lend(operation: uint8, token: uint64, amount_wei: uint64):
    """Supply to or withdraw from borrow/lend.
    operation is constants.BLP_SUPPLY or constants.BLP_WITHDRAW.
    WARNING: amount_wei = 0 means the maximum amount (Hyperliquid protocol
    semantics). A computed amount that rounds down to 0 moves the whole balance.
    Validate the amount in your contract before you call this function."""
    self._send(concat(
        constants.BORROW_LEND_HEADER,
        abi_encode(operation, token, amount_wei),
    ))

borrow_lend has a trap: an amount of 0 means "the maximum". If your contract computes an amount that rounds down to 0, the action moves the whole balance. The library keeps the protocol meaning and warns you, so your contract must check the amount.

Account mode

@internal
def set_abstraction(user: address, abstraction: uint8):
    """Set the account abstraction mode.
    abstraction is constants.ABSTRACTION_DISABLED, constants.ABSTRACTION_UNIFIED_ACCOUNT
    or constants.ABSTRACTION_PORTFOLIO_MARGIN.
    user can be the master user or a sub-account. The info API request
    userAbstraction returns "default" for an account with no mode set, or
    "disabled", "unifiedAccount" or "portfolioMargin". A switch to unified account
    mode moves the perp USDC to spot, and a switch back does not move it back.
    Core ignores portfolio margin unless the account has 10,000 USD of account
    value or 5,000,000 USD of trading volume. In unified account mode, balance
    reads can show perp funds through spot. No precompile reads the current mode.
    Call set_abstraction(self, constants.ABSTRACTION_DISABLED) once after
    activation, check the mode off-chain, and use send_asset for transfers."""
    self._send(concat(
        constants.SET_ABSTRACTION_HEADER,
        abi_encode(user, abstraction),
    ))

The account mode decides how Core holds an account's USDC: as separate spot and perp balances, or pooled. The info API request userAbstraction returns one of these values:

Value Meaning
default No mode is set, so the account uses Hyperliquid's default for its account type. A new contract keeps spot and perp separate, and a new user wallet starts in unifiedAccount.
disabled Spot and perp stay separate. set_abstraction(self, ABSTRACTION_DISABLED) sets it.
unifiedAccount Spot and perp are pooled. A switch to this mode moves the perp USDC to spot, and a switch back doesn't move it back. Balance reads can show perp funds through spot.
portfolioMargin Portfolio margin. Core ignores the switch unless the account has 10,000 USD of account value or 5,000,000 USD of trading volume.

A contract that holds funds should call set_abstraction(self, ABSTRACTION_DISABLED) once after activation. disabled pins separate spot and perp balances, so accounting that counts them separately doesn't change if Hyperliquid changes the default. No precompile can read the current mode, so check the result with the info API.

Check your understanding

  1. A contract calls transfer_usd_class and the EVM transaction succeeds. Has the USDC moved? How can the contract find out?
  2. What is the byte length of a cancel_order_by_oid payload?
  3. Encode a buy order for 1.5 units at a price of 2.25 USDC. What are sz and limit_px?
  4. Why does the library prefer send_asset to spot_send?
  5. Your contract calls borrow_lend(BLP_WITHDRAW, token, amount) with amount = wei_to_sz(...). What can go wrong?

Try it

uv run --locked pytest tests/test_actions.py -q

Swap limit_px and sz in the abi_encode call of place_limit_order, run the command again and read the failure. Then restore the file with git checkout -- src/.

Next: conversions.vy.