Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hourglass PT/CT Linear Discount Rate Oracle #77

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/adapter/hourglass/HourglassOracle.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;

import {IERC20} from "forge-std/interfaces/IERC20.sol";
import {BaseAdapter, Errors, IPriceOracle} from "../BaseAdapter.sol";
import {ScaleUtils, Scale} from "../../lib/ScaleUtils.sol";
import {IHourglassDepositor} from "./IHourglassDepositor.sol";
import {IHourglassERC20TBT} from "./IHourglassERC20TBT.sol";

contract HourglassOracle is BaseAdapter {
/// @inheritdoc IPriceOracle
string public constant name = "HourglassOracle";

/// @notice The number of decimals for the base token.
uint256 internal immutable baseTokenScale;
/// @notice The scale factors used for decimal conversions.
Scale internal immutable scale;

/// @notice The address of the base asset (e.g., PT or CT).
address public immutable base;
/// @notice The address of the quote asset (e.g., underlying asset).
address public immutable quote;

/// @notice Per second discount rate (scaled by 1e18).
uint256 public immutable discountRate;

/// @notice Address of the Hourglass depositor contract (pool-specific).
IHourglassDepositor public immutable hourglassDepositor;

/// @notice The address of the combined token.
address public immutable combinedToken;
/// @notice The address of the principal token.
address public immutable principalToken;
/// @notice The address of the underlying token.
address public immutable underlyingToken;

/// @notice Deploy the HourglassLinearDiscountOracle.
/// @param _base The address of the base asset (PT or CT).
/// @param _quote The address of the quote asset (underlying token).
/// @param _discountRate Discount rate (secondly, scaled by 1e18).
constructor(address _base, address _quote, uint256 _discountRate) {
if (_discountRate == 0) revert Errors.PriceOracle_InvalidConfiguration();

// Initialize key parameters
base = _base;
quote = _quote;
discountRate = _discountRate;
hourglassDepositor = IHourglassDepositor(IHourglassERC20TBT(_base).depositor());

// Fetch token addresses
address[] memory tokens = hourglassDepositor.getTokens();
combinedToken = tokens[0];
principalToken = tokens[1];
underlyingToken = hourglassDepositor.getUnderlying();

// Only allow PT or CT as base token
if (_base != combinedToken && _base != principalToken) revert Errors.PriceOracle_InvalidConfiguration();

// Calculate scale factors for decimal conversions
uint8 baseDecimals = _getDecimals(_base);
uint8 quoteDecimals = _getDecimals(_quote);
scale = ScaleUtils.calcScale(baseDecimals, quoteDecimals, quoteDecimals);
baseTokenScale = 10 ** baseDecimals;
}

/// @notice Get a dynamic quote using linear discounting and solvency adjustment.
/// @param inAmount The amount of `base` to convert.
/// @param _base The token being priced (e.g., PT or CT).
/// @param _quote The token used as the unit of account (e.g., underlying).
/// @return The converted amount using the linear discount rate and solvency adjustment.
function _getQuote(uint256 inAmount, address _base, address _quote) internal view override returns (uint256) {
bool inverse = ScaleUtils.getDirectionOrRevert(_base, base, _quote, quote);

// Get solvency ratio, baseTokenDecimals precision
uint256 solvencyRatio = _getSolvencyRatio();

// Calculate present value using linear discounting, baseTokenDecimals precision
uint256 presentValue = _getUnitPresentValue(solvencyRatio);

// Return scaled output amount
return ScaleUtils.calcOutAmount(inAmount, presentValue, scale, inverse);
}

/// @notice Calculate the present value using linear discounting.
/// @param solvencyRatio Solvency ratio of the Hourglass system (scaled by baseTokenDecimals).
/// @return presentValue The present value of the input amount (scaled by baseTokenDecimals).
function _getUnitPresentValue(uint256 solvencyRatio) internal view returns (uint256) {
uint256 maturityTime = hourglassDepositor.maturity();

// Already matured, so PV = solvencyRatio.
if (maturityTime <= block.timestamp) return solvencyRatio;

uint256 timeToMaturity = maturityTime - block.timestamp;

// The expression (1e18 + discountRate * timeToMaturity) is ~1e18 scale
// We want the denominator to be scaled to baseTokenDecimals so that when
// we divide the (inAmount * solvencyRatio) [which is 2 * baseTokenDecimals in scale],
// we end up back with baseTokenDecimals in scale.

uint256 scaledDenominator = (
(1e18 + (discountRate * timeToMaturity)) // ~1e18 scale
* baseTokenScale
) // multiply by 1e(baseTokenDecimals)
/ 1e18; // now scaledDenominator has baseTokenDecimals precision

// (inAmount * solvencyRatio) is scale = 2 * baseTokenDecimals
// dividing by scaledDenominator (scale = baseTokenDecimals)
// => result has scale = baseTokenDecimals
return (baseTokenScale * solvencyRatio) / scaledDenominator;
}

/// @notice Fetch the solvency ratio of the Hourglass system.
/// @dev The ratio is capped to 1. The returned value is scaled by baseTokenDecimals.
/// @return solvencyRatio Solvency ratio of the Hourglass system (scaled by baseTokenDecimals).
function _getSolvencyRatio() internal view returns (uint256) {
uint256 ptSupply = IERC20(principalToken).totalSupply();
uint256 ctSupply = IERC20(combinedToken).totalSupply();
uint256 totalClaims = ptSupply + ctSupply;
if (totalClaims == 0) return baseTokenScale;

uint256 underlyingTokenBalance = IERC20(underlyingToken).balanceOf(address(hourglassDepositor));

// Return the solvency as a ratio capped to 1.
if (underlyingTokenBalance < totalClaims) {
return underlyingTokenBalance * baseTokenScale / totalClaims;
} else {
return baseTokenScale;
}
}
}
24 changes: 24 additions & 0 deletions src/adapter/hourglass/IHourglassDepositor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IHourglassDepositor {
function getTokens() external view returns (address[] memory);
function getUnderlying() external view returns (address);
function maturity() external view returns (uint256);
}

interface IVedaDepositor {
function mintLockedUnderlying(address depositAsset, uint256 amountOutMinBps) external returns (uint256 amountOut);
}

interface IEthFiLUSDDepositor {
function mintLockedUnderlying(uint256 minMintReceivedSlippageBps, address lusdDepositAsset, address sourceOfFunds)
external
returns (uint256 amountDepositAssetMinted);
}

interface IEthFiLiquidDepositor {
function mintLockedUnderlying(uint256 minMintReceivedSlippageBps, address lusdDepositAsset, address sourceOfFunds)
external
returns (uint256 amountDepositAssetMinted);
}
6 changes: 6 additions & 0 deletions src/adapter/hourglass/IHourglassERC20TBT.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IHourglassERC20TBT {
function depositor() external view returns (address);
}
14 changes: 14 additions & 0 deletions test/adapter/hourglass/HourglassAddresses.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;

import {LBTCV} from "../../utils/EthereumAddresses.sol";

address constant HOURGLASS_LBTCV_01MAR2025_DEPOSITOR = 0xf06617fBECF1BdEa2D62079bdab9595f86801604;
address constant HOURGLASS_LBTCV_01MAR2025_CT = 0xe6dA3BD04cEEE35D6A52fF329e57cC2220a669b1;
address constant HOURGLASS_LBTCV_01MAR2025_PT = 0x97955073caA92028a86Cd3F660FE484d6B89B938;
address constant HOURGLASS_LBTCV_01MAR2025_UNDERLYING = LBTCV;

address constant HOURGLASS_LBTCV_01DEC2024_DEPOSITOR = 0xA285bca8f01c8F18953443e645ef2786D31ada99;
address constant HOURGLASS_LBTCV_01DEC2024_CT = 0x0CB35DC9ADDce18669E2Fd5db4B405Ea655e98Bd;
address constant HOURGLASS_LBTCV_01DEC2024_PT = 0xDB0Ee7308cF1F5A3f376D015a1545B4cB9A878D9;
address constant HOURGLASS_LBTCV_01DEC2024_UNDERLYING = LBTCV;
148 changes: 148 additions & 0 deletions test/adapter/hourglass/HourglassOracle.fork.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;

// ============ Imports ============

// Foundry's base test that sets up a mainnet or testnet fork
import {ForkTest} from "test/utils/ForkTest.sol";

// Import your HourglassOracle
import {HourglassOracle} from "src/adapter/hourglass/HourglassOracle.sol";
import {Errors} from "src/lib/Errors.sol";

// Typically you'd import ERC20 or an interface to check balances if needed
import {IERC20} from "forge-std/interfaces/IERC20.sol";

import {
HOURGLASS_LBTCV_01MAR2025_DEPOSITOR,
HOURGLASS_LBTCV_01MAR2025_PT,
HOURGLASS_LBTCV_01MAR2025_CT,
HOURGLASS_LBTCV_01MAR2025_UNDERLYING,
HOURGLASS_LBTCV_01DEC2024_DEPOSITOR,
HOURGLASS_LBTCV_01DEC2024_PT,
HOURGLASS_LBTCV_01DEC2024_UNDERLYING
} from "test/adapter/hourglass/HourglassAddresses.sol";

/**
* @dev Example discountRate as "per-second" rate in 1e18 form. For instance:
* - 100% annual ~ 3.17e10 if you do (1.0 / 31536000) * 1e18
* - 50% annual ~ 1.585e10
* - Adjust to whatever you want for testing
*/
uint256 constant DISCOUNT_RATE_PER_SECOND = 1585489599; // ~ 5% annual

contract HourglassOracleForkTest is ForkTest {
// For relative assert precision (e.g. 1% = 0.01e18)
uint256 constant REL_PRECISION = 0.01e18;

/**
* @dev Choose a block where the Hourglass depositor, PT, CT, etc. are deployed
* and in a known state. Adjust as needed.
*/
function setUp() public {
_setUpFork(21_400_000); // Dec-14-2024 09:56:47 AM +UTC
}

/**
* @dev Basic constructor test: deploy HourglassOracle with the PT as 'base'
* and the "underlying" (or CT, whichever is correct in your design) as 'quote'.
*/
function test_Constructor_Integrity_Hourglass() public {
HourglassOracle oracle = new HourglassOracle(
HOURGLASS_LBTCV_01MAR2025_PT, // base
HOURGLASS_LBTCV_01MAR2025_UNDERLYING, // quote
DISCOUNT_RATE_PER_SECOND // discount rate
);

// The contract returns "HourglassOracle"
assertEq(oracle.name(), "HourglassOracle");

// The base/quote we passed in
assertEq(oracle.base(), HOURGLASS_LBTCV_01MAR2025_PT);
assertEq(oracle.quote(), HOURGLASS_LBTCV_01MAR2025_UNDERLYING);

// The discountRate we provided
assertEq(oracle.discountRate(), DISCOUNT_RATE_PER_SECOND);

// You could also check that the "depositor" is set as expected:
// e.g. (from inside your HourglassOracle) "hourglassDepositor"
// But you only can do that if it's public or there's a getter.
// e.g., if hourglassDepositor is public:
assertEq(address(oracle.hourglassDepositor()), HOURGLASS_LBTCV_01MAR2025_DEPOSITOR);
}

/**
* @dev Example "active market" test - calls getQuote() both ways (PT -> underlying, and underlying -> PT).
* This is analogous to your Pendle tests where you check the rate with no slippage,
* but you need to know what 1 PT is expected to be in "underlying" at this block.
*/
function test_GetQuote_ActiveMarket_LBTCV_01MAR2025_PT() public {
// Deploy the oracle
HourglassOracle oracle = new HourglassOracle(
HOURGLASS_LBTCV_01MAR2025_PT, // base
HOURGLASS_LBTCV_01MAR2025_UNDERLYING, // quote
DISCOUNT_RATE_PER_SECOND
);

// PT -> underlying
uint256 outAmount = oracle.getQuote(1e8, HOURGLASS_LBTCV_01MAR2025_PT, HOURGLASS_LBTCV_01MAR2025_UNDERLYING);
assertApproxEqRel(outAmount, 0.99707e8, REL_PRECISION);

// Underlying -> PT
uint256 outAmountInv =
oracle.getQuote(outAmount, HOURGLASS_LBTCV_01MAR2025_UNDERLYING, HOURGLASS_LBTCV_01MAR2025_PT);
assertApproxEqRel(outAmountInv, 1e8, REL_PRECISION);
}

/**
* @dev Example "active market" test - calls getQuote() both ways (CT -> underlying, and underlying -> CT).
* This is analogous to your Pendle tests where you check the rate with no slippage,
* but you need to know what 1 CT is expected to be in "underlying" at this block.
*/
function test_GetQuote_ActiveMarket_LBTCV_01MAR2025_CT() public {
// Deploy the oracle
HourglassOracle oracle = new HourglassOracle(
HOURGLASS_LBTCV_01MAR2025_CT, // base
HOURGLASS_LBTCV_01MAR2025_UNDERLYING, // quote
DISCOUNT_RATE_PER_SECOND
);

// PT -> underlying
uint256 outAmount = oracle.getQuote(1e8, HOURGLASS_LBTCV_01MAR2025_CT, HOURGLASS_LBTCV_01MAR2025_UNDERLYING);
assertApproxEqRel(outAmount, 0.99707e8, REL_PRECISION);

// Underlying -> PT
uint256 outAmountInv =
oracle.getQuote(outAmount, HOURGLASS_LBTCV_01MAR2025_UNDERLYING, HOURGLASS_LBTCV_01MAR2025_CT);
assertApproxEqRel(outAmountInv, 1e8, REL_PRECISION);
}

/**
* @dev Example "expired market" test. If your hourglass PT has matured by the fork block,
* then 1 PT might fully be worth exactly 1 underlying, or some final settled ratio.
*/
function test_GetQuote_ExpiredMarket() public {
// If the market for LBTCV_01MAR2025 is expired at the chosen block, you can test that 1 PT = 1 underlying
// or whatever the final settlement is.
HourglassOracle oracle = new HourglassOracle(
HOURGLASS_LBTCV_01DEC2024_PT, HOURGLASS_LBTCV_01DEC2024_UNDERLYING, DISCOUNT_RATE_PER_SECOND
);

uint256 outAmount = oracle.getQuote(1e8, HOURGLASS_LBTCV_01DEC2024_PT, HOURGLASS_LBTCV_01DEC2024_UNDERLYING);
assertEq(outAmount, 1e8);
}

/**
* @dev If you expect invalid configuration (like discountRate=0 or base=quote, etc.),
* you can test that your HourglassOracle reverts.
*/
function test_Constructor_InvalidConfiguration() public {
// For example, discountRate = 0 => revert PriceOracle_InvalidConfiguration
vm.expectRevert(Errors.PriceOracle_InvalidConfiguration.selector);
new HourglassOracle(
HOURGLASS_LBTCV_01MAR2025_PT,
HOURGLASS_LBTCV_01MAR2025_UNDERLYING,
0 // zero discount => revert
);
}
}
44 changes: 44 additions & 0 deletions test/adapter/hourglass/HourglassOracle.prop.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;

import {AdapterPropTest} from "test/adapter/AdapterPropTest.sol";
import {HourglassOracleHelper} from "test/adapter/hourglass/HourglassOracleHelper.sol";

contract HourglassOraclePropTest is HourglassOracleHelper, AdapterPropTest {
function testProp_Bidirectional(FuzzableState memory s, Prop_Bidirectional memory p) public {
setUpPropTest(s);
checkProp(p);
}

function testProp_NoOtherPaths(FuzzableState memory s, Prop_NoOtherPaths memory p) public {
setUpPropTest(s);
checkProp(p);
}

function testProp_IdempotentQuoteAndQuotes(FuzzableState memory s, Prop_IdempotentQuoteAndQuotes memory p) public {
setUpPropTest(s);
checkProp(p);
}

function testProp_SupportsZero(FuzzableState memory s, Prop_SupportsZero memory p) public {
setUpPropTest(s);
checkProp(p);
}

function testProp_ContinuousDomain(FuzzableState memory s, Prop_ContinuousDomain memory p) public {
setUpPropTest(s);
checkProp(p);
}

function testProp_OutAmountIncreasing(FuzzableState memory s, Prop_OutAmountIncreasing memory p) public {
setUpPropTest(s);
checkProp(p);
}

function setUpPropTest(FuzzableState memory s) internal {
setUpState(s);
adapter = address(oracle);
base = s.base;
quote = s.quote;
}
}
Loading