From 2e0b9442ffa3799a453ef0f76ca092e6c2094bc7 Mon Sep 17 00:00:00 2001 From: Gerardo Nardelli Date: Tue, 6 Oct 2026 15:26:28 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20price-feeds:=20add=20inverse=20feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/swift-heron-flips.md | 5 ++ .gas-snapshot | 70 +++++++++++-------- contracts/PriceFeedInverse.sol | 31 +++++++++ deploy/PriceFeeds.ts | 11 +++ deploy/mocks/Assets.ts | 6 +- hardhat.config.ts | 2 +- test/PriceFeedInverse.t.sol | 118 ++++++++++++++++++++++++++++++++ 7 files changed, 211 insertions(+), 32 deletions(-) create mode 100644 .changeset/swift-heron-flips.md create mode 100644 contracts/PriceFeedInverse.sol create mode 100644 test/PriceFeedInverse.t.sol diff --git a/.changeset/swift-heron-flips.md b/.changeset/swift-heron-flips.md new file mode 100644 index 000000000..0324de001 --- /dev/null +++ b/.changeset/swift-heron-flips.md @@ -0,0 +1,5 @@ +--- +"@exactly/protocol": patch +--- + +✨ price-feeds: add inverse feed diff --git a/.gas-snapshot b/.gas-snapshot index 2809c3155..7ac576218 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -66,7 +66,7 @@ DebtManagerTest:testFloatingToFixedRollHigherThanAvailableLiquidityWithSlippage( DebtManagerTest:testFloatingToFixedRollHigherThanAvailableLiquidityWithSlippageWithThreePools() (gas: 2819298) DebtManagerTest:testFloatingToFixedRollWithAccurateSlippage() (gas: 1733293) DebtManagerTest:testFloatingToFixedRollWithAccurateSlippageWithPreviousPosition() (gas: 1955460) -DebtManagerTest:testFuzzRolls(uint8[4],uint8[4],uint256[4],uint40[4],uint8[4]) (runs: 256, μ: 8031885, ~: 8086009) +DebtManagerTest:testFuzzRolls(uint8[4],uint8[4],uint256[4],uint40[4],uint8[4]) (runs: 256, μ: 8004418, ~: 8031281) DebtManagerTest:testLateFixedRoll() (gas: 1455061) DebtManagerTest:testLateFixedRollWithThreeLoops() (gas: 2044387) DebtManagerTest:testLateFixedToFloatingRoll() (gas: 1449884) @@ -220,19 +220,19 @@ IntegrationPreviewerTest:test_fixedRepayPosition_beforeMaturity() (gas: 580013) IntegrationPreviewerTest:test_fixedRepayPosition_maxUint() (gas: 79490) IntegrationPreviewerTest:test_fixedRepayPosition_saturatedFallback() (gas: 63069) IntegrationPreviewerTest:test_healthFactor() (gas: 464287) -IntegrationPreviewerTest:test_previewDeposit_(uint256) (runs: 256, μ: 1595909, ~: 1599768) +IntegrationPreviewerTest:test_previewDeposit_(uint256) (runs: 256, μ: 1595663, ~: 1599768) IntegrationPreviewerTest:test_previewHealthFactor() (gas: 5827588) IntegrationPreviewerTest:test_previewHealthFactor_nonCollateralMarket() (gas: 3511184) -IntegrationPreviewerTest:test_previewWithdraw_(uint256) (runs: 256, μ: 1656041, ~: 1660104) +IntegrationPreviewerTest:test_previewWithdraw_(uint256) (runs: 256, μ: 1655792, ~: 1660104) InterestRateModelTest:testFixedBorrowRate() (gas: 2088868) InterestRateModelTest:testFixedRateRevertAlreadyMatured() (gas: 2083135) InterestRateModelTest:testFixedRateRevertUtilizationExceeded() (gas: 2097203) InterestRateModelTest:testFloatingBorrowRate() (gas: 2082456) -InterestRateModelTest:testFuzzFixedRateGrowth(uint256,uint256,uint256,uint256) (runs: 256, μ: 2108979, ~: 2105195) -InterestRateModelTest:testFuzzFixedRateTimeSensitivity(uint256,uint256,uint256) (runs: 256, μ: 2117002, ~: 2117161) -InterestRateModelTest:testFuzzReferenceLegacyRateFixed(uint32,uint256,uint256[2],uint256[2],uint256,uint256,uint256) (runs: 256, μ: 12926388, ~: 13131329) -InterestRateModelTest:testFuzzReferenceRateFixed(uint256,uint256,uint256,uint256,uint256,uint256,(uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256,int256,uint256,uint256)) (runs: 256, μ: 2384553, ~: 2386829) -InterestRateModelTest:testFuzzReferenceRateFloating(uint256,uint256,(uint256,uint256,uint256,uint256,uint256,uint256,uint256)) (runs: 256, μ: 2324290, ~: 2324936) +InterestRateModelTest:testFuzzFixedRateGrowth(uint256,uint256,uint256,uint256) (runs: 256, μ: 2109336, ~: 2112192) +InterestRateModelTest:testFuzzFixedRateTimeSensitivity(uint256,uint256,uint256) (runs: 256, μ: 2117022, ~: 2117161) +InterestRateModelTest:testFuzzReferenceLegacyRateFixed(uint32,uint256,uint256[2],uint256[2],uint256,uint256,uint256) (runs: 256, μ: 12931216, ~: 13130880) +InterestRateModelTest:testFuzzReferenceRateFixed(uint256,uint256,uint256,uint256,uint256,uint256,(uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256,int256,uint256,uint256)) (runs: 256, μ: 2384630, ~: 2386873) +InterestRateModelTest:testFuzzReferenceRateFloating(uint256,uint256,(uint256,uint256,uint256,uint256,uint256,uint256,uint256)) (runs: 256, μ: 2324355, ~: 2324942) InterestRateModelTest:testMinTimeToMaturity() (gas: 2107232) InterestRateModelTest:testRevertMaxUtilizationLowerThanWad() (gas: 268402) MarketTest:testAccountLiquidityAdjustedDebt() (gas: 654879) @@ -396,7 +396,7 @@ PoolLibTest:testAtomicDepositBorrowRepayWithdraw() (gas: 45922) PoolLibTest:testBackupBorrow() (gas: 33624) PoolLibTest:testEarningsAccrual() (gas: 38952) PoolLibTest:testEarningsDistribution() (gas: 32679) -PoolLibTest:testFuzzAddRemoveAll(uint8[12]) (runs: 256, μ: 104450, ~: 105534) +PoolLibTest:testFuzzAddRemoveAll(uint8[12]) (runs: 256, μ: 104690, ~: 106307) PoolLibTest:testMaturityRangeLimit() (gas: 7866) PoolLibTest:testMaturityRangeTooWide() (gas: 11656) PreviewerTest:testAccountsReturningAccurateAmounts() (gas: 1687100) @@ -478,6 +478,18 @@ PriceFeedDoubleTest:testPriceFeedDoubleReturningPrice() (gas: 76193) PriceFeedDoubleTest:testPriceFeedDoubleWithActualOnChainValues() (gas: 85326) PriceFeedDoubleTest:testPriceFeedDoubleWithNegativePriceShouldRevert() (gas: 212681) PriceFeedDoubleTest:testPriceFeedDoubleWithUsdPriceFeed() (gas: 498675) +PriceFeedInverseTest:testFuzz_latestAnswer_matchesGreaterScale(int256) (runs: 256, μ: 55881, ~: 56215) +PriceFeedInverseTest:testFuzz_latestAnswer_returnsOne_whenAnswerIsAboveBaseUnitSquared(int256) (runs: 256, μ: 2531209, ~: 2531318) +PriceFeedInverseTest:testFuzz_latestAnswer_reverts_whenAnswerIsNegative(int256) (runs: 256, μ: 54861, ~: 54945) +PriceFeedInverseTest:testFuzz_latestAnswer_roundsUp(int256) (runs: 256, μ: 55095, ~: 55431) +PriceFeedInverseTest:test_assetPrice_returnsPrice_whenAnswerIsAboveBaseUnitSquared() (gas: 2509699) +PriceFeedInverseTest:test_constructor_setsPriceFeedDecimalsAndBaseUnit() (gas: 26853) +PriceFeedInverseTest:test_latestAnswer_matchesGreaterScale_withHighPrices() (gas: 229965) +PriceFeedInverseTest:test_latestAnswer_returnsExactInverse_whenDivisionIsExact() (gas: 91181) +PriceFeedInverseTest:test_latestAnswer_returnsInverse() (gas: 57926) +PriceFeedInverseTest:test_latestAnswer_returnsOne_whenAnswerIsAboveBaseUnitSquared() (gas: 91218) +PriceFeedInverseTest:test_latestAnswer_reverts_whenAnswerIsNegative() (gas: 94442) +PriceFeedInverseTest:test_latestAnswer_reverts_whenAnswerIsZero() (gas: 37201) PriceFeedPoolTest:testPriceFeedPoolReturningPrice() (gas: 40917) PriceFeedPoolTest:testPriceFeedPoolReturningPriceWithToken0False() (gas: 2181230) PriceFeedPoolTest:testPriceFeedPoolWithAllDifferentDecimals() (gas: 2159319) @@ -577,8 +589,8 @@ StakedEXATest:invariantNoDuplicatedReward() (runs: 10, calls: 5000, reverts: 0) StakedEXATest:invariantRewardsUpOnly() (runs: 10, calls: 5000, reverts: 0) StakedEXATest:invariantShareValueIsOne() (runs: 10, calls: 5000, reverts: 0) StakedEXATest:testAlreadyListedError() (gas: 43677) -StakedEXATest:testAvgIndex(uint256[3],uint256[2]) (runs: 256, μ: 1322403, ~: 1370843) -StakedEXATest:testAvgStartTime(uint256[3],uint256[2]) (runs: 256, μ: 1250469, ~: 1298909) +StakedEXATest:testAvgIndex(uint256[3],uint256[2]) (runs: 256, μ: 1319458, ~: 1370678) +StakedEXATest:testAvgStartTime(uint256[3],uint256[2]) (runs: 256, μ: 1247524, ~: 1298744) StakedEXATest:testBalanceOfDeposit(uint80) (runs: 256, μ: 331003, ~: 339807) StakedEXATest:testBalanceOfWithdraw(uint256) (runs: 256, μ: 82393, ~: 82407) StakedEXATest:testCanChangeRewardsDurationWhenDisabled() (gas: 176331) @@ -587,12 +599,12 @@ StakedEXATest:testClaimAndUnstake() (gas: 1622618) StakedEXATest:testClaimAndWithdrawAfterRefTime() (gas: 1029325) StakedEXATest:testClaimBeforeFirstHarvest() (gas: 527334) StakedEXATest:testDepositClaimsRewardsToReceiver() (gas: 1121108) -StakedEXATest:testDepositEvent(uint256) (runs: 256, μ: 335230, ~: 334966) -StakedEXATest:testDepositShouldClaim(uint256[2],uint32) (runs: 256, μ: 822456, ~: 754904) +StakedEXATest:testDepositEvent(uint256) (runs: 256, μ: 335228, ~: 334966) +StakedEXATest:testDepositShouldClaim(uint256[2],uint32) (runs: 256, μ: 826654, ~: 754952) StakedEXATest:testDepositToAnotherWithAllowance() (gas: 375284) StakedEXATest:testDepositToAnotherWithoutAllowanceShouldFail() (gas: 121959) -StakedEXATest:testDepositWithdrawAvgStartTimeAndIndex(uint256[3],uint256,uint256[5]) (runs: 256, μ: 1879175, ~: 1904532) -StakedEXATest:testEarnedWithTime(uint256) (runs: 256, μ: 86134, ~: 86326) +StakedEXATest:testDepositWithdrawAvgStartTimeAndIndex(uint256[3],uint256,uint256[5]) (runs: 256, μ: 1875287, ~: 1904448) +StakedEXATest:testEarnedWithTime(uint256) (runs: 256, μ: 86108, ~: 86326) StakedEXATest:testEmergencyAdminCanPauseNotUnpause() (gas: 175029) StakedEXATest:testFinishDistributionEmitEvent() (gas: 240752) StakedEXATest:testFinishDistributionLetsClaimUnclaimed() (gas: 1784877) @@ -603,9 +615,9 @@ StakedEXATest:testGrantRevokeEmergencyAdmin() (gas: 117824) StakedEXATest:testGrantRevokePauser() (gas: 117836) StakedEXATest:testHandlerClaim(uint8) (runs: 256, μ: 485340, ~: 485340) StakedEXATest:testHandlerDeposit(uint80) (runs: 256, μ: 1240331, ~: 1276242) -StakedEXATest:testHandlerHarvest(uint64) (runs: 256, μ: 402355, ~: 411143) -StakedEXATest:testHandlerNotifyRewardAmount(uint64) (runs: 256, μ: 166056, ~: 156797) -StakedEXATest:testHandlerSetDuration(uint32) (runs: 256, μ: 196224, ~: 218711) +StakedEXATest:testHandlerHarvest(uint64) (runs: 256, μ: 402351, ~: 411143) +StakedEXATest:testHandlerNotifyRewardAmount(uint64) (runs: 256, μ: 165113, ~: 156797) +StakedEXATest:testHandlerSetDuration(uint32) (runs: 256, μ: 198473, ~: 218711) StakedEXATest:testHandlerSetMarket() (gas: 144553) StakedEXATest:testHandlerWithdraw(uint256) (runs: 256, μ: 91497, ~: 91511) StakedEXATest:testHarvestAmountWithReducedAllowance() (gas: 184605) @@ -616,13 +628,13 @@ StakedEXATest:testHarvestWhenFinished() (gas: 326352) StakedEXATest:testHarvestX() (gas: 180952) StakedEXATest:testHarvestZero() (gas: 255436) StakedEXATest:testInitialValues() (gas: 196484) -StakedEXATest:testInsufficientBalanceError(uint256) (runs: 256, μ: 63929, ~: 64151) +StakedEXATest:testInsufficientBalanceError(uint256) (runs: 256, μ: 63958, ~: 64151) StakedEXATest:testMaxRewardsGasConsumption() (gas: 144674963) StakedEXATest:testMultipleClaimsVsOne() (gas: 21936440) StakedEXATest:testMultipleHarvests() (gas: 284083) -StakedEXATest:testNoRewardsAfterPeriod(uint256) (runs: 256, μ: 1809884, ~: 1804427) +StakedEXATest:testNoRewardsAfterPeriod(uint256) (runs: 256, μ: 1809893, ~: 1804427) StakedEXATest:testNotPausingRoleError() (gas: 39124) -StakedEXATest:testNotifyRewardAmount(uint256,uint256) (runs: 256, μ: 155618, ~: 155564) +StakedEXATest:testNotifyRewardAmount(uint256,uint256) (runs: 256, μ: 155601, ~: 155564) StakedEXATest:testNotifyRewardWithUnderlyingAsset() (gas: 460608) StakedEXATest:testOnlyAdminEnableReward() (gas: 1297302) StakedEXATest:testOnlyAdminFinishDistribution() (gas: 213640) @@ -640,13 +652,13 @@ StakedEXATest:testPenaltyThresholdRange() (gas: 36989) StakedEXATest:testPermitAndDeposit() (gas: 452146) StakedEXATest:testPermitFailKeepsFlow() (gas: 484480) StakedEXATest:testRemoveDepositAllowance() (gas: 466913) -StakedEXATest:testResetDepositAfterRefTime(uint256) (runs: 256, μ: 1033242, ~: 1032994) -StakedEXATest:testRewardAmountNotifiedEvent(uint256) (runs: 256, μ: 105549, ~: 105281) +StakedEXATest:testResetDepositAfterRefTime(uint256) (runs: 256, μ: 1033239, ~: 1032994) +StakedEXATest:testRewardAmountNotifiedEvent(uint256) (runs: 256, μ: 105547, ~: 105293) StakedEXATest:testRewardNotListedError() (gas: 1177184) -StakedEXATest:testRewardPaidEvent(uint256,uint256) (runs: 256, μ: 810714, ~: 854321) -StakedEXATest:testRewardsAmounts(uint256) (runs: 256, μ: 1707512, ~: 1707287) +StakedEXATest:testRewardPaidEvent(uint256,uint256) (runs: 256, μ: 809092, ~: 854321) +StakedEXATest:testRewardsAmounts(uint256) (runs: 256, μ: 1707506, ~: 1707287) StakedEXATest:testRewardsDurationSetEvent(uint40) (runs: 256, μ: 52173, ~: 52149) -StakedEXATest:testSetDuration(uint256,uint40) (runs: 256, μ: 66005, ~: 64453) +StakedEXATest:testSetDuration(uint256,uint40) (runs: 256, μ: 65997, ~: 64453) StakedEXATest:testSetMarketAddressZero() (gas: 36979) StakedEXATest:testSetMarketOnlyAdmin() (gas: 1345554) StakedEXATest:testSetMaxRewardsTokensExceeded() (gas: 111958823) @@ -658,12 +670,12 @@ StakedEXATest:testSetSavingsZeroAddressError() (gas: 37006) StakedEXATest:testTotalSupplyDeposit(uint80) (runs: 256, μ: 330389, ~: 339193) StakedEXATest:testTotalSupplyWithdraw(uint256) (runs: 256, μ: 81753, ~: 81767) StakedEXATest:testUntransferable(uint80) (runs: 256, μ: 338580, ~: 348672) -StakedEXATest:testWithdrawEvent(uint256) (runs: 256, μ: 483396, ~: 483140) +StakedEXATest:testWithdrawEvent(uint256) (runs: 256, μ: 483393, ~: 483140) StakedEXATest:testWithdrawRewardUnderlyingAsset() (gas: 456617) StakedEXATest:testWithdrawRewardsOnlyAdmin() (gas: 242200) StakedEXATest:testWithdrawRewardsOnlyReward() (gas: 1177174) -StakedEXATest:testWithdrawSameAmountRewardsShouldEqual(uint256,uint256) (runs: 256, μ: 1065777, ~: 1107874) -StakedEXATest:testWithdrawWithRewards(uint256) (runs: 256, μ: 895620, ~: 895364) +StakedEXATest:testWithdrawSameAmountRewardsShouldEqual(uint256,uint256) (runs: 256, μ: 1063385, ~: 1107803) +StakedEXATest:testWithdrawWithRewards(uint256) (runs: 256, μ: 895617, ~: 895364) StakedEXATest:testZeroRateError() (gas: 58274) StakingPreviewerTest:testAllClaimable() (gas: 769205) StakingPreviewerTest:testAllClaimed() (gas: 818952) diff --git a/contracts/PriceFeedInverse.sol b/contracts/PriceFeedInverse.sol new file mode 100644 index 000000000..b51e992e8 --- /dev/null +++ b/contracts/PriceFeedInverse.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.17; + +import { FixedPointMathLib } from "solady/src/utils/FixedPointMathLib.sol"; +import { SafeCastLib } from "solady/src/utils/SafeCastLib.sol"; +import { IPriceFeed } from "./utils/IPriceFeed.sol"; + +contract PriceFeedInverse is IPriceFeed { + using FixedPointMathLib for uint256; + using SafeCastLib for int256; + + /// @notice Price feed whose answer is inverted. + IPriceFeed public immutable priceFeed; + /// @notice Number of decimals that the answer of this price feed has, same as the inverted feed's. + uint8 public immutable decimals; + /// @notice Base unit of the inverted feed's answer. + uint256 public immutable baseUnit; + + constructor(IPriceFeed priceFeed_) { + priceFeed = priceFeed_; + decimals = priceFeed_.decimals(); + baseUnit = 10 ** decimals; + } + + /// @notice Returns the inverse of the price feed's latest answer. + /// @dev Rounds up, the conservative direction for debt-only markets, so the inverse can't truncate to zero. + /// Reverts on a zero or negative answer. + function latestAnswer() external view returns (int256) { + return int256(baseUnit.mulDivUp(baseUnit, priceFeed.latestAnswer().toUint256())); + } +} diff --git a/deploy/PriceFeeds.ts b/deploy/PriceFeeds.ts index c8cbd5460..1eb26d168 100644 --- a/deploy/PriceFeeds.ts +++ b/deploy/PriceFeeds.ts @@ -21,6 +21,17 @@ const func: DeployFunction = async ({ ethers, network, deployments: { deploy, ge log: true, }), ); + } else if (priceFeed === "inverse") { + await tenderlify( + "PriceFeedInverse", + await deploy(`PriceFeed${symbol}`, { + skipIfAlreadyDeployed, + contract: "PriceFeedInverse", + args: [(await get(`PriceFeed${symbol}Inverted`)).address], + from: deployer, + log: true, + }), + ); } else if (priceFeed.wrapper) { const { address, abi } = await get(priceFeed.wrapper); await tenderlify( diff --git a/deploy/mocks/Assets.ts b/deploy/mocks/Assets.ts index 92667eaf5..903b08e54 100644 --- a/deploy/mocks/Assets.ts +++ b/deploy/mocks/Assets.ts @@ -42,7 +42,7 @@ const func: DeployFunction = async ({ deployments: { deploy, log }, getNamedAcco from: deployer, log: true, }); - } else if (priceFeed) { + } else if (typeof priceFeed === "object") { await deploy(priceFeed.wrapper, { skipIfAlreadyDeployed: true, contract: "MockStETH", @@ -52,7 +52,9 @@ const func: DeployFunction = async ({ deployments: { deploy, log }, getNamedAcco }); } - await deploy(`PriceFeed${symbol}${priceFeed ? (priceFeed === "double" ? "One" : "Main") : ""}`, { + const sourceSuffix = + priceFeed === "double" ? "One" : priceFeed === "inverse" ? "Inverted" : priceFeed ? "Main" : ""; + await deploy(`PriceFeed${symbol}${sourceSuffix}`, { skipIfAlreadyDeployed: true, contract: "MockPriceFeed", args: [priceDecimals, parseUnits({ WBTC: "63000", WETH: "1000", OP: "3" }[symbol] ?? "1", priceDecimals)], diff --git a/hardhat.config.ts b/hardhat.config.ts index a466b5f28..364004c50 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -553,7 +553,7 @@ declare module "hardhat/types/config" { frozen?: boolean; nonCollateral?: boolean; adjustFactor: number; - priceFeed?: "double" | { wrapper: string; fn: string; baseUnit: bigint }; + priceFeed?: "double" | "inverse" | { wrapper: string; fn: string; baseUnit: bigint }; penaltyRatePerDay: number; treasuryFeeRate: number; backupFeeRate: number; diff --git a/test/PriceFeedInverse.t.sol b/test/PriceFeedInverse.t.sol new file mode 100644 index 000000000..3f91ce618 --- /dev/null +++ b/test/PriceFeedInverse.t.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.17; + +import { Test } from "forge-std/Test.sol"; +import { FixedPointMathLib } from "solady/src/utils/FixedPointMathLib.sol"; +import { SafeCastLib } from "solady/src/utils/SafeCastLib.sol"; +import { Auditor } from "../contracts/Auditor.sol"; +import { PriceFeedInverse } from "../contracts/PriceFeedInverse.sol"; +import { MockPriceFeed } from "../contracts/mocks/MockPriceFeed.sol"; + +contract PriceFeedInverseTest is Test { + using FixedPointMathLib for uint256; + + PriceFeedInverse internal priceFeedInverse; + MockPriceFeed internal usdArsPriceFeed; + + function setUp() external { + usdArsPriceFeed = new MockPriceFeed(8, 1_607.5387e8); + priceFeedInverse = new PriceFeedInverse(usdArsPriceFeed); + } + + function test_constructor_setsPriceFeedDecimalsAndBaseUnit() external view { + assertEq(address(priceFeedInverse.priceFeed()), address(usdArsPriceFeed)); + assertEq(priceFeedInverse.decimals(), 8); + assertEq(priceFeedInverse.baseUnit(), 1e8); + } + + function test_latestAnswer_returnsInverse() external { + assertEq(priceFeedInverse.latestAnswer(), 62_207); + + usdArsPriceFeed.setPrice(159_503_620_000); + assertEq(priceFeedInverse.latestAnswer(), 62_695); + } + + function test_latestAnswer_returnsExactInverse_whenDivisionIsExact() external { + usdArsPriceFeed.setPrice(2e8); + assertEq(priceFeedInverse.latestAnswer(), 0.5e8); + usdArsPriceFeed.setPrice(1e8); + assertEq(priceFeedInverse.latestAnswer(), 1e8); + } + + function test_latestAnswer_matchesGreaterScale_withHighPrices() external { + int256[5] memory prices = [int256(1_607.5387e8), 16_075.387e8, 160_753.87e8, 1e16, 1e16 + 1]; + uint256[5] memory expected = [uint256(62_207), 6_221, 623, 1, 1]; + for (uint256 i = 0; i < prices.length; ++i) { + usdArsPriceFeed.setPrice(prices[i]); + assertEq(priceFeedInverse.latestAnswer(), int256(expected[i])); + assertEq(expected[i], inverseAtScale(uint256(prices[i]), 1e18)); + assertEq(expected[i], inverseAtScale(uint256(prices[i]), 1e36)); + } + } + + function test_latestAnswer_reverts_whenAnswerIsZero() external { + usdArsPriceFeed.setPrice(0); + vm.expectRevert(FixedPointMathLib.MulDivFailed.selector); + priceFeedInverse.latestAnswer(); + } + + function test_latestAnswer_reverts_whenAnswerIsNegative() external { + usdArsPriceFeed.setPrice(-1); + vm.expectRevert(SafeCastLib.Overflow.selector); + priceFeedInverse.latestAnswer(); + + usdArsPriceFeed.setPrice(type(int256).min); + vm.expectRevert(SafeCastLib.Overflow.selector); + priceFeedInverse.latestAnswer(); + } + + function test_latestAnswer_returnsOne_whenAnswerIsAboveBaseUnitSquared() external { + usdArsPriceFeed.setPrice(1e16); + assertEq(priceFeedInverse.latestAnswer(), 1); + + usdArsPriceFeed.setPrice(1e16 + 1); + assertEq(priceFeedInverse.latestAnswer(), 1); + } + + function test_assetPrice_returnsPrice_whenAnswerIsAboveBaseUnitSquared() external { + Auditor auditor = new Auditor(8); + usdArsPriceFeed.setPrice(1e16 + 1); + assertEq(auditor.assetPrice(priceFeedInverse), 1e10); + } + + function testFuzz_latestAnswer_returnsOne_whenAnswerIsAboveBaseUnitSquared(int256 price) external { + price = bound(price, 1e16, 2 ** 176 - 1); + usdArsPriceFeed.setPrice(price); + assertEq(priceFeedInverse.latestAnswer(), 1); + assertEq(new Auditor(8).assetPrice(priceFeedInverse), 1e10); + } + + function testFuzz_latestAnswer_roundsUp(int256 price) external { + price = bound(price, 1, 2 ** 176 - 1); + usdArsPriceFeed.setPrice(price); + + uint256 roundedDown = 1e16 / uint256(price); + bool isExact = 1e16 % uint256(price) == 0; + assertEq(priceFeedInverse.latestAnswer(), int256(isExact ? roundedDown : roundedDown + 1)); + } + + function testFuzz_latestAnswer_matchesGreaterScale(int256 price) external { + price = bound(price, 1, 2 ** 176 - 1); + usdArsPriceFeed.setPrice(price); + + uint256 answer = uint256(priceFeedInverse.latestAnswer()); + assertEq(answer, inverseAtScale(uint256(price), 1e18)); + assertEq(answer, inverseAtScale(uint256(price), 1e36)); + } + + function testFuzz_latestAnswer_reverts_whenAnswerIsNegative(int256 price) external { + price = bound(price, type(int256).min, -1); + usdArsPriceFeed.setPrice(price); + vm.expectRevert(SafeCastLib.Overflow.selector); + priceFeedInverse.latestAnswer(); + } + + function inverseAtScale(uint256 price, uint256 scale) internal pure returns (uint256) { + return scale.mulDivUp(1e8, price).mulDivUp(1e8, scale); + } +}