If your HyperEVM contract settles, lends, liquidates or mints against Hyperliquid's prices, one
wallet can move those prices and take the difference. On 2026-10-10 a wallet spent $130,873 on
Hyperliquid pushing ETH's book and was paid $1.41M by Papertrade, an exchange that settled at
HL's mid: $1.28M net (cases/papertrade-20261010, rebuilt from public data).
The obvious fixes did not hold up when we replayed it on the same 228 positions:
| what the exchange could have done | what the positions take in the replay |
|---|---|
| nothing: settle at HL's best bid/ask mid | $2,052,046 |
| settle at HL's oracle instead (it moved with the push) | $1,738,919, only 15% less |
| refuse closes when the mid is over 15 bp from the oracle | refuses 0 of 228 |
cap notional settled per minute at $3.3M (ExposureBudget.Rolling) |
about $55,101 (estimate, below), under the $130,873 the push cost |
Take is notional times the price move each rule settles at. The cap row is not a replay of
ExposureBudget.Rolling: it is the incident's average edge times $3.3M times the 18 minutes with
closes, an estimate from this one incident and not a bound (replay_c.py, docs/safe-limits.md). The other
failure is the oracle itself: on 2026-07-27 a HIP-3 deployer's oracle for xyz:SKHX fell 15.6% in one
update, then stepped down 1% at a time. Comparing each read with the previous one let 39 prices
under $1,000 through; the jump latch let none (cases/skhx-20260727).
This library is those defenses as Solidity you call: read the oracle and refuse it when the book or mark disagrees, check it against an independent price, latch on jumps, and cap what one price can settle. Unaudited, and not for production funds yet (Status). Part of Fabrknt for HIP-3 (product page); built for Hyperliquid, not affiliated with it.
Try it in a minute, with no key and no funds:
git clone --recurse-submodules https://github.com/fabrknt/hl-price-guard && cd hl-price-guard
forge test --match-contract Quickstart -vvnormal book: accepted at $2427.23
pushed book: rejected, mid vs oracle 20 bp, limit 15
The second book is pushed 20 bp before the oracle has moved: the case the mid check catches. In Papertrade itself the mid moved the attacker's way in 16 of 18 rounds (up to about 18 bp), and in each of those 16 the oracle moved the same way too (mid–oracle gap at close at most 10.2 bp), which is why that incident needs the cap in the table above as well.
Use it in your own Foundry project:
forge install fabrknt/hl-price-guard@v0.3.1
echo 'hl-price-guard/=lib/hl-price-guard/src/' >> remappings.txtThe second line pins where hl-price-guard/ imports resolve. Without it, Foundry guesses from the
repo's layout and can pick the repo root (it did with v0.3.0 and forge 1.7.1), and the imports
below are not found.
import {HLPriceGuard} from "hl-price-guard/HLPriceGuard.sol";
contract MyMarket {
HLPriceGuard.Asset ETH = HLPriceGuard.Asset({dex: 0, index: 1, szDecimals: 4});
HLPriceGuard.Bounds BOUNDS =
HLPriceGuard.Bounds({maxMarkOracleBps: 15, maxMidOracleBps: 15, maxSpreadBps: 10, maxRefOracleBps: 0});
function price() public view returns (uint256) {
// The oracle (1e18), or a revert PriceRejected(reason, observedBps, limitBps).
return HLPriceGuard.read(ETH, BOUNDS, HLPriceGuard.Ref(0, 0, 0));
}
}Test it locally with HLTest, which puts mock precompiles at their real addresses (forge's fork
mode cannot run them) and lets you set prices in 1e18:
import {HLTest} from "hl-price-guard/test/HLTest.sol";
import {HLPriceGuard} from "hl-price-guard/HLPriceGuard.sol";
contract MyMarketTest is HLTest {
function setUp() public { installHL(); }
function test_pushedBook() public {
MyMarket m = new MyMarket();
setBook(ETH, 2427.23e18, 2428.00e18, 2432.00e18, 2432.10e18); // oracle, mark, bid, ask
vm.expectRevert(
abi.encodeWithSelector(HLPriceGuard.PriceRejected.selector, HLPriceGuard.Breach.MidOracle, 20, 15)
);
m.price();
}
}The same contract and test are examples/GuardedMarket.sol and test/Quickstart.t.sol.
Already testing with hyper-evm-lib's
simulator? Its simulator forks mainnet and lets you move the mark (CoreSimulatorLib.setMarkPx),
but it cannot set the oracle (we proposed setOraclePx:
hyper-evm-lib#38) and does not model the book: BBO reads go to mainnet. Call
installBboOnly() after CoreSimulatorLib.init() and set the book with setBbo; the simulator
keeps the oracle and mark. examples/with-hyper-evm-lib/GuardedMarketSim.t.sol plays a calm book,
the simulator's own +20% mark move, and a book pushed 60 bp against ETH's live oracle. It is
not compiled here; copy it into a project with both libraries (checked with hyper-evm-lib
4eb7ab0). It needs network access. The simulator reads the oracle live on every call, so a test
can see it move between reads (the example leaves room for that), and the public RPC sometimes
answers "invalid block height": rerun. installBboOnly replaces the book for every asset: set each
one your contract reads with setBbo, or its BBO read reverts.
read is a deviation check: it rejects a book or mark that has moved away from the oracle. It does
not stop a push that moves the oracle too (Papertrade's later rounds), or a HIP-3 deployer moving
their own oracle. For that, add an independent reference, the jump latch and a cap, as
test/Integration.t.sol does; the table below says which piece covers what.
| I want to… | Use |
|---|---|
| read the oracle, reverting when the book or mark has moved away from it | HLPriceGuard.read |
| know why, without reverting (monitoring, pausing) | HLPriceGuard.check |
| check against an independent price | PythRef + maxRefOracleBps |
| use Pyth alone, reverting when its price is stale | PythRef.readFresh / updateAndReadFresh |
| take the price at a set time, from a print no caller can choose | PythRef.readFirstAfter |
| reject an oracle that jumps, and every small step after | HLPriceGuard.observe (JumpLatch) |
| cap how much settles against one price per minute | ExposureBudget.Rolling (cap sizes: docs/safe-limits.md §4, first estimates from one incident) |
| liquidate without a wide book holding it back | HLPriceGuard.lenientPrice |
| test my contract locally | HLTest |
| test inside hyper-evm-lib's simulator | HLTest.installBboOnly + setBbo |
Bounds and caps per asset: docs/safe-limits.md. Pin a tag, not main (see Versions).
Scope: reading prices, detecting deviation (BBO, mark), an independent reference, a jump latch, and exposure caps. Nothing else: pools, settlement queues, liquidation and UI belong to the apps that use it (for example Day One, below), and nothing in this repo depends on any of them.
HyperEVM contracts can read HyperCore prices through precompiles (how a node exposes its own state to contracts this way is built step by step in Build OpenHL Precompiles, on Reth):
| Precompile | Price | Set by | How hard to move |
|---|---|---|---|
0x…807 |
oracle | validators, from external venues (HIP-3: the deployer) | harder, but a push on HL reached it for ETH (cases/papertrade-20261010) |
0x…806 |
mark | oracle combined with the HL book | medium |
0x…80e |
best bid / ask (BBO) | the HL book itself | easy |
Index encodings differ between precompiles (checked on mainnet, 2026-10-17):
0x807oracle takesdex * 10000 + index(xyz:TSLA =10001); the API's asset ID110001errors.0x80eBBO takes the API asset ID100000 + dex * 10000 + index(xyz:TSLA =110001).
src/: a Solidity library to read HL prices safely.HLPriceGuard: returns the oracle and rejects it when the BBO, mark, an independent reference or the last stored price disagree beyond set bounds.lenientPricegives liquidations the oracle or the mark, whichever favours the position, andoracleOnlythe oracle with no tripwires; both revert on a 0 price (docs/safe-limits.md §5).PythRef: Pyth on HyperEVM as the independent reference (it must be updated in the same tx, with update data from Pyth's Hermes, which needs an API key). Without an HL quote, usereadFresh/updateAndReadFresh: they revert withPythStalewhenpublishTimeis more thanmaxAgefrom the block time, in either direction. When a price at a set time decides who gets paid,readFirstAfter(at, window)takes the first update published at or afterat(Pyth'sparsePriceFeedUpdatesUnique), so the caller cannot pick one print from several.ExposureBudget: caps notional settled against one price per time window.Rolling(use this): no stretch of the window settles more than the cap; cap and window change only throughreconfigure.Window: the older fixed window, which lets up to2 * capthrough across a reset.HLPrecompiles: raw reads and the two index encodings.test/HLTest: a base for testing your own contract locally, with mock precompiles at their real addresses and prices set in 1e18 (setBook) or in the precompiles' own units (setBookRaw, onmain, not yet tagged).- Apps built on it live in their own repos. One is Day One — Fabrknt Labs (
fabrknt/dayone, private for now), a research launch pool. It uses this library as a git submodule (see Versions).
examples/GuardedMarket.sol: the smallest contract using the library (tested intest/Quickstart.t.sol).examples/with-hyper-evm-lib/: the same contract tested inside hyper-evm-lib's simulator.docs/safe-limits.md: which price to use, how to set the bounds, how to size the cap, and what the cap does not cover (settlements at a single instant; a HIP-3 deployer's oracle key); then liquidations, prices at a set time (including time-averages of HIP-3 oracles that are built from their own book) and testnet, learned from apps built on the library.docs/c-launch-pool-design.md: points to Day One, where the launch-pool design now lives.cases/: incidents rebuilt from public data. Each is replayed under rules built from this library (oracle instead of BBO, deviation tripwire, exposure cap), plus the two-step settlement Day One adds.- Papertrade, 2026-10-10: one wallet moved HL's ETH mid and settled 500x positions against it. The Binance comparison fetches Binance's public klines; save them once to rerun offline.
- xyz:SKHX, 2026-07-27: the deployer's oracle fell 15.6% in one update, then stepped down.
measure/:normal_gaps.pysamples the gaps the guard checks;safe_caps.pyturns book depth into a per-minute exposure cap per asset (calibrated on Papertrade, one incident: a first estimate). Both save as they go;safe_caps.py summary OUT.jsonre-prints per UTC hour.session.sh START END OUTruns both over a UTC window;.github/workflows/measure-us-session.ymlran it on GitHub through the US session of 2026-10-19, started on time bydispatch-at.ymlbecause this repository's scheduled runs start hours late. The data arenormal-gaps-20261019.jsonandsafe-caps-20261019.json;session_tables.pyre-prints the tables indocs/safe-limits.mdfrom them.round_trips_collect.pyrecords trades (with wallets), best bid/offer and books;round_trips.pypicks out single-wallet push-and-unwind round trips and compares their cost with the book-depth prediction, to test the Papertrade calibration on more than one incident.round_trips_summary.pycombines many runs' results; the runs through 2026-11-03 are inround-trips-20261017-20261103.jsonl(withround-trips-covered-20261017-20261103.json, the stretches collected, andround-trips-runs-20261017-20261103.txt, the workflow run IDs)..github/workflows/collect-round-trips.ymlruns both every 6 hours (it skips itself if the repository is private) and keeps the round trips found as artifacts for 30 days.settle_window.pyrecords every oracle on the core, xyz andiodexes and a dozen books about every 10 s (backing off up to 2 minutes after a failed sweep; a request that fails leaves a gap in that row) around a fixed-time settlement (a TWAP ending 20:00 ET, say).settle_session.sh START END OUTruns it over a UTC window,.github/workflows/settle-window.ymlon GitHub, whichdispatch-at.ymlstarts at a set time withtarget=settle-window. A run that records nothing, or starts afterEND, fails; one that starts late but beforeENDrecords what is left. Data files are named by the JST date they were taken; their timestamps are UTC.
Projects of ours that depend on it through a pinned tag, the way an outside app would:
- hyperevm-lend: borrowing and collateral withdrawals go
through
HLPriceGuard.read; liquidation picks the higher of oracle and mark itself (the patternlenientPricewas later added for). Its tests set prices withHLTest. - hyperevm-perp: settles through
read; liquidation picks oracle or mark itself, as in hyperevm-lend. Tests onHLTest. - Day One (
fabrknt/dayone, private): a launch pool onquoteandcheck, the jump latch (observe,rearm) andExposureBudget.Rolling. Tests onHLTest. - Summon (
psyto/summon-stocks, private for now): backer-funded rounds for tokenized stocks on Base Sepolia, priced from Pyth alone throughPythRef.readFresh, which was added for it inv0.3.2(Summon needed a staleness check without an HL quote), and fills throughPythRef.readFirstAfter, added for it inv0.4.0(a review found that a filler could choose among several fresh prints).
All three moved from v0.2.1 to v0.3.0 on 2026-11-04 with no change to their contracts and all
their tests passing. hyperevm-lend and hyperevm-perp hold their test prices in the precompiles' own
units and had to convert them for setBook, which is where setBookRaw came from; both moved to
v0.3.1 the same day and now pass those units straight to setBookRaw. Day One kept its raw .set
calls on HLMockPrecompile and stays on v0.3.0.
Not for production funds. The library is unaudited and its caps are first estimates from little data; use it for research, test deployments and review.
- Measured so far: normal gaps and book depth through one full US session (2026-10-19), plus a
quiet Friday-evening sample of each (2026-10-16). The caps in
docs/safe-limits.mdstill rest on the 0.27 calibration from one incident, so they remain first estimates. - Running: round trips are collected every 6 hours to test that calibration. Through 2026-11-03
(59 runs, 314 hours) 7 of 321 candidates passed the strict filters, all 5 to 14 bp pushes with a
predicted cost of $10 to $87 and ratios from -11 to 15; the 14 pushes of $1M or more were all
filtered out, mostly because they moved past the recorded book or unwound outside 20 to 120 s.
0.27 is still one incident's number; how to change the measurement is in
docs/safe-limits.md("MeasuringCper asset"). - Scheduled: oracles and books from 19:00 to 20:05 ET on Friday 2026-11-06 (xyz equities still priced from outside venues until 20:00), Saturday 11-07 and Sunday 11-08 (priced from their own book). The last 11 minutes of the window ending 20:00 ET on Wednesday 2026-11-04 were recorded locally for comparison.
- Not measured: volatile sessions; weekend depth for the caps in
docs/safe-limits.md. - Not done: a cost for pushes shorter than a minute; an audit.
Tagged vX.Y.Z. Apps should pin a tag (as a git submodule at that tag) and move to a new tag on
purpose, never to the latest commit. hyperevm-lend and hyperevm-perp pin v0.3.1, Day One v0.3.0, Summon v0.3.2 (Used in).
v0.1.0(b596268):HLPriceGuard,PythRef,JumpLatch,ExposureBudget(fixed window).v0.2.0(d996453): addsExposureBudget.Rolling, a cap that no stretch of the window can exceed, andreconfigurethat waits on the last spend. Do not pin: a spend at timestamp 0 was treated as "never spent", soreconfigureskipped the wait.v0.2.1(f18cdd4): the same, with that fix. The first version safe to pin forRolling; new projects should pin the latest tag.v0.3.0(b343536):szDecimalsover 6 reverts (BadSzDecimals; it used to mis-scale, and overflow from 46);lenientPrice,oracleOnlyandNoPrice;test/HLTestfor testing apps locally, alone or inside hyper-evm-lib's simulator (installBboOnly,setBbo); source files carry the Apache-2.0 SPDX identifier (they said UNLICENSED).v0.3.1(f151b2d): addsHLTest.setBookRaw, for tests that hold prices in the precompiles' own units (as hyperevm-lend and hyperevm-perp do). No change to the library contracts.v0.3.2(e82819a): addsPythRef.readFreshandupdateAndReadFresh, for contracts that read Pyth without an HL quote (readleaves staleness toHLPriceGuard.check, which needs one). No change to existing functions.v0.4.0(4d6a832): addsPythRef.readFirstAfter(the first Pyth update at or after a set time, throughparsePriceFeedUpdatesUnique) andPythRef.toRef;IPythgainsPriceFeedandparsePriceFeedUpdatesUnique.readnow callstoRef, with the same result. A minor version becauseIPythgrew: a contract that implements it must add the new function.
forge test. Precompiles are mocked at their real addresses with mainnet values (HLTest, the
same mock apps use): forge's fork mode cannot run HyperEVM's custom precompiles. test/Live.t.sol checks what the mocks assume
against mainnet: it fetches real precompile output over the public RPC and decodes it with
HLPrecompiles for BTC, ETH, xyz:TSLA and the L1 block number, and reads HyperEVM's Pyth
through PythRef (HL_LIVE=true forge test --match-contract Live). Its bounds are loose: it
catches a wrong encoding or scale, not a market anomaly. CI (.github/workflows/test.yml) runs
the suite on pushes to main and pull requests, and the live check then and daily.
test/HLPriceGuard.t.sol: each check against mainnet values and the two incidents;lenientPriceandoracleOnly(including a 0 price), andszDecimalsover 6 reverting.ExposureBudget.Rollinghas two fuzz tests (with and withoutreconfigure) that check every admitted spend against the cap and window it was admitted under.test/Properties.t.sol: fuzz tests thatchecknever passes a price outside an enabled bound and never reports a breach within one, thatdiffBpsrounds up, and that the jump latch moves its anchor only on an accepted read, thatlenientPricereturns one of the two feeds and the one on the position's side;PythRef.updateAndReadpaying the fee and reading the update;PythRef.readFirstAftertaking the first update after the time and refusing a later print, one past the window, or a wide confidence.test/Quickstart.t.sol:examples/GuardedMarket.solon a normal ETH book (accepted) and on Papertrade's pushed book (rejected), throughHLTest; shown to fail with the mid check turned off. The Quickstart's install steps were run in a fresh Foundry project (forge installofv0.3.0from GitHub, forge 1.7.1) and the README test passed there; repeated forv0.3.1with asetBookRawtest.examples/with-hyper-evm-lib/GuardedMarketSim.t.solis not run here (it needs hyper-evm-lib and mainnet). It was run in a project with both libraries (hyper-evm-lib4eb7ab0, 2026-11-03): 3 passed, and the mark test failed with the mark check turned off.test/Integration.t.sol: an example settlement contract wiring Pyth, the tripwires, the latch and a rolling cap the way docs/safe-limits.md recommends, run through Papertrade- and SKHX-like moves. A starting point for apps, not part of the library.- Each fuzz test was shown to fail when the check it covers is removed or weakened.
- Not covered: the library running on HyperEVM itself (it runs locally; only what the precompiles and Pyth return is fetched from HyperEVM), and pushes shorter than a minute.
Apache-2.0 (see LICENSE). Not audited.