Skip to content

precompiles.vy

This module lets a contract read HyperCore state: balances, positions, prices and token metadata. Each function asks one question and returns a typed answer.

If you know Python, think of it as a typed API client: each function sends a request, gets bytes back and parses them into a dataclass. The difference is that the "server" is part of the chain, and the answer describes Core state at the start of the current EVM block.

The module docstring

"""
@title HyperEVM Precompile Reader
@notice Typed Vyper wrappers for HyperEVM read precompiles.
        Precompiles return HyperCore state as of the start of the current EVM block,
        so CoreWriter actions are never visible to precompiles in the same block.
        Gas cost: 2000 + 65 * (input_len + output_len) per call.
@dev All functions are internal so this file can be imported as a module:

        from hyper_vyper import precompiles

        bal: precompiles.SpotBalance = precompiles.spot_balance(user, token)

     Dynamic responses (token_info, token_supply, delegations, perp_asset_info,
     spot_info) are decoded into bounded types. If a response exceeds the bound,
     the call reverts. The constants below set the limits.

     Reply length: a read with a fixed-size reply reverts if the reply is longer
     than expected. If the precompile format changes, these reads stop working,
     so a contract that holds funds needs an upgrade or escape path. A read with
     a variable-length reply accepts trailing bytes up to the decode bound of its
     type.

     Gas: a failed precompile read consumes its whole gas cap (SMALL_READ_GAS or
     LARGE_READ_GAS), which can use most of a small block's gas.
     spot_index_for_token and spot_index_for_pair read spot_info for each market
     of the token, up to MAX_SPOT_MARKETS reads.
"""

A read shows Core state at the start of the block, so an action that your contract sends in this block shows only in a later block (concepts explains why). Variable-length replies go into types with a maximum size, and a larger reply causes a revert. A failed read uses all the gas that the read is allowed.

Limits and gas

# Decode bounds for dynamic precompile responses
MAX_NAME: constant(uint256) = 64
MAX_SPOT_MARKETS: constant(uint256) = 256
MAX_DELEGATIONS: constant(uint256) = 64
MAX_NON_CIRCULATING: constant(uint256) = 256

# Reply buffers. LARGE_REPLY fits the largest token_info, token_supply or
# delegations reply within the decode bounds above, with the outer ABI tuple
# offset. SMALL_REPLY fits every other reply.
SMALL_REPLY: constant(uint256) = 1024
LARGE_REPLY: constant(uint256) = 16608

# Gas caps for one read, with headroom for the largest reply (see the read cost
# in the module docstring). A read never forwards all gas.
SMALL_READ_GAS: constant(uint256) = 75_000
LARGE_READ_GAS: constant(uint256) = 1_100_000

Vyper has no lists or strings without a maximum size, so each variable-length value needs a bound, for example String[64]. These four constants set the bounds.

The two gas constants limit what a read may spend. A precompile costs 2000 + 65 * (input bytes + output bytes) gas, and these are the largest cases:

Read Largest input Largest output Cost Limit
Small reads 64 bytes 1,024 bytes 72,720 75,000
Large reads 32 bytes 16,608 bytes 1,083,600 1,100,000

Each limit covers the largest reply that the library accepts. The Solidity library sends all remaining gas instead, but a limit is safer because a broken read cannot use up the gas of the whole transaction.

Structs: the shape of each answer

struct SpotBalance:
    total: uint64
    hold: uint64
    entry_ntl: uint64

A struct is like a Python dataclass. The decoder reads the values by position, so the field order must match the order of the values in the reply.

struct AccountMarginSummary:
    account_value: int64
    margin_used: uint64
    ntl_pos: uint64
    raw_usd: int64

account_value and raw_usd are int64, a signed type, because a perp account can have a negative value. The other fields are unsigned (uint64). account_value uses 6 decimals, so 1 USDC is 1_000_000.

struct TokenInfo:
    name: String[MAX_NAME]
    spots: DynArray[uint64, MAX_SPOT_MARKETS]
    deployer_trading_fee_share: uint64
    deployer: address
    evm_contract: address
    sz_decimals: uint8
    wei_decimals: uint8
    evm_extra_wei_decimals: int8

TokenInfo holds the token metadata that the conversions use (see conversions.vy): wei_decimals, sz_decimals and evm_extra_wei_decimals. spots is a list with a maximum length, written DynArray[uint64, MAX_SPOT_MARKETS].

The two call helpers

@internal
@view
def _call(addr: address, data: Bytes[64]) -> Bytes[SMALL_REPLY]:
    return raw_call(addr, data, max_outsize=SMALL_REPLY, gas=SMALL_READ_GAS, is_static_call=True)


@internal
@view
def _call_big(addr: address, data: Bytes[64]) -> Bytes[LARGE_REPLY]:
    return raw_call(addr, data, max_outsize=LARGE_REPLY, gas=LARGE_READ_GAS, is_static_call=True)

Every read goes through one of these two helpers. raw_call sends bytes to an address and returns bytes, and it takes these options:

Option Meaning
max_outsize=SMALL_REPLY The largest reply that the call accepts: 1,024 bytes
gas=SMALL_READ_GAS The most gas that the call may use
is_static_call=True The call can read but cannot change state
(default) revert_on_failure=True If the precompile fails, the whole transaction reverts

The last row matters most for funds. A failed read never returns zero. It reverts the transaction, so a contract that holds funds cannot read "balance 0" by mistake when the read failed.

@internal means only code in the same contract can call the function. When a contract imports the module, the compiler copies the functions into that contract. @view means the function reads but does not write.

One read, step by step

@internal
@view
def spot_balance(user: address, token: uint64) -> SpotBalance:
    """Spot balance of a token for a user."""
    response: Bytes[SMALL_REPLY] = self._call(
        constants.SPOT_BALANCE_PRECOMPILE,
        abi_encode(user, token),
    )
    return abi_decode(response, SpotBalance)

spot_balance(user, token) works in three steps:

  1. abi_encode(user, token) builds the input: two 32-byte words, 64 bytes in total. Both the address and the token index are padded on the left with zeros.
  2. self._call(...) sends the input to precompile 0x0801 and gets the reply.
  3. abi_decode(response, SpotBalance) checks the reply and returns a struct.

The reply has three 32-byte words, 96 bytes in total:

Word Field Meaning
1 total All of the token, in Core wei (8 decimals for USDC)
2 hold The part that open orders hold. It is included in total.
3 entry_ntl Entry notional value

abi_decode rejects a reply that is too short, too long, or has a value that does not fit its type. For example, a uint64 field with a value above 2**64 - 1 causes a revert.

All the other simple reads follow the same three steps, and only the precompile address, the input and the struct change.

Prices and their units

@internal
@view
def mark_px(perp_index: uint32) -> uint64:
    """Perp mark price. Divide by 10**(6 - sz_decimals) for the price in USD."""
    response: Bytes[SMALL_REPLY] = self._call(
        constants.MARK_PX_PRECOMPILE,
        abi_encode(perp_index),
    )
    return abi_decode(response, uint64)

A raw price has a scale that depends on the market:

  • A perp price is the price times 10**(6 - sz_decimals).
  • A spot price is the price times 10**(8 - base_sz_decimals), where base_sz_decimals is the sz_decimals of the base token.

The normalized_* functions give a price with a fixed number of decimals:

@internal
@view
def normalized_mark_px(perp_index: uint32) -> uint256:
    """Perp mark price with 6 decimals (USD).
    Equals mark_px * 10**sz_decimals. Reverts on overflow."""
    sz_decimals: uint8 = self.perp_asset_info(perp_index).sz_decimals
    return convert(self.mark_px(perp_index), uint256) * 10 ** convert(sz_decimals, uint256)

Here is an example from mainnet. BTC has sz_decimals = 5, so a raw mark price of 834570 times 10**5 is 83_457_000_000. With 6 decimals, that is 83,457 USD.

Finding a market

@internal
@view
def _find_spot_index(token: uint64, quote: uint64) -> (bool, uint64):
    # First of the token's spot markets quoted in `quote`, in token_info order.
    info: TokenInfo = self.token_info(token)
    for spot: uint64 in info.spots:
        market: SpotInfo = self.spot_info(spot)
        if market.tokens[1] == quote:
            return True, spot
    return False, 0


@internal
@view
def spot_index_for_token(token: uint64) -> uint64:
    """Spot market index for the token's USDC pair (quote token index 0).
    Reverts if no market is quoted in USDC, even if the token has only one market."""
    found: bool = False
    spot: uint64 = 0
    found, spot = self._find_spot_index(token, constants.USDC_TOKEN_INDEX)
    assert found, "No USDC spot market"
    return spot

_find_spot_index reads the token's list of spot markets, then reads each market until it finds one with the requested quote token. Vyper returns the two values as a tuple: (found, spot).

spot_index_for_token always requires a USDC quote. The Solidity library skips this check and returns a token's only market, which can be wrong. On mainnet, STHYPE has one market, and its quote token is not USDC. The Solidity library returns that market, so a caller that reads its price gets a price in another token, not in USDC. Use spot_index_for_pair for a market quoted in another token.

Account existence

@internal
@view
def core_user_exists(user: address) -> bool:
    """Whether a user has an activated HyperCore account.
    Returns false for an account that is not activated. HYPE and linked tokens
    sent to it go into an escrow that the precompiles do not show. An account that
    is not activated cannot send CoreWriter actions."""
    response: Bytes[SMALL_REPLY] = self._call(
        constants.CORE_USER_EXISTS_PRECOMPILE,
        abi_encode(user),
    )
    return abi_decode(response, bool)

core_user_exists returns true only for an activated account. Security explains what changes at activation and why a contract that holds funds must check it.

What the reads cannot tell you

  • The account mode (see account mode).
  • Funds in escrow.
  • Past state. An off-chain eth_call to a precompile ignores the block tag and always returns the current state.
  • A list of open orders, or of all tokens that an account holds.

Check your understanding

  1. Why does a failed read revert instead of returning zero, and why does that matter for a contract that holds funds?
  2. spot_balance returns total = 500_000_000 and hold = 100_000_000 for USDC. How much USDC is free for a transfer?
  3. Why are account_value and raw_usd signed, but margin_used unsigned?
  4. A contract sends a CoreWriter action and reads spot_balance in the same transaction. Does the read show the result of the action? Why?
  5. Why does the library set a gas limit on each read?

Try it

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

Swap the hold and entry_ntl fields in SpotBalance, run the command again and read the failure. Then restore the file with git checkout -- src/.

Next: core_writer.vy.