Skip to content

Commit 488f3e4

Browse files
committed
feat(oracle): V-11 gets a mechanism, and the field turns out to be one candidate
Went looking for a launch-anchor price source and found the field is far narrower than expected. All three leads measured, not assumed: HyperCore spotPx reads HIP-1 spot markets. xStocks are EVM-native with no HIP-1 linkage (V-02), so there is nothing to read. HyperCore oraclePx prices perps. Scanned all 233 perp assets: no equity. The only equity-shaped ticker is SPX at index 171, which reads ~$0.60 and is the SPX6900 memecoin. HyperSwap pools none, for any xStock, at any tier (V-22). So Pyth is the only price source that exists on this chain. It is genuinely deployed - v1.4.6 at 0xe9d6..., 60s valid period - and its crypto feeds are maintained: HYPE 183s old, BTC 2101s. Its EQUITY feeds are present and abandoned: TSLA 63 days, NVDA 71, SPY 561. The 24/7 variants were never published here at all. Present and wrong is worse than absent. getPriceUnsafe would hand back a two-month-old TSLA without complaint, and S402 fixes p0 for a market's life. PythAggregatorShim presents one Pyth feed as a Chainlink round, so ReferencePriceAdapter consumes it UNCHANGED. That shape is the point: a Pyth-native adapter would mean rewriting every S135 refusal - stale, zero, negative, out-of-band, unreadable, wrong decimals - against a different interface, and the second copy is the one that gets a check wrong. Two tests drive a Pyth price through the real adapter to prove the reuse is real. It reads through getPriceNoOlderThan, so an abandoned feed REVERTS rather than answering. The fork suite asserts that against the live contract, and asserts separately that a crypto feed IS fresh there - the two together distinguish "Pyth is broken on this chain" from "nobody pays to publish equities on it", which are different problems and only the second is ours. Immutable feed id, immutable maxAge, no owner, no setters. The adapter trusts this contract by address, so a repointable shim would be an admin path to changing what a market's anchor means. What is left is operational, and recorded as such: somebody must publish equity prices here, and Pyth's public update API now returns 401 on every endpoint tried - only feed metadata answers anonymously. 305 -> 322 contract tests, 14 fork tests against the real chain.
1 parent 0115cbb commit 488f3e4

8 files changed

Lines changed: 770 additions & 3 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
pragma solidity 0.8.28;
3+
4+
import {IPyth, PythStructs} from "./interfaces/IPyth.sol";
5+
6+
/**
7+
* @title PythAggregatorShim
8+
* @notice One Pyth price feed, speaking Chainlink's `latestRoundData`.
9+
*
10+
* WHY A SHIM AND NOT A SECOND ADAPTER
11+
* -----------------------------------
12+
* `ReferencePriceAdapter` is the launch anchor, and it already refuses a stale
13+
* answer, a zero, a negative, one outside its configured band, one from a feed
14+
* that will not answer at all, and one whose decimals it cannot read. Those
15+
* refusals are §135's, they are tested, and they are the reason a launch is
16+
* blocked rather than mispriced.
17+
*
18+
* Writing a Pyth-native adapter would mean writing all of that again against a
19+
* different interface, and the second copy is the one that gets a check wrong.
20+
* So this translates instead: Pyth in, `latestRoundData` out, and every existing
21+
* refusal applies unchanged.
22+
*
23+
* WHY PYTH AT ALL (V-11)
24+
* ----------------------
25+
* Measured on HyperEVM rather than assumed:
26+
*
27+
* - Pyth is deployed at 0xe9d69CdD6Fe41e7B621B4A688C5D1a68cB5c8ADc, v1.4.6,
28+
* with a 60-second valid time period.
29+
* - Its crypto feeds are actively maintained there (HYPE was 183 seconds old,
30+
* BTC 2101 seconds, when this was written).
31+
* - Its EQUITY feeds are present but abandoned: TSLA read 63 days stale, NVDA
32+
* 71 days, SPY 561 days. The 24/7 equity variants have never been pushed at
33+
* all and revert.
34+
*
35+
* Nothing else on this chain prices an xStock. There is no HyperSwap pool for
36+
* any of them (V-22) and no HyperCore spot market, because they are EVM-native
37+
* and were never HIP-1 linked (V-02). Pyth is the only source that exists.
38+
*
39+
* THE STALENESS IS THE WHOLE POINT, NOT A PROBLEM TO ROUTE AROUND
40+
* ---------------------------------------------------------------
41+
* Pyth is pull-based by design. A price is on-chain only because somebody paid
42+
* to put it there, and "63 days stale" means nobody has, not that the feed is
43+
* broken. The intended pattern is that the transaction needing a price carries
44+
* the signed update with it: `updatePriceFeeds` then read, in one call.
45+
*
46+
* This contract does NOT do that, deliberately. It is a view, and a view that
47+
* could push an update would be a view that costs money and changes state. The
48+
* update belongs in the launch transaction, ahead of the read.
49+
*
50+
* What this contract does instead is REFUSE a stale price rather than return
51+
* one. `maxAge` is immutable, and `getPriceNoOlderThan` reverts rather than
52+
* answering — so a launch against an abandoned feed fails loudly at the
53+
* adapter, which is §135's own preference and the reason the anchor is read
54+
* from a feed rather than supplied by the caller.
55+
*/
56+
contract PythAggregatorShim {
57+
/// @notice The Pyth contract on this chain.
58+
IPyth public immutable PYTH;
59+
60+
/// @notice The feed this shim speaks for. One shim, one feed, immutable.
61+
/// @dev A shim that could be repointed is an admin path to changing what a
62+
/// market's anchor means, on a contract the adapter trusts by address.
63+
bytes32 public immutable PRICE_ID;
64+
65+
/**
66+
* @notice How old a price may be before this refuses to report it.
67+
*
68+
* @dev Immutable, and it is the security parameter.
69+
*
70+
* `ReferencePriceAdapter` has its own staleness bound and would catch a
71+
* stale answer anyway. This one exists because the two are checking
72+
* different things: the adapter bounds how old an ANSWER may be, and
73+
* this bounds what Pyth is willing to call a price at all. Pyth's own
74+
* valid period on HyperEVM is 60 seconds, and a shim that quietly
75+
* reported something older would be laundering a stale reading into a
76+
* well-formed round.
77+
*/
78+
uint256 public immutable MAX_AGE;
79+
80+
/// @notice Chainlink-style decimals. Fixed at 8, and converted to.
81+
/// @dev Pyth reports a per-feed exponent that can differ between feeds and
82+
/// can in principle change. Reporting it raw would make this shim's
83+
/// decimals a moving target for a consumer that reads them once.
84+
uint8 public constant DECIMALS = 8;
85+
86+
error ZeroAddress();
87+
error PriceOutOfRange(int64 price, int32 expo);
88+
89+
constructor(address pyth, bytes32 priceId, uint256 maxAge) {
90+
if (pyth == address(0)) revert ZeroAddress();
91+
if (priceId == bytes32(0)) revert ZeroAddress();
92+
if (maxAge == 0) revert ZeroAddress();
93+
94+
PYTH = IPyth(pyth);
95+
PRICE_ID = priceId;
96+
MAX_AGE = maxAge;
97+
}
98+
99+
function decimals() external pure returns (uint8) {
100+
return DECIMALS;
101+
}
102+
103+
/**
104+
* @notice The latest price, as a Chainlink round.
105+
*
106+
* @dev Reverts when the price is older than `MAX_AGE`, because
107+
* `getPriceNoOlderThan` does. That propagates to the adapter as an
108+
* unreadable feed, which it already treats as a refusal to launch.
109+
*
110+
* `roundId` and `answeredInRound` are the publish time. Pyth has no
111+
* round concept, and inventing a counter would let a consumer believe
112+
* it could compare rounds for ordering. The publish time is the only
113+
* monotonic thing here and is what those fields honestly are.
114+
*/
115+
function latestRoundData()
116+
external
117+
view
118+
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
119+
{
120+
PythStructs.Price memory p = PYTH.getPriceNoOlderThan(PRICE_ID, MAX_AGE);
121+
122+
answer = _toFixed8(p.price, p.expo);
123+
updatedAt = p.publishTime;
124+
startedAt = p.publishTime;
125+
roundId = uint80(p.publishTime);
126+
answeredInRound = roundId;
127+
}
128+
129+
/// @notice The confidence interval, in the same 8-decimal scale.
130+
/// @dev Not part of the Chainlink shape, so the adapter cannot see it. It is
131+
/// exposed because a wide confidence band is exactly the condition an
132+
/// operator would want to look at before enabling an asset, and it
133+
/// would otherwise be invisible from on-chain.
134+
function latestConfidence() external view returns (uint256) {
135+
PythStructs.Price memory p = PYTH.getPriceNoOlderThan(PRICE_ID, MAX_AGE);
136+
return uint256(_toFixed8(int64(p.conf), p.expo));
137+
}
138+
139+
/**
140+
* @dev Pyth's exponent to a fixed 8 decimals.
141+
*
142+
* Pyth reports `price × 10^expo`, with `expo` almost always negative.
143+
* Equity feeds read -5 on this chain and crypto feeds -8, so both
144+
* directions are live and both are handled — a shim that only handled
145+
* the common one would be correct until the first equity feed.
146+
*
147+
* Reverts rather than truncating when the conversion cannot be
148+
* represented. A silently rounded anchor fixes a market's price for
149+
* its entire life, which is the one place a wrong number must not be
150+
* quietly accepted.
151+
*/
152+
function _toFixed8(int64 price, int32 expo) private pure returns (int256) {
153+
if (price <= 0) revert PriceOutOfRange(price, expo);
154+
155+
int256 value = int256(price);
156+
157+
if (expo <= -128 || expo >= 128) revert PriceOutOfRange(price, expo);
158+
159+
int32 shift = expo + 8;
160+
161+
if (shift == 0) return value;
162+
163+
if (shift > 0) {
164+
if (shift > 60) revert PriceOutOfRange(price, expo);
165+
return value * int256(10 ** uint32(shift));
166+
}
167+
168+
uint32 down = uint32(-shift);
169+
if (down > 60) revert PriceOutOfRange(price, expo);
170+
171+
int256 divisor = int256(10 ** down);
172+
int256 scaled = value / divisor;
173+
174+
// A price that rounds to zero is not a price. Returning it would hand
175+
// the adapter a zero, which it refuses anyway — but refusing here says
176+
// which feed did it.
177+
if (scaled == 0) revert PriceOutOfRange(price, expo);
178+
179+
return scaled;
180+
}
181+
}

contracts/src/interfaces/IPyth.sol

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
pragma solidity 0.8.28;
3+
4+
library PythStructs {
5+
/// @dev `value = price × 10^expo`. `expo` is negative in practice — equity
6+
/// feeds on HyperEVM report -5, crypto feeds -8.
7+
struct Price {
8+
int64 price;
9+
uint64 conf;
10+
int32 expo;
11+
uint256 publishTime;
12+
}
13+
}
14+
15+
/**
16+
* @notice The read surface of Pyth, declared narrowly.
17+
*
18+
* `updatePriceFeeds` is deliberately ABSENT. It is payable and changes state,
19+
* and nothing that reads an anchor should be able to reach it — the update
20+
* belongs in the transaction that needs the price, ahead of the read, paid for
21+
* by whoever is launching.
22+
*
23+
* Measured against the deployment at 0xe9d69CdD6Fe41e7B621B4A688C5D1a68cB5c8ADc
24+
* on HyperEVM: Pyth v1.4.6, `getValidTimePeriod()` = 60.
25+
*/
26+
interface IPyth {
27+
/**
28+
* @notice The price, or a revert if it is older than `age` seconds.
29+
*
30+
* @dev Reverting is the behaviour this codebase wants and the reason this
31+
* function is used instead of `getPriceUnsafe`. On HyperEVM the equity
32+
* feeds are present but abandoned — TSLA read 63 days stale — and
33+
* `getPriceUnsafe` would return that number without complaint.
34+
*/
35+
function getPriceNoOlderThan(bytes32 id, uint256 age)
36+
external
37+
view
38+
returns (PythStructs.Price memory price);
39+
40+
/// @notice How old Pyth itself considers acceptable. 60 seconds on HyperEVM.
41+
function getValidTimePeriod() external view returns (uint256);
42+
}

0 commit comments

Comments
 (0)