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 ICoreWritertells the compiler the function signature of the CoreWriter contract, much like a type stub in Python.extcallcalls another contract that can change state. The reads inprecompiles.vyare 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_pxandszare10**8times the human-readable value. A price of 47.203 USDC is4_720_300_000, and a size of 0.25 is25_000_000.- For a spot market,
assetis10000 +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.timestampis 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
- A contract calls
transfer_usd_classand the EVM transaction succeeds. Has the USDC moved? How can the contract find out? - What is the byte length of a
cancel_order_by_oidpayload? - Encode a buy order for 1.5 units at a price of 2.25 USDC. What are
szandlimit_px? - Why does the library prefer
send_assettospot_send? - Your contract calls
borrow_lend(BLP_WITHDRAW, token, amount)withamount = wei_to_sz(...). What can go wrong?
Try it
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.