Skip to content

Commit b65ebb7

Browse files
ahnaguibclaude
andcommitted
Add DexOracle: indirect AMPL/USDC 24h TWAP provider
No direct AMPL/USDC UniswapV2 market exists on mainnet, so this oracle bridges through WETH: AMPL/USDC = (AMPL/WETH) x (WETH/USDC), reporting an 18-decimal TWAP to a MedianOracle on the 24h rebase cadence. update() opens the measurement window right after rebase (via the Orchestrator tx list, gated to the daily update window) and pushReport() closes it ~2h before the next rebase (gated by minReportTimeIntervalSec) so the report ages past the MedianOracle security window. - contracts/DexOracle.sol: per-leg UniswapV2 cumulative-price TWAP with on-chain decimals bridging; direction is a per-leg useToken1Price flag, so no inverse code is needed. unchecked blocks faithfully mirror UniswapV2 by-design accumulator/uint32 wrapping (not expected operationally, but kept so a far-future wrap degrades gracefully instead of reverting). Constructor emits LogBridgeTokens for off-chain inspection (log-only, no revert: the two bridge-side tokens may legitimately be distinct equivalent tokens). - _external/: minimal vendored IUniswapV2Pair + 0.8.4 UniswapV2OracleLibrary. - mocks/: MockUniswapV2Pair, MockERC20Decimals, MockMedianOracle. - test/unit/DexOracle.ts: decimals bridging, chained TWAP, accumulator wraparound, window/min-period gating, and revert paths (12 tests). - scripts/deploy.ts: deploy:dexoracle task with verified mainnet pool defaults; re-reads token0()/token1() on-chain, optionally registers the provider and appends update() to the Orchestrator. Mainnet token orderings verified via eth_call: AMPL/WETH 0xc5be99... token0=WETH token1=AMPL -> price1 (WETH per AMPL) USDC/WETH 0xb4e16d... token0=USDC token1=WETH -> price1 (USDC per WETH) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 701eee2 commit b65ebb7

7 files changed

Lines changed: 956 additions & 0 deletions

File tree

contracts/DexOracle.sol

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
pragma solidity 0.8.4;
3+
4+
import {Ownable} from "./_external/Ownable.sol";
5+
import {SafeMath} from "./_external/SafeMath.sol";
6+
import {IUniswapV2Pair} from "./_external/IUniswapV2Pair.sol";
7+
import {UniswapV2OracleLibrary} from "./_external/UniswapV2OracleLibrary.sol";
8+
9+
interface IERC20Decimals {
10+
function decimals() external view returns (uint8);
11+
}
12+
13+
interface IMedianOracle {
14+
function pushReport(uint256 payload) external;
15+
}
16+
17+
/**
18+
* @title DexOracle
19+
*
20+
* @notice Computes a 24h time-weighted average price (TWAP) for an asset pair
21+
* that has no direct UniswapV2 market by chaining two underlying
22+
* markets, and reports the result to a MedianOracle instance.
23+
*
24+
* For AMPL/USDC the price is bridged through WETH:
25+
*
26+
* AMPL/USDC = (AMPL/WETH) * (WETH/USDC)
27+
*
28+
* - leg1 prices the source asset (AMPL) in the bridge asset (WETH)
29+
* - leg2 prices the bridge asset (WETH) in the quote asset (USDC)
30+
*
31+
* UniswapV2 maintains per-pair price accumulators as UQ112x112 fixed
32+
* point numbers denominated in raw (smallest-unit) reserves. This
33+
* contract bridges that representation into an OUTPUT_DECIMALS (18)
34+
* fixed point decimal price, which is the format MedianOracle expects:
35+
*
36+
* price_18 = (avgRatioUQ112x112 * decimalsFactor) >> 112
37+
* decimalsFactor = 10**(OUTPUT_DECIMALS + baseDecimals - quoteDecimals)
38+
*
39+
* Intended 24h rebase cadence:
40+
* - `update()` is called right after rebase (appended to the
41+
* Orchestrator's transaction list) to open a fresh
42+
* measurement window.
43+
* - `pushReport()` is called ~2h before the next rebase to close the
44+
* window and report the TWAP. The report then ages
45+
* past the MedianOracle report-delay (security) window
46+
* before it is consumed at the following rebase.
47+
*/
48+
contract DexOracle is Ownable {
49+
using SafeMath for uint256;
50+
51+
/// @notice Decimals of the reported price; matches MedianOracle.DECIMALS.
52+
uint256 public constant OUTPUT_DECIMALS = 18;
53+
54+
/// @notice MedianOracle this contract reports to as a registered provider.
55+
IMedianOracle public immutable medianOracle;
56+
57+
/// @notice First leg market: prices the source asset in the bridge asset.
58+
IUniswapV2Pair public immutable pairLeg1;
59+
/// @notice Second leg market: prices the bridge asset in the quote asset.
60+
IUniswapV2Pair public immutable pairLeg2;
61+
62+
/// @dev When true the leg reads price1 (token1 priced in token0),
63+
/// otherwise price0 (token0 priced in token1).
64+
bool public immutable leg1UseToken1Price;
65+
bool public immutable leg2UseToken1Price;
66+
67+
/// @dev 10**(OUTPUT_DECIMALS + baseDecimals - quoteDecimals) per leg, used
68+
/// to convert a raw UQ112x112 reserve ratio into a decimal price.
69+
uint256 public immutable decimalsFactorLeg1;
70+
uint256 public immutable decimalsFactorLeg2;
71+
72+
/// @notice Raw UniswapV2 price cumulatives captured at the last `update()`.
73+
uint256 public priceLeg1CumulativeLast;
74+
uint256 public priceLeg2CumulativeLast;
75+
/// @notice Timestamp (mod 2**32) of the last `update()`. Zero until the
76+
/// first `update()`, which marks the oracle as uninitialized.
77+
uint32 public blockTimestampLast;
78+
79+
/// @notice Minimum measurement window length before a report can be pushed.
80+
uint256 public minReportTimeIntervalSec = 22 hours;
81+
82+
/// @dev Daily cadence and the window (relative to the day) during which
83+
/// `update()` may open a new measurement window. Defaults mirror the
84+
/// policy's rebase window so updates land right after rebase.
85+
uint256 public updateTimeIntervalSec = 1 days;
86+
uint256 public updateWindowOffsetSec = 7200; // 2AM UTC, matches rebase
87+
uint256 public updateWindowLengthSec = 20 minutes;
88+
89+
event LogPriceUpdate(
90+
uint256 priceLeg1Cumulative,
91+
uint256 priceLeg2Cumulative,
92+
uint32 timestamp
93+
);
94+
event LogReportPushed(uint256 price, uint32 timeElapsed);
95+
// Emitted once at construction. The two bridge-side tokens (leg1's quote
96+
// and leg2's base) are expected to represent the same value; `matched` is
97+
// true when they are the exact same address. They may legitimately differ
98+
// (e.g. two equivalent wrapped representations), so this is informational
99+
// only and never reverts.
100+
event LogBridgeTokens(address leg1QuoteToken, address leg2BaseToken, bool matched);
101+
102+
/**
103+
* @param medianOracle_ MedianOracle instance to report to.
104+
* @param pairLeg1_ UniswapV2 pair for the first (source/bridge) leg.
105+
* @param leg1UseToken1Price_ True to read price1 on leg1, false for price0.
106+
* @param pairLeg2_ UniswapV2 pair for the second (bridge/quote) leg.
107+
* @param leg2UseToken1Price_ True to read price1 on leg2, false for price0.
108+
*/
109+
constructor(
110+
address medianOracle_,
111+
address pairLeg1_,
112+
bool leg1UseToken1Price_,
113+
address pairLeg2_,
114+
bool leg2UseToken1Price_
115+
) {
116+
Ownable.initialize(msg.sender);
117+
118+
medianOracle = IMedianOracle(medianOracle_);
119+
120+
pairLeg1 = IUniswapV2Pair(pairLeg1_);
121+
pairLeg2 = IUniswapV2Pair(pairLeg2_);
122+
leg1UseToken1Price = leg1UseToken1Price_;
123+
leg2UseToken1Price = leg2UseToken1Price_;
124+
125+
(address leg1Base, address leg1Quote) = _baseQuote(pairLeg1_, leg1UseToken1Price_);
126+
(address leg2Base, address leg2Quote) = _baseQuote(pairLeg2_, leg2UseToken1Price_);
127+
decimalsFactorLeg1 = _decimalsFactor(leg1Base, leg1Quote);
128+
decimalsFactorLeg2 = _decimalsFactor(leg2Base, leg2Quote);
129+
130+
// The bridge token is leg1's quote (asset AMPL is priced in) and leg2's
131+
// base (asset priced in USDC). Logged for off-chain inspection; the two
132+
// may legitimately be distinct equivalent tokens, so this never reverts.
133+
emit LogBridgeTokens(leg1Quote, leg2Base, leg1Quote == leg2Base);
134+
135+
// blockTimestampLast is left at 0 to mark the oracle as uninitialized.
136+
}
137+
138+
/**
139+
* @notice Opens a fresh measurement window by snapshotting the current
140+
* price cumulatives. Intended to be appended to the Orchestrator's
141+
* transaction list so it runs immediately after each rebase.
142+
* @dev Gated to the daily update window so the measurement window cannot be
143+
* reset off-schedule (which would shorten a subsequent report's TWAP).
144+
*/
145+
function update() external {
146+
require(inUpdateWindow(), "DexOracle: NOT_IN_UPDATE_WINDOW");
147+
148+
(
149+
uint256 leg1Cumulative,
150+
uint256 leg2Cumulative,
151+
uint32 blockTimestamp
152+
) = _currentCumulatives();
153+
priceLeg1CumulativeLast = leg1Cumulative;
154+
priceLeg2CumulativeLast = leg2Cumulative;
155+
blockTimestampLast = blockTimestamp;
156+
157+
emit LogPriceUpdate(leg1Cumulative, leg2Cumulative, blockTimestamp);
158+
}
159+
160+
/**
161+
* @notice Closes the measurement window, computes the chained TWAP and
162+
* reports it to the MedianOracle. Intended to be called ~2h before
163+
* the next rebase, leaving the report to age past the MedianOracle
164+
* report-delay window before it is consumed.
165+
* @return price The reported AMPL/USDC price as an OUTPUT_DECIMALS number.
166+
*/
167+
function pushReport() external returns (uint256 price) {
168+
uint32 timeElapsed;
169+
(price, timeElapsed) = _computePrice(minReportTimeIntervalSec);
170+
171+
medianOracle.pushReport(price);
172+
emit LogReportPushed(price, timeElapsed);
173+
}
174+
175+
/**
176+
* @notice Computes the chained TWAP over the current measurement window
177+
* without reporting it.
178+
* @return price The AMPL/USDC price as an OUTPUT_DECIMALS number.
179+
*/
180+
function computePrice() external view returns (uint256 price) {
181+
// Require at least one second of measurement so the average is defined.
182+
(price, ) = _computePrice(1);
183+
}
184+
185+
/**
186+
* @return True if the current block falls within the daily update window.
187+
*/
188+
function inUpdateWindow() public view returns (bool) {
189+
uint256 timeOfDay = block.timestamp.mod(updateTimeIntervalSec);
190+
return (timeOfDay >= updateWindowOffsetSec &&
191+
timeOfDay < updateWindowOffsetSec.add(updateWindowLengthSec));
192+
}
193+
194+
/**
195+
* @notice Sets the minimum measurement window length before a report can be
196+
* pushed.
197+
* @param minReportTimeIntervalSec_ The new minimum window length in seconds.
198+
*/
199+
function setMinReportTimeIntervalSec(uint256 minReportTimeIntervalSec_) external onlyOwner {
200+
require(minReportTimeIntervalSec_ < updateTimeIntervalSec, "DexOracle: INTERVAL_TOO_LONG");
201+
minReportTimeIntervalSec = minReportTimeIntervalSec_;
202+
}
203+
204+
/**
205+
* @notice Sets the daily update window parameters.
206+
* @param updateTimeIntervalSec_ Length of a full cadence cycle in seconds.
207+
* @param updateWindowOffsetSec_ Offset of the window from the cycle start.
208+
* @param updateWindowLengthSec_ Length of the update window in seconds.
209+
*/
210+
function setUpdateWindow(
211+
uint256 updateTimeIntervalSec_,
212+
uint256 updateWindowOffsetSec_,
213+
uint256 updateWindowLengthSec_
214+
) external onlyOwner {
215+
require(updateWindowOffsetSec_ < updateTimeIntervalSec_, "DexOracle: BAD_OFFSET");
216+
require(updateWindowLengthSec_ <= updateTimeIntervalSec_, "DexOracle: BAD_LENGTH");
217+
updateTimeIntervalSec = updateTimeIntervalSec_;
218+
updateWindowOffsetSec = updateWindowOffsetSec_;
219+
updateWindowLengthSec = updateWindowLengthSec_;
220+
}
221+
222+
/**
223+
* @dev Computes the chained TWAP, requiring at least `minElapsedSec` of
224+
* measurement since the last `update()`.
225+
* @return price The chained price as an OUTPUT_DECIMALS number.
226+
* @return timeElapsed The length of the measurement window in seconds.
227+
*/
228+
function _computePrice(uint256 minElapsedSec)
229+
private
230+
view
231+
returns (uint256 price, uint32 timeElapsed)
232+
{
233+
require(blockTimestampLast > 0, "DexOracle: UPDATE_NEVER_CALLED");
234+
235+
(
236+
uint256 leg1Cumulative,
237+
uint256 leg2Cumulative,
238+
uint32 blockTimestamp
239+
) = _currentCumulatives();
240+
unchecked {
241+
// Wraparound is desired; both timestamps are taken mod 2**32.
242+
timeElapsed = blockTimestamp - blockTimestampLast;
243+
}
244+
require(timeElapsed >= minElapsedSec, "DexOracle: PERIOD_NOT_ELAPSED");
245+
246+
uint256 priceLeg1 = _legPrice(
247+
leg1Cumulative,
248+
priceLeg1CumulativeLast,
249+
timeElapsed,
250+
decimalsFactorLeg1
251+
);
252+
uint256 priceLeg2 = _legPrice(
253+
leg2Cumulative,
254+
priceLeg2CumulativeLast,
255+
timeElapsed,
256+
decimalsFactorLeg2
257+
);
258+
price = priceLeg1.mul(priceLeg2).div(10**OUTPUT_DECIMALS);
259+
}
260+
261+
/**
262+
* @dev Reads the current raw price cumulatives for both legs, selecting the
263+
* configured direction. Both legs share the same block timestamp.
264+
*/
265+
function _currentCumulatives()
266+
private
267+
view
268+
returns (
269+
uint256 leg1Cumulative,
270+
uint256 leg2Cumulative,
271+
uint32 blockTimestamp
272+
)
273+
{
274+
uint256 price0;
275+
uint256 price1;
276+
277+
(price0, price1, blockTimestamp) = UniswapV2OracleLibrary.currentCumulativePrices(
278+
address(pairLeg1)
279+
);
280+
leg1Cumulative = leg1UseToken1Price ? price1 : price0;
281+
282+
(price0, price1, ) = UniswapV2OracleLibrary.currentCumulativePrices(address(pairLeg2));
283+
leg2Cumulative = leg2UseToken1Price ? price1 : price0;
284+
}
285+
286+
/**
287+
* @dev Converts the windowed difference of a raw UQ112x112 cumulative into
288+
* an OUTPUT_DECIMALS decimal price.
289+
*/
290+
function _legPrice(
291+
uint256 cumulativeNow,
292+
uint256 cumulativeLast,
293+
uint32 timeElapsed,
294+
uint256 decimalsFactor
295+
) private pure returns (uint256) {
296+
uint256 avgRatioUQ112x112;
297+
unchecked {
298+
// The UniswapV2 accumulators are designed to overflow; the windowed
299+
// difference is well-defined modulo 2**256.
300+
avgRatioUQ112x112 = (cumulativeNow - cumulativeLast) / timeElapsed;
301+
}
302+
// Bridge the UQ112x112 raw reserve ratio into a decimal price. The
303+
// windowed average is bounded, so the scaling cannot overflow.
304+
return avgRatioUQ112x112.mul(decimalsFactor) >> 112;
305+
}
306+
307+
/**
308+
* @dev Resolves the (base, quote) tokens a leg prices, given the read
309+
* direction. price0 prices token0 in token1; price1 prices token1 in
310+
* token0.
311+
*/
312+
function _baseQuote(address pair, bool useToken1Price)
313+
private
314+
view
315+
returns (address base, address quote)
316+
{
317+
if (useToken1Price) {
318+
base = IUniswapV2Pair(pair).token1();
319+
quote = IUniswapV2Pair(pair).token0();
320+
} else {
321+
base = IUniswapV2Pair(pair).token0();
322+
quote = IUniswapV2Pair(pair).token1();
323+
}
324+
}
325+
326+
/**
327+
* @dev Computes 10**(OUTPUT_DECIMALS + baseDecimals - quoteDecimals), the
328+
* factor that converts a leg's raw UQ112x112 reserve ratio into an
329+
* OUTPUT_DECIMALS decimal price.
330+
*/
331+
function _decimalsFactor(address base, address quote) private view returns (uint256) {
332+
uint256 baseDecimals = uint256(IERC20Decimals(base).decimals());
333+
uint256 quoteDecimals = uint256(IERC20Decimals(quote).decimals());
334+
return 10**(OUTPUT_DECIMALS.add(baseDecimals).sub(quoteDecimals));
335+
}
336+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
pragma solidity 0.8.4;
3+
4+
/**
5+
* @title IUniswapV2Pair
6+
* @dev Minimal interface for the subset of UniswapV2Pair used by the oracle.
7+
* See https://github.com/Uniswap/v2-core for the full interface.
8+
*/
9+
interface IUniswapV2Pair {
10+
function token0() external view returns (address);
11+
12+
function token1() external view returns (address);
13+
14+
function getReserves()
15+
external
16+
view
17+
returns (
18+
uint112 reserve0,
19+
uint112 reserve1,
20+
uint32 blockTimestampLast
21+
);
22+
23+
function price0CumulativeLast() external view returns (uint256);
24+
25+
function price1CumulativeLast() external view returns (uint256);
26+
}

0 commit comments

Comments
 (0)