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
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:
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.self._call(...)sends the input to precompile0x0801and gets the reply.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), wherebase_sz_decimalsis thesz_decimalsof 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_callto 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
- Why does a failed read revert instead of returning zero, and why does that matter for a contract that holds funds?
spot_balancereturnstotal = 500_000_000andhold = 100_000_000for USDC. How much USDC is free for a transfer?- Why are
account_valueandraw_usdsigned, butmargin_usedunsigned? - A contract sends a CoreWriter action and reads
spot_balancein the same transaction. Does the read show the result of the action? Why? - Why does the library set a gas limit on each read?
Try it
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.