From dba8dff9cf6c22c919fc65b9f04a0de8bc859732 Mon Sep 17 00:00:00 2001 From: Abhishek Krishna Date: Mon, 1 Jun 2026 14:41:57 +0000 Subject: [PATCH] test(por): foundry suite for ChainlinkPoRAdapter + make forge test discover forge-test/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ChainlinkPoRAdapter contract had no Foundry coverage — only a hardhat JS test under test/ChainlinkPoR.test.js. The four sibling contracts (Stablecoin, Minter, ReserveManager, ComplianceModule) each have a *.t.sol under forge-test/, so this PR brings PoR up to parity. There were also two pieces of incidental setup that prevented forge test from finding any tests at all: 1. foundry.toml did not set test = "forge-test". The repo deliberately uses forge-test/ (not the default test/) to coexist with hardhat, but the foundry profile never pointed forge at it. Without this, running forge test on a clean clone reports "No tests found in project!" even though the four existing .t.sol files compile. 2. forge-std was not present and not referenced from remappings.txt or .gitmodules — added forge-std/=lib/forge-std/src/ + git submodule for the canonical Foundry test library. Tests added (forge-test/ChainlinkPoRAdapter.t.sol, 19 tests / 1 fuzz): Constructor (4) - sets porFeed - rejects zero address -> FeedNotAvailable - emits PorFeedSet - FEED_DECIMALS == 8 getLatestReserveAmount (5) - happy path returns the mock answer - reflects updated mock answer - reverts on negative answer -> InvalidFeedAnswer - reverts on feed call failure -> FeedNotAvailable (via a RevertingAggregator mock defined inline in the test file) - propagates the custom timestamp set via setAnswerWithTimestamp convertToStablecoinUnits (5, one fuzz) - 50_000_000_000 (8 dec) -> 500_000_000 (6 dec) round trip - zero -> zero - values smaller than 100 truncate to zero (documented behaviour pinned by the test so we notice if integer math changes) - large value (1_000_000_000_000) -> 10_000_000_000 - fuzz: convertToStablecoinUnits(uint128 raw) == raw / 100 getReserveInStablecoinUnits (3) - happy path: reserve = INITIAL_ANSWER / 100 - emits ReserveDataPulled - reverts on negative answer getFeedInfo (2) - returns mock description + decimals - falls back to ("", FEED_DECIMALS) when the feed reverts Verification: forge test Ran 5 test suites in 54.60ms (165.77ms CPU time): 60 tests passed, 0 failed, 0 skipped forge test --match-path "forge-test/ChainlinkPoRAdapter.t.sol" -v Suite result: ok. 19 passed; 0 failed; 0 skipped; 8.59ms. 41 pre-existing tests + 19 new = 60/60 green. No regression in the other contracts. --- .gitmodules | 3 + forge-test/ChainlinkPoRAdapter.t.sol | 180 +++++++++++++++++++++++++++ foundry.toml | 1 + lib/forge-std | 1 + remappings.txt | 1 + 5 files changed, 186 insertions(+) create mode 100644 .gitmodules create mode 100644 forge-test/ChainlinkPoRAdapter.t.sol create mode 160000 lib/forge-std diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..888d42d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/forge-test/ChainlinkPoRAdapter.t.sol b/forge-test/ChainlinkPoRAdapter.t.sol new file mode 100644 index 0000000..c6c330e --- /dev/null +++ b/forge-test/ChainlinkPoRAdapter.t.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../contracts/ChainlinkPoRAdapter.sol"; +import "../contracts/mocks/ChainlinkPoRMock.sol"; +import "../contracts/interfaces/IAggregatorV3.sol"; + +/// @dev A minimal aggregator that always reverts on latestRoundData() so we +/// can exercise the FeedNotAvailable revert branch of getLatestReserveAmount. +contract RevertingAggregator is AggregatorV3Interface { + function description() external pure override returns (string memory) { + revert("description-not-available"); + } + + function decimals() external pure override returns (uint8) { + revert("decimals-not-available"); + } + + function version() external pure override returns (uint256) { + return 0; + } + + function getRoundData(uint80) external pure override returns (uint80, int256, uint256, uint256, uint80) { + revert("getRoundData-not-available"); + } + + function latestRoundData() external pure override returns (uint80, int256, uint256, uint256, uint80) { + revert("latestRoundData-not-available"); + } +} + +contract ChainlinkPoRAdapterTest is Test { + ChainlinkPoRAdapter internal adapter; + ChainlinkPoRMock internal mock; + + int256 internal constant INITIAL_ANSWER = 50_000_000_000; // 500 USD @ 8 decimals + + event PorFeedSet(address indexed feed); + event ReserveDataPulled(uint256 reserveAmount, uint256 timestamp); + + function setUp() public { + mock = new ChainlinkPoRMock(INITIAL_ANSWER); + adapter = new ChainlinkPoRAdapter(address(mock)); + } + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + function test_constructor_setsPorFeed() public view { + assertEq(address(adapter.porFeed()), address(mock)); + } + + function test_constructor_rejectsZeroAddress() public { + vm.expectRevert(ChainlinkPoRAdapter.FeedNotAvailable.selector); + new ChainlinkPoRAdapter(address(0)); + } + + function test_constructor_emitsPorFeedSet() public { + ChainlinkPoRMock m = new ChainlinkPoRMock(0); + vm.expectEmit(true, false, false, false); + emit PorFeedSet(address(m)); + new ChainlinkPoRAdapter(address(m)); + } + + function test_feedDecimalsConstantIs8() public view { + assertEq(adapter.FEED_DECIMALS(), 8); + } + + // ------------------------------------------------------------------------- + // getLatestReserveAmount + // ------------------------------------------------------------------------- + + function test_getLatestReserveAmount_happyPath() public view { + (uint256 amount, uint256 updatedAt) = adapter.getLatestReserveAmount(); + assertEq(amount, uint256(INITIAL_ANSWER)); + assertGt(updatedAt, 0); + } + + function test_getLatestReserveAmount_reflectsUpdatedMockAnswer() public { + int256 next = 75_000_000_000; + mock.setAnswer(next); + (uint256 amount, ) = adapter.getLatestReserveAmount(); + assertEq(amount, uint256(next)); + } + + function test_getLatestReserveAmount_revertsOnNegativeAnswer() public { + mock.setAnswer(-1); + vm.expectRevert(ChainlinkPoRAdapter.InvalidFeedAnswer.selector); + adapter.getLatestReserveAmount(); + } + + function test_getLatestReserveAmount_revertsOnFeedCallFailure() public { + // Point a fresh adapter at a feed that always reverts on latestRoundData() + // — the adapter must surface this as FeedNotAvailable, not as the raw revert. + RevertingAggregator bad = new RevertingAggregator(); + ChainlinkPoRAdapter local = new ChainlinkPoRAdapter(address(bad)); + vm.expectRevert(ChainlinkPoRAdapter.FeedNotAvailable.selector); + local.getLatestReserveAmount(); + } + + function test_getLatestReserveAmount_propagatesCustomTimestamp() public { + // setAnswerWithTimestamp lets the test pin the updatedAt value. + mock.setAnswerWithTimestamp(60_000_000_000, 1_234_567_890); + (, uint256 updatedAt) = adapter.getLatestReserveAmount(); + assertEq(updatedAt, 1_234_567_890); + } + + // ------------------------------------------------------------------------- + // convertToStablecoinUnits — 8 dec feed -> 6 dec stablecoin + // ------------------------------------------------------------------------- + + function test_convertToStablecoinUnits_500usd() public view { + // 50_000_000_000 (8 dec) -> 500_000_000 (6 dec) = 500 USD + assertEq(adapter.convertToStablecoinUnits(50_000_000_000), 500_000_000); + } + + function test_convertToStablecoinUnits_zero() public view { + assertEq(adapter.convertToStablecoinUnits(0), 0); + } + + function test_convertToStablecoinUnits_smallerThan100_truncatesToZero() public view { + // 99 raw (8 dec) -> 0 (6 dec), because 99 / 100 = 0 in integer math. + // This is the documented behaviour; the test pins it so we notice if it changes. + assertEq(adapter.convertToStablecoinUnits(99), 0); + } + + function test_convertToStablecoinUnits_largeValue() public view { + // 1_000_000_000_000 raw -> 10_000_000_000 (10K USD at 6 dec) + assertEq(adapter.convertToStablecoinUnits(1_000_000_000_000), 10_000_000_000); + } + + function testFuzz_convertToStablecoinUnits_divBy100(uint128 raw) public view { + // Property: convertToStablecoinUnits = raw / 100. Capped to uint128 to keep + // the arithmetic inside the documented PoR feed range. + assertEq(adapter.convertToStablecoinUnits(raw), uint256(raw) / 100); + } + + // ------------------------------------------------------------------------- + // getReserveInStablecoinUnits — pull + convert + emit + // ------------------------------------------------------------------------- + + function test_getReserveInStablecoinUnits_happyPath() public { + (uint256 reserve, uint256 updatedAt) = adapter.getReserveInStablecoinUnits(); + assertEq(reserve, uint256(INITIAL_ANSWER) / 100); + assertGt(updatedAt, 0); + } + + function test_getReserveInStablecoinUnits_emitsReserveDataPulled() public { + vm.expectEmit(false, false, false, false); + emit ReserveDataPulled(uint256(INITIAL_ANSWER) / 100, block.timestamp); + adapter.getReserveInStablecoinUnits(); + } + + function test_getReserveInStablecoinUnits_revertsOnNegativeAnswer() public { + mock.setAnswer(-42); + vm.expectRevert(ChainlinkPoRAdapter.InvalidFeedAnswer.selector); + adapter.getReserveInStablecoinUnits(); + } + + // ------------------------------------------------------------------------- + // getFeedInfo — metadata fallthrough + // ------------------------------------------------------------------------- + + function test_getFeedInfo_returnsMockDescriptionAndDecimals() public view { + (string memory description, uint8 decimals_) = adapter.getFeedInfo(); + assertEq(description, "Mock PoR Feed"); + assertEq(decimals_, 8); + } + + function test_getFeedInfo_fallsBackWhenFeedReverts() public { + // Point at the reverting aggregator — adapter must return ("", FEED_DECIMALS). + RevertingAggregator bad = new RevertingAggregator(); + ChainlinkPoRAdapter local = new ChainlinkPoRAdapter(address(bad)); + (string memory description, uint8 decimals_) = local.getFeedInfo(); + assertEq(description, ""); + assertEq(decimals_, 8); // FEED_DECIMALS fallback + } +} diff --git a/foundry.toml b/foundry.toml index d890d5a..ee189ee 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,5 +1,6 @@ [profile.default] src = "contracts" +test = "forge-test" out = "forge-out" libs = ["node_modules"] solc = "0.8.24" diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..b3bc8b1 --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit b3bc8b154382a75d0b0ef22d7fd4a0a5f0feee0e diff --git a/remappings.txt b/remappings.txt index 0c071e4..3c43954 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1 +1,2 @@ @openzeppelin/contracts/=node_modules/@openzeppelin/contracts/ +forge-std/=lib/forge-std/src/