Reverse-engineering Hyperliquid Stars from the binary
/ 14 min read
Read this post in 日本語Table of Contents
As of: 2026-08-30 (JST)
How evidence is handled: this post keeps four things apart — implementation facts confirmed by statically analysing
hl-node, state confirmed by testnet transactions and snapshots, the official Docs, and outside observation. It avoids inferring source-level type names or intended semantics from a stripped binary and then asserting them.
Hyperliquid’s HIP-3 DEXes carry a mechanism called “Stars” that permits normal orders only from specific addresses. The public Docs do not yet explain its schema in any detail, while the current hl-node binary holds concrete implementation: Hip3DeployAction::Star, modifyApprovals, Hip3StarState, User requires approval.
The analysis showed that Stars keeps a per-market address allowlist as a BTreeSet, and consults that set when a normal order is admitted. An update is given as pairs of an address and a bool, true to add and false to remove. The number of approvals after the update is capped at 10,000. A reduce-only order, on the other hand, bypasses the approval check, so this is not a design that also stops an unapproved user from shrinking a position.
Overview (TL;DR)
Hip3DeployAction::Staris its own variant of the HIP-3 deploy action, with internal tag0x10.- The outer payload is
{"star":{"dex":"<dex>","operation":...}}. - There are three operations:
activate,modifyApprovalsandpa. modifyApprovalsis[[20-byte address, bool], ...].trueadds,falseremoves.- The allowlist on the stored side is a
BTreeSet<Address20>. The bool exists only in the update input. - In the serialized state,
ss.ais the array of approved addresses. - The final number of approvals is 10,000 or fewer. Re-adding an existing address, or removing an address that is not registered, is a no-op.
- On a Star-activated DEX, a normal order has the caller address looked up in the allowlist. A reduce-only order bypasses that check.
0/1/2atrecord + 0x78,DeployerState.disabled_state, and thedisabled/reduceOnly/enabledenum on a separate path are three different concepts.
Stars in one diagram
Put the update path and the order-admission path together, and Stars comes out like this.
Deployer action { type: "perpDeploy", star: { dex, operation } } | v +-------------------+ | Star operation | +-------------------+ | | | | | +---- pa: order / cancel / sendAsset | | | +---- modifyApprovals | [[address, true|false], ...] | | | v | calculate delta and final count | | | final_count <= 10,000 | | | +------+------+ | | | | true false | | | | insert remove | +------v------+ | BTreeSet<Address20> | +---- activate: ss=None -> ss=Some(empty set)
User order | +---- reduceOnly == true ---------------------> accept | v Star active? | +---- no -------------------------------------> normal path | v caller address in BTreeSet? | +---- yes ------------------------------------> accept | +---- no -------------------------------------> User requires approvalWhat matters here is that there is no address -> bool value on the stored side. The bool only states the direction of a change instruction; once it has been applied, nothing but approved addresses remains in the state.
What Stars does
Stars is the machinery for running a DEX created through HIP-3 as an address allowlist. On testnet a BTC Star DEX named ktob can be confirmed, and its public transactions carry a sequence of register, Star activate, trader approve, unhalt, a failed unapproved order, and a successful approved order. Observed on testnet
Outside reports say that an unapproved address can still deposit and can still do reduce-only/close. That is consistent with an implementation which, on the order-processing side, bypasses the allowlist check when the caller’s reduceOnly flag is set. A report on Stars and its cap
The current official HIP-3 Docs explain registerAsset, the oracle, margin, the deployer fee and so on, but Stars, modifyApprovals and the internal state’s ss.a are not listed in the public schema. Official HIP-3 deployer actions, Official HIP-3 proposal
What was analysed
The target is hl-node, compiled from Rust with its debug symbols removed.
path: /home/dev/hl-nodeversion: 6df83ed3250019f0cf5286b9ef214493d12ce157build time: 2026-08-28 11:25:59 +0000sha256: 9004d9c4c425e003dc657af542862eda67d9e3bfb936459f483e13c00e00c26compiler: rustc 1.95.0 (59807616e 2026-04-14)With Ghidra and IDA, strings originating from Rust/serde, generated parsers and serializers, jump tables, writes into state, and BTree helpers were cross-checked against one another. Transactions from the testnet explorer, together with a snapshot turned into JSON by the official node’s translate-abci-state, were also used to confirm the wire and storage representations.
Because the binary is stripped, the sub_... and FUN_... names in this post are names the analysis tools assigned. The meanings do not come from the function names; they rest on agreement between call chains, comparison widths, field accesses, embedded strings and runtime data.
The shape of the action
FUN_02a854c0 is the JSON object-key parser for Hip3DeployAction. It recognises star as a key of length 4 and produces the internal tag 0x10.
Hip3DeployAction::Star tag = 0x10 ├─ dex └─ operation: Hip3StarOperation ├─ activate tag = 0 ├─ modifyApprovals tag = 1 │ └─ [[20-byte address, bool], ...] └─ pa tag = 2The object-key parsers for Hip3StarOperation sit in FUN_01d7cdf0 and FUN_01dcce70, and the following correspondence matches in both.
| JSON key | Internal tag |
|---|---|
activate |
0 |
modifyApprovals |
1 |
pa |
2 |
From case 16 of the generated serializer/parser and from txDetails in the testnet explorer, the fields of the outer payload are settled as dex and operation. Real examples follow.
{"type":"perpDeploy","star":{"dex":"ktob","operation":"activate"}}{"type":"perpDeploy","star":{"dex":"ktob","operation":{"modifyApprovals":[["0xd8cb8d9747f50be8e423c698f9104ee090540961",true]]}}}The input format of modifyApprovals
The array path in FUN_02b54330 enters FUN_02bbfd90, which calls FUN_01d61010 for each element.
FUN_01dea8e0: decodes a 20-byte address from a JSON stringFUN_01deae80: parses a JSON boolean, makingfalse0 andtrue1FUN_01d4ba40: handles the tuple’s brackets, commas and iterator
The inner shape is as follows.
"modifyApprovals": [ ["0x<40 hex characters>", true], ["0x<40 hex characters>", false]]This collection is not “the whole of the current approval state”; it is a set of changes. It checks whether each address is already in the current set, and applies only the additions and removals that are needed.
The stored side is a BTreeSet<Address20>
The first pass of the analysis read a region inside the node as a bool paired with the key, and took it for a BTreeMap<Address, bool>. Cross-checking further against the serializer, the iterator, the insert/remove helpers and the snapshot showed that the stored side is a BTreeSet whose elements are 20-byte addresses and nothing else.
| Observation | Interpretation |
|---|---|
key width 0x14 |
a 20-byte address |
key slot stride 0x14 |
a set element carrying no value |
node +0xe6 |
the number of keys in the node (16-bit) |
header +0x00 |
root pointer |
header +0x08 |
tree depth/height |
header +0x10 |
element count |
sub_27612C0 removes a 20-byte key, and sub_2854440 adds a key of the same width. There is no per-entry bool in the stored node. The bool carried in modifyApprovals is an input value the handler uses to choose between remove and insert, and it is gone once the update is applied.
ss.a in the serialized state
The generated codec for MainOrHip3::Hip3 treats limits, schema, state, ss and df as separate fields. Following the dedicated serializer for ss, the Star state the current node produces and stores takes at least the following shape.
Hip3StarState { a: BTreeSet<Address20>}sub_3D50CC0 emits a as a JSON object key and passes its value to the collection serializer for 20-byte addresses. The binary/RMP serializer emits the same key.
Turning a testnet snapshot into JSON with the official node’s translate-abci-state then showed the following structure present as real data in the ktob record.
{ "moh": { "Hip3": { "schema": { "name": "ktob", "full_name": "BTC Star DEX" }, "ss": { "a": [ "0xd8cb8d9747f50be8e423c698f9104ee090540961" ] } } }}So ss.a being the set of approved addresses in the serialized clearinghouse state could be confirmed with high confidence. Whether a goes by some other name in the Rust source, such as approved_users or allowlist, is not something a stripped binary can tell.
perpDexs, meta and metaAndAssetCtxs, as of this investigation, also return neither ss nor a. Existing in a snapshot or in storage, and being exposed through a documented public API, are two different things.
Approval updates and the 10,000 cap
sub_24B01B0 is the centre of an approval update. For each address in the input, it counts the real difference against the current set.
for (address, requested_flag) in modifications: current = approvals.contains(address)
if requested_flag == true and current == false: new_approvals += 1
if requested_flag == false and current == true: revocations += 1Setting an already-approved address to true again does not count as a new addition. Setting an address that does not exist to false does not count as a removal.
Having checked that the number of entries on the input side does not exceed 10,000, the handler then evaluates the following condition.
current_count + new_approvals <= 10000 + revocationsThis is equivalent to the set holding 10,000 elements or fewer once the change has been applied.
| Current count | New additions | Removals | Verdict |
|---|---|---|---|
| 9,999 | 1 | 0 | allowed |
| 10,000 | 1 | 1 | allowed |
| 10,000 | 1 | 0 | rejected |
The embedded string for going over the cap is as follows.
too many approved usersInside the cap, false addresses are removed with sub_27612C0 and true addresses are inserted with sub_2854440.
What activate initialises
Operation tag 0 of sub_24B01B0 — that is, activate — performs the following writes when record + 0x78 of the target DEX is in the not-yet-activated state.
record + 0x78 = 1record + 0x80 = 0 // BTree rootrecord + 0x90 = 0 // BTree lengthThis is not a routine that clears an existing allowlist. While record + 0x78 == 0, everything from +0x80 onwards is not a valid BTree header but an inactive region overlapped by a union. activate switches a representation equivalent to ss=None over to one equivalent to ss=Some(empty BTreeSet), and thereby establishes the representation invariant of a set.
If record + 0x78 == 1 already, the initialisation branch is not taken and the existing tree is preserved. activate is therefore idempotent in effect. And because modifyApprovals before activate is rejected with Not activated, the ordinary sequence looks like this.
registerAsset2 -> star.activate -> star.modifyApprovalsOn testnet’s ktob too, transactions that succeeded in this order can be confirmed.
The allowlist check on the order-admission side
Beyond the update handler, the call chain on the order-processing side could be identified as well.
sub_2482980 -> sub_29994D0 -> sub_21AE390sub_21AE390 consults the tag of the selected DEX record and the BTree at record + 0x80. On a Star-activated named HIP-3 DEX, it looks the caller’s 20-byte address up in the set.
- a match: success
- no root, or no match:
User requires approval - the caller’s
reduceOnlyflag is set: the approval check is bypassed
This confirms not only that the allowlist exists on the state-update side, but that it is actually used in the admission control for normal orders.
The reduce-only bypass matters. Stars is not a mechanism that freezes every operation of an unapproved address; it restricts the normal orders that would add new risk, while leaving open a path for shrinking an existing position.
Why record + 0x78 is not called a three-valued mode
record + 0x78 is not a plain disabled / reduceOnly / enabled enum. Going by the behaviour of the implementation, the safe way to organise it for now is as follows.
| Value | Observed behaviour | Provisional meaning |
|---|---|---|
0 |
Star mutations are rejected as not activated. No approval check runs on the order side | HIP-3, ss=None |
1 |
The BTree in the ss payload is treated as valid, and normal orders are checked |
HIP-3, ss=Some |
2 |
Excluded from Star mutations, and no approval is needed on the order side | Main / validator-operated DEX |
This position is thought to be the region where the MainOrHip3 union overlaps the optional ss discriminant on the Hip3 side. 0/1 are most likely the Star activation state on the Hip3 side, and 2 the tag/niche of the Main variant.
The named state field, by contrast, sits separately at record + 0x128 and is serialized as a DeployerState with 5 fields.
DeployerState ├─ n_reserve_deployments_used ├─ last_settlement_time ├─ deploy_time ├─ disabled_state └─ mdisabled_state has 4 variants: Na, UserDisabled, ValidatorDisabled and m. A separate vote/global action path carries a three-valued enum of disabled, reduceOnly and enabled as well. But there is no basis for mapping any of these onto record + 0x78 or onto the Star activation gate.
These three, in other words, have to be handled separately.
record + 0x78 Star/MainOrHip3 operational tagDeployerState.disabled_state a separate field inside the DEX deployer statedisabled/reduceOnly/enabled a different generated/vote action enumThe pa proxy action
FUN_01d7dd50 and FUN_01dcee00 are the Hip3StarProxyAction parser, and pa carries a proxyAction wrapper.
| JSON key | Tag |
|---|---|
cancel |
0 |
order |
1 |
sendAsset |
2 |
The payloads that could be confirmed are as follows.
{"pa":{"proxyAction":{"order":{"orders":[...],"grouping":"na","builder":{"b":"0x...","f":...}}}}}{"pa":{"proxyAction":{"cancel":{"cancels":[...]}}}}{"pa":{"proxyAction":{"sendAsset":{"destination":"0x...","amount":"..."}}}}builder is optional, and grouping has at least na, normalTpsl and positionTpsl.
Where the signed L1 action fits
A Star is an L1 action that uses sign_l1_action, and an ordinary deployer action is sent to /exchange with vaultAddress=null and expiresAfter=null.
action_hash = keccak256( msgpack.packb(action) || nonce.to_bytes(8, "big") || 0x00 // vaultAddress is None [|| 0x00 || expiresAfter(8)] // if expiresAfter is present)On testnet it is signed under the Exchange EIP-712 domain as a phantom agent with source b, and r, s and v go into the envelope. As with any ordinary Hyperliquid L1 action, field order and number representation affect the hash. Official Signing, Official Nonces
The explorer’s display of past transactions, however, does not return the actual nonce or signature. A complete signed envelope for a known Star transaction has therefore not been reconstructed.
The main functions confirmed
| Address | Role |
|---|---|
0x02a854c0 |
Hip3DeployAction key parser. star -> tag 0x10 |
0x02aa6df0 |
Star operation parser |
0x02bbfd90 |
address/bool collection parser for modifyApprovals |
0x025b01b0 |
validation and application handler for Star operations |
0x027612c0 |
approval BTreeSet remove |
0x02854440 |
approval BTreeSet insert |
0x03d50cc0 |
Hip3StarState JSON serializer. key a |
0x03d51070 |
Hip3StarState binary/RMP serializer. key a |
0x03b110f0 |
JSON serializer for the approval address collection |
0x03e58e90 |
JSON serializer for a 20-byte address |
0x021ae390 |
approval lookup in order admission |
0x03d282f0 |
DEX registry record lookup |
0x03c51d70 |
DEX record constructor |
0x024a9d50 |
outer perp-deploy action dispatcher |
0x01d7dd50 |
Hip3StarProxyAction parser |
The distance between the public spec and the implementation
The Star action, ss.a and the approval lookup confirmed here do exist in the current binary and in testnet state. They are not, on the other hand, listed yet in the HIP-3 action schema in the official Docs.
That gap covers possibilities such as the implementation landing on testnet ahead of a Docs update, the surface being treated as experimental, or the binary and the Docs not being at the same version. Absence from the Docs, on its own, cannot establish that this is a private feature or a future specification.
Nor is an internal field stored in a snapshot necessarily a public API contract. ss.a can be confirmed in the current serialized state, but it cannot be fetched from a documented info endpoint. Depending on the field names of internal state as though they were a schema for external clients is dangerous.
What is still unknown
- The complete signed envelope, including the real nonce and
r/s/v, of past Star transactions. - The exact type alias and semantic field name of
Hip3StarState.ain the Rust source. - How widely the approval set is exposed, undocumented endpoints included.
- The public-specification account, and the atomicity, of the representation initialisation
activateperforms. - The source-level assignment by which
MainOrHip3state is installed into the live runtime view on restart. - The clone/revert boundary for an error in an individual transaction.
- The final state commit function after an
AllowHip3GrowthModevote passes.
From the handler up to the action dispatcher, work state is updated in place directly. Further out, a block-level clone, apply and commit boundary was confirmed to exist. That on its own, however, is not enough to settle rollback semantics at the level of an individual transaction.
Summary
The heart of Stars comes down to these three points.
1. state ss.a = BTreeSet<Address20>
2. mutation modifyApprovals = [(address, true|false), ...] true -> add false -> remove final size <= 10,000
3. admission normal order -> check address membership reduce-only order -> bypass the approval checkStars is therefore not a feature that flips a market’s tradability with a single boolean. It is a market-level policy that holds a set of approved addresses in market state and combines incremental updates, a cap on the count, activation, and a membership test at order admission.
The BTreeMap<Address, bool> the first pass of the analysis had in mind, and the “reduce-only transition that clears the existing tree on activate”, were corrected by following the serializer, the snapshot and the order-processing side through. Separating the input bool from the stored state, and Star activation from the other mode enums, is what matters in reading this implementation correctly.
This post does not recommend any particular trade, any leverage, or the sending of any signed action. Hyperliquid’s specification, node binary, API and testnet state can all change. If you reproduce this, check the version and SHA-256 of the target binary, and the latest official Docs.