From 53db6933e4e9a2d53d11e766cd8c1953b3d4f56a Mon Sep 17 00:00:00 2001 From: Pattermesh Date: Tue, 30 Jun 2026 01:06:13 +0530 Subject: [PATCH] test: strengthen core issuance, reserve, and DepegGuard coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three focused Foundry suites on the toolkit core. All complement the existing tests rather than replace them; the full suite stays green (145 passed, 0 failed). - MinterIssuance.t.sol (12): mint/burn against reserves — reserve-ratio gate reverts on under-collateralization, exact-ceiling mint, tracked-supply propagation, raised-minimum-ratio gate, authorize/revoke lifecycle, owner-implicit authorization, compliance enforced through the minter (non-KYC + daily-limit), fee/collector routing, and redeem burn accounting. - ReserveManagerPoR.t.sol (16): Chainlink PoR pull path (setPorAdapter, pullPorReserve decimal conversion + idempotent ids + manual coexistence, pullPorReserveAndCheck pass/fail/unset), inactive-asset guard, re-add overwrite without duplicate id, emitted events, and onlyOwner gating. - DepegGuardStateMachine.t.sol (11): direct Normal->Hard escalation, Hard pin in the caution band, hysteresis hold, real Minter authorize/revoke side-effects under guard ownership, emergency-escalate no-op, pending-clear on snap-back to peg, and the remaining tuning setters (durations/staleness/ pegTarget incl. deviation recomputation). Also gitignores forge-out/ build output and package-lock.json. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 + forge-test/DepegGuardStateMachine.t.sol | 259 ++++++++++++++++++++ forge-test/MinterIssuance.t.sol | 305 ++++++++++++++++++++++++ forge-test/ReserveManagerPoR.t.sol | 213 +++++++++++++++++ 4 files changed, 779 insertions(+) create mode 100644 forge-test/DepegGuardStateMachine.t.sol create mode 100644 forge-test/MinterIssuance.t.sol create mode 100644 forge-test/ReserveManagerPoR.t.sol diff --git a/.gitignore b/.gitignore index 7778850..836174f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ node_modules/ artifacts/ cache/ +forge-out/ typechain-types/ coverage/ coverage.json .env *.log +package-lock.json diff --git a/forge-test/DepegGuardStateMachine.t.sol b/forge-test/DepegGuardStateMachine.t.sol new file mode 100644 index 0000000..6e3d62e --- /dev/null +++ b/forge-test/DepegGuardStateMachine.t.sol @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../contracts/DepegGuard.sol"; +import "../contracts/Stablecoin.sol"; +import "../contracts/Minter.sol"; +import "../contracts/ReserveManager.sol"; +import "../contracts/ComplianceModule.sol"; +import "../contracts/mocks/ChainlinkPoRMock.sol"; + +/// @title DepegGuardStateMachineTest +/// @notice Strengthens the DepegGuard state-machine coverage with the +/// transition paths the existing DepegGuard.t.sol does not exercise: +/// the direct Normal -> Hard escalation, the Hard -> Caution step, the +/// hysteresis hold band, the no-op governance escalate, observed +/// mitigation side-effects on the *real* Minter (authorize/revoke), +/// and the remaining tuning setters (durations / staleness / pegTarget). +contract DepegGuardStateMachineTest is Test { + DepegGuard public guard; + Stablecoin public coin; + Minter public minter; + ReserveManager public rm; + ComplianceModule public cm; + ChainlinkPoRMock public feed; + + address public admin = address(0xA); + address public feeCollector = address(0xC); + address public randomCaller = address(0xD); + + uint256 public constant PEG = 1e8; + + function setUp() public { + vm.startPrank(admin); + + coin = new Stablecoin("Test Stable", "TSTB", admin); + rm = new ReserveManager(10_000); + cm = new ComplianceModule(); + minter = new Minter(address(coin), address(rm), address(cm), 0, 0, feeCollector); + + feed = new ChainlinkPoRMock(int256(PEG)); + guard = new DepegGuard(address(coin), address(minter), address(feed), admin); + + coin.grantRole(coin.PAUSER_ROLE(), address(guard)); + // Hand the Minter to the guard so authorize/revokeMinter side-effects + // actually execute (not just emit from the catch branch). + minter.transferOwnership(address(guard)); + + vm.stopPrank(); + } + + function _setPrice(int256 answer) internal { + feed.setAnswerWithTimestamp(answer, block.timestamp); + } + + function _state() internal view returns (uint256) { + return uint256(guard.currentState()); + } + + // ==================== Direct Normal -> Hard ==================== + + /// @dev A deviation already past the hard threshold escalates straight to + /// Hard after one observation window — it does not stop at Caution. + function test_normalToHard_directEscalation() public { + _setPrice(int256(PEG - PEG / 40)); // -250 bps, well past hardBps + guard.poke(); // arms pending = Hard + assertEq(_state(), uint256(DepegGuard.State.Normal)); + + vm.warp(block.timestamp + guard.minObservationSeconds() + 1); + guard.poke(); + + assertEq(_state(), uint256(DepegGuard.State.Hard)); + assertTrue(coin.paused(), "Hard must pause the stablecoin"); + // Minter ownership is the guard's; revokeMinter(guard) was a no-op on + // authorization state (guard was never an authorized minter), but the + // call path must not have reverted. + assertFalse(guard.mintAllowed()); + assertFalse(guard.redeemAllowed()); + } + + // ==================== Hard -> Caution step-down ==================== + + /// @dev From Hard, a price that recovers only into the Caution band (still + /// >= cautionBps) cannot auto-clear: _observationTarget pins it to Hard + /// until the price reaches the recovery band. Then, after the hard-halt + /// timer, it can step down. We drive Hard -> Caution explicitly via a + /// price in the caution band AFTER the halt elapses by routing through + /// Normal is not possible in one hop; assert the pin-to-Hard behavior. + function test_hard_pinsToHard_whilePriceStaysInCautionBand() public { + // Enter Hard. + _setPrice(int256(PEG - PEG / 40)); + guard.poke(); + vm.warp(block.timestamp + guard.minObservationSeconds() + 1); + guard.poke(); + assertEq(_state(), uint256(DepegGuard.State.Hard)); + + // Price improves but only into the caution band (-100 bps, between + // cautionBps=50 and hardBps=200). The guard must remain Hard. + _setPrice(int256(PEG - PEG / 100)); + guard.poke(); + vm.warp(block.timestamp + guard.minRecoverySeconds() + 1); + guard.poke(); + assertEq(_state(), uint256(DepegGuard.State.Hard), "must pin to Hard in caution band"); + } + + // ==================== Hysteresis hold band ==================== + + /// @dev In the band between recoveryCeilingBps and cautionBps, the target + /// equals the current state — Caution holds rather than de-escalating. + function test_hysteresisBand_holdsCaution() public { + // Enter Caution at -100 bps. + _setPrice(int256(PEG - PEG / 100)); + guard.poke(); + vm.warp(block.timestamp + guard.minObservationSeconds() + 1); + guard.poke(); + assertEq(_state(), uint256(DepegGuard.State.Caution)); + + // Recover into the hysteresis band: between recoveryCeiling (25 bps) + // and caution (50 bps). Pick -40 bps. Must hold Caution, not recover. + _setPrice(int256(PEG - (PEG * 40) / 10_000)); + guard.poke(); + vm.warp(block.timestamp + guard.minRecoverySeconds() + 1); + guard.poke(); + assertEq(_state(), uint256(DepegGuard.State.Caution), "hysteresis band must hold Caution"); + assertFalse(guard.mintAllowed()); + } + + // ==================== Minter authorize/revoke side-effects ==================== + + /// @dev When the guard owns the Minter, escalating to Caution revokes the + /// guard as a minter and recovering to Normal re-authorizes it. We use + /// authorizedMinters(guard) as the observable proof the try-call ran. + function test_cautionRevokesMinter_normalReauthorizes() public { + // The Minter is owned by the guard (see setUp), so the guard's internal + // revoke/authorizeMinter try-calls actually execute against real + // authorization state rather than only emitting from the catch branch. + // Escalating Normal->Caution revokes the guard; recovering Caution-> + // Normal re-authorizes it. + _setPrice(int256(PEG - PEG / 100)); + guard.poke(); + vm.warp(block.timestamp + guard.minObservationSeconds() + 1); + guard.poke(); + assertEq(_state(), uint256(DepegGuard.State.Caution)); + assertFalse(minter.authorizedMinters(address(guard)), "revoked in Caution"); + + // Recover to Normal; the Caution->Normal transition authorizes the guard. + _setPrice(int256(PEG)); + guard.poke(); + vm.warp(block.timestamp + guard.minRecoverySeconds() + 1); + guard.poke(); + assertEq(_state(), uint256(DepegGuard.State.Normal)); + assertTrue(minter.authorizedMinters(address(guard)), "re-authorized in Normal"); + } + + /// @dev Emergency-escalating to Hard pauses the coin and revokes the minter; + /// resetState back to Normal unpauses and re-authorizes. + function test_emergencyEscalateThenReset_togglesMinterAndPause() public { + vm.prank(admin); + guard.emergencyEscalate(DepegGuard.State.Hard); + assertEq(_state(), uint256(DepegGuard.State.Hard)); + assertTrue(coin.paused()); + assertFalse(minter.authorizedMinters(address(guard))); + + vm.prank(admin); + guard.resetState(DepegGuard.State.Normal); + assertEq(_state(), uint256(DepegGuard.State.Normal)); + assertFalse(coin.paused()); + assertTrue(minter.authorizedMinters(address(guard))); + } + + // ==================== No-op governance escalate ==================== + + function test_emergencyEscalate_toSameState_isNoOp() public { + // Already Normal; escalating to Normal must not pause or change state. + uint256 enteredBefore = guard.stateEnteredAt(); + vm.prank(admin); + guard.emergencyEscalate(DepegGuard.State.Normal); + assertEq(_state(), uint256(DepegGuard.State.Normal)); + assertEq(guard.stateEnteredAt(), enteredBefore, "no-op must not bump stateEnteredAt"); + assertFalse(coin.paused()); + } + + // ==================== poke at-peg in non-normal clears pending ==================== + + /// @dev Arming a pending escalation and then seeing the price snap back to + /// peg must clear the pending observation so the timer restarts. + function test_poke_atPegClearsPendingObservation() public { + _setPrice(int256(PEG - PEG / 100)); // caution band + guard.poke(); + assertGt(guard.pendingObservationSince(), 0); + assertEq(uint256(guard.pendingState()), uint256(DepegGuard.State.Caution)); + + // Snap back to peg before the window elapses. + _setPrice(int256(PEG)); + guard.poke(); + assertEq(guard.pendingObservationSince(), 0, "pending cleared at peg"); + assertEq(_state(), uint256(DepegGuard.State.Normal)); + } + + // ==================== Remaining tuning setters ==================== + + function test_setDurations_validAndInvalid() public { + vm.startPrank(admin); + vm.expectRevert(DepegGuard.InvalidDurations.selector); + guard.setDurations(0, 1800, 3600); // minObs == 0 + + vm.expectRevert(DepegGuard.InvalidDurations.selector); + guard.setDurations(600, 0, 3600); // minRec == 0 + + guard.setDurations(900, 2400, 7200); + vm.stopPrank(); + + assertEq(guard.minObservationSeconds(), 900); + assertEq(guard.minRecoverySeconds(), 2400); + assertEq(guard.hardRedeemHaltSeconds(), 7200); + } + + function test_setStaleness_validAndInvalid() public { + vm.startPrank(admin); + vm.expectRevert(DepegGuard.InvalidDurations.selector); + guard.setStaleness(0); + + guard.setStaleness(2 hours); + vm.stopPrank(); + assertEq(guard.maxFeedStaleSeconds(), 2 hours); + } + + function test_setPegTarget_validAndInvalid() public { + vm.startPrank(admin); + vm.expectRevert(DepegGuard.InvalidThresholds.selector); + guard.setPegTarget(0); + + guard.setPegTarget(1e6); // a 6-decimal feed peg + vm.stopPrank(); + assertEq(guard.pegTarget(), 1e6); + } + + /// @dev After lowering the peg target, the same raw price reads as a larger + /// deviation — proves setPegTarget feeds into currentDeviationBps. + function test_setPegTarget_changesDeviationComputation() public { + // At peg 1e8, price 1e8 = 0 bps deviation. + (uint256 devAtDefault, , ) = guard.currentDeviationBps(); + assertEq(devAtDefault, 0); + + // Move the peg target up 1%; price now reads 100 bps below peg. + vm.prank(admin); + guard.setPegTarget(PEG + PEG / 100); + _setPrice(int256(PEG)); + (uint256 devAfter, , ) = guard.currentDeviationBps(); + // |1e8 - 1.01e8| / 1.01e8 ~= 99 bps (integer-truncated). + assertApproxEq(devAfter, 99, 1); + } + + // Local helper since the pinned forge-std shim style favors explicit asserts. + function assertApproxEq(uint256 a, uint256 b, uint256 tol) internal pure { + uint256 diff = a > b ? a - b : b - a; + assertTrue(diff <= tol, "assertApproxEq"); + } +} diff --git a/forge-test/MinterIssuance.t.sol b/forge-test/MinterIssuance.t.sol new file mode 100644 index 0000000..7b27a11 --- /dev/null +++ b/forge-test/MinterIssuance.t.sol @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../contracts/Stablecoin.sol"; +import "../contracts/Minter.sol"; +import "../contracts/ReserveManager.sol"; +import "../contracts/ComplianceModule.sol"; + +/// @title MinterIssuanceTest +/// @notice Strengthens issuance coverage: mint/burn against reserves, the +/// reserve-ratio gate, authorization lifecycle, compliance enforcement +/// through the minter, and redeem accounting. Complements the existing +/// Minter.t.sol happy-path/fee tests with the revert and bookkeeping +/// paths that "mint against reserves" actually depends on. +contract MinterIssuanceTest is Test { + Stablecoin public coin; + Minter public minter; + ReserveManager public rm; + ComplianceModule public cm; + + address public admin = address(1); + address public user1 = address(2); + address public user2 = address(4); + address public feeCollector = address(3); + + bytes2 constant US = bytes2("US"); + + // Reserves are tracked in 6-decimal stablecoin units. Seed with 100k tokens. + uint256 constant RESERVES = 100_000_000_000; + + event Minted(address indexed to, uint256 amount, uint256 fee); + event RedemptionQueued(uint256 indexed id, address indexed redeemer, uint256 amount); + event MinterRevoked(address indexed minter); + + function setUp() public { + vm.startPrank(admin); + + coin = new Stablecoin("Test Stablecoin", "TSTBL", admin); + rm = new ReserveManager(10_000); // 100% minimum ratio + cm = new ComplianceModule(); + + // Seed reserves and compliance BEFORE handing ownership to the minter. + rm.addReserveAsset(keccak256("USD_BANK"), "USD Bank", RESERVES); + + cm.setKYC(user1, ComplianceModule.KYCStatus.Approved); + cm.setGeography(user1, US); + cm.setKYC(user2, ComplianceModule.KYCStatus.Approved); + cm.setGeography(user2, US); + // Wide per-tx and daily limits so compliance never masks the reserve + // gate in the over-collateralization tests. The daily-spend test wires + // its own tighter geo (see test_mint_recordsDailySpendForCompliance). + cm.configureGeography(US, true, type(uint256).max, type(uint256).max); + + minter = new Minter( + address(coin), + address(rm), + address(cm), + 10, // 0.1% mint fee + 10, // 0.1% redeem fee + feeCollector + ); + + coin.grantRole(coin.MINTER_ROLE(), address(minter)); + rm.transferOwnership(address(minter)); + cm.transferOwnership(address(minter)); + + minter.authorizeMinter(admin); + + vm.stopPrank(); + } + + // ==================== Mint against reserves ==================== + + /// @dev The core "mint against reserves" guarantee: a mint that would push + /// tracked supply above what reserves can back must revert. + function test_mint_revertsWhenReservesInsufficient() public { + // Reserves back exactly RESERVES tokens at a 100% ratio. One unit over + // the reserve total drops the ratio below 10_000 bps. + uint256 over = RESERVES + 1; + + vm.prank(admin); + vm.expectRevert( + abi.encodeWithSelector( + ReserveManager.ReserveRatioTooLow.selector, + (RESERVES * 10_000) / over, + 10_000 + ) + ); + minter.mint(user1, over); + + // No tokens were minted and supply did not move. + assertEq(coin.totalSupply(), 0); + assertEq(coin.balanceOf(user1), 0); + } + + /// @dev Minting exactly up to the reserve ceiling is allowed. + function test_mint_atExactReserveCeiling_succeeds() public { + vm.prank(admin); + minter.mint(user1, RESERVES); + + assertEq(coin.totalSupply(), RESERVES); + assertEq(rm.totalSupplyTracked(), RESERVES); + assertEq(rm.getReserveRatioBps(), 10_000); + } + + /// @dev A successful mint must propagate the new supply into the + /// ReserveManager so subsequent ratio checks are accurate. + function test_mint_updatesTrackedSupply() public { + vm.prank(admin); + minter.mint(user1, 1_000_000); + assertEq(rm.totalSupplyTracked(), 1_000_000); + + vm.prank(admin); + minter.mint(user1, 2_000_000); + assertEq(rm.totalSupplyTracked(), 3_000_000); + } + + /// @dev When the reserve ratio requirement is raised above 100%, the same + /// mint that previously fit now fails the gate. + function test_mint_respectsRaisedMinimumRatio() public { + // ReserveManager.setMinimumRatio is onlyOwner and the RM here is owned + // by the minter, so validate the raised-ratio gate via a fresh, + // self-contained 105%-collateralized stack. + vm.startPrank(admin); + Stablecoin coin105 = new Stablecoin("Strict", "STR", admin); + ReserveManager rm105 = new ReserveManager(10_500); + ComplianceModule cm105 = new ComplianceModule(); + rm105.addReserveAsset(keccak256("USD_BANK"), "USD Bank", RESERVES); + cm105.setKYC(user1, ComplianceModule.KYCStatus.Approved); + cm105.setGeography(user1, US); + cm105.configureGeography(US, true, type(uint256).max, type(uint256).max); + + Minter strictMinter = new Minter( + address(coin105), address(rm105), address(cm105), 0, 0, feeCollector + ); + coin105.grantRole(coin105.MINTER_ROLE(), address(strictMinter)); + rm105.transferOwnership(address(strictMinter)); + cm105.transferOwnership(address(strictMinter)); + strictMinter.authorizeMinter(admin); + vm.stopPrank(); + + // Minting the full reserve amount yields only a 100% ratio < 105%. + vm.prank(admin); + vm.expectRevert( + abi.encodeWithSelector( + ReserveManager.ReserveRatioTooLow.selector, 10_000, 10_500 + ) + ); + strictMinter.mint(user1, RESERVES); + + // Minting within the 105% headroom succeeds. + vm.prank(admin); + strictMinter.mint(user1, (RESERVES * 10_000) / 10_500); + assertGt(coin105.balanceOf(user1), 0); + } + + // ==================== Authorization lifecycle ==================== + + function test_authorizeThenRevoke_blocksMinting() public { + vm.prank(admin); + minter.authorizeMinter(user2); + + // user2 can mint while authorized. + vm.prank(user2); + minter.mint(user1, 1_000_000); + assertEq(coin.balanceOf(user1), 999_000); + + // Revoke and confirm the event, then confirm the mint is blocked. + vm.prank(admin); + vm.expectEmit(true, false, false, false); + emit MinterRevoked(user2); + minter.revokeMinter(user2); + + vm.prank(user2); + vm.expectRevert(Minter.NotAuthorizedMinter.selector); + minter.mint(user1, 1_000_000); + } + + /// @dev The owner is always an implicit authorized minter even without an + /// explicit authorizeMinter call. + function test_owner_canMintWithoutExplicitAuthorization() public { + vm.prank(admin); + minter.revokeMinter(admin); // explicitly clear the flag set in setUp + + vm.prank(admin); + minter.mint(user1, 1_000_000); // still works: msg.sender == owner() + assertEq(coin.balanceOf(user1), 999_000); + } + + // ==================== Compliance enforced through the minter ==================== + + function test_mint_revertsForNonKYCedRecipient() public { + address stranger = address(0xBEEF); + vm.prank(admin); + vm.expectRevert( + abi.encodeWithSelector(ComplianceModule.NotKYCApproved.selector, stranger) + ); + minter.mint(stranger, 1_000_000); + } + + function test_mint_recordsDailySpendForCompliance() public { + // Self-contained wiring with a tight daily limit so the second mint + // trips ExceedsDailyLimit via recordSpend bookkeeping. Reserves are + // generous so the daily limit, not the reserve gate, is the binding + // constraint. + uint256 dailyLimit = 1_000_000; + + vm.startPrank(admin); + Stablecoin coin2 = new Stablecoin("Daily", "DLY", admin); + ReserveManager rm2 = new ReserveManager(10_000); + ComplianceModule cm2 = new ComplianceModule(); + rm2.addReserveAsset(keccak256("BANK"), "Bank", RESERVES); + cm2.setKYC(user1, ComplianceModule.KYCStatus.Approved); + cm2.setGeography(user1, US); + cm2.configureGeography(US, true, type(uint256).max, dailyLimit); + + Minter minter2 = new Minter( + address(coin2), address(rm2), address(cm2), 0, 0, feeCollector + ); + coin2.grantRole(coin2.MINTER_ROLE(), address(minter2)); + rm2.transferOwnership(address(minter2)); + cm2.transferOwnership(address(minter2)); + minter2.authorizeMinter(admin); + + // Mint the full daily limit. + minter2.mint(user1, dailyLimit); + + // A further mint of 1 exceeds the daily limit. + vm.expectRevert( + abi.encodeWithSelector( + ComplianceModule.ExceedsDailyLimit.selector, dailyLimit + 1, dailyLimit + ) + ); + minter2.mint(user1, 1); + vm.stopPrank(); + } + + // ==================== Fee / toll boundary ==================== + + /// @dev With a 100% mint fee configured, an amount of 1 leaves nothing for + /// the recipient but does not revert (fee == amount, not fee > amount). + /// Pushing the fee strictly over the amount is impossible via bps, so + /// assert the netAmount-zero boundary behaves and emits. + function test_mint_fullFee_mintsOnlyToCollector() public { + vm.prank(admin); + minter.setFees(10_000, 0); // 100% mint fee + + vm.prank(admin); + vm.expectEmit(true, false, false, true); + emit Minted(user1, 0, 1_000_000); + minter.mint(user1, 1_000_000); + + assertEq(coin.balanceOf(user1), 0); + assertEq(coin.balanceOf(feeCollector), 1_000_000); + } + + function test_setFeeCollector_routesFutureFees() public { + address newCollector = address(0xC0FFEE); + vm.prank(admin); + minter.setFeeCollector(newCollector); + assertEq(minter.feeCollector(), newCollector); + + vm.prank(admin); + minter.mint(user1, 1_000_000); + assertEq(coin.balanceOf(newCollector), 1_000); // 0.1% fee + assertEq(coin.balanceOf(feeCollector), 0); + } + + // ==================== Redeem burns against supply ==================== + + function test_redeem_burnsSupplyAndQueues() public { + vm.prank(admin); + minter.mint(user1, 1_000_000); // user1 holds 999_000 net + + uint256 supplyBefore = coin.totalSupply(); + + vm.prank(user1); + coin.approve(address(minter), type(uint256).max); + + vm.prank(user1); + vm.expectEmit(true, true, false, true); + // burnAmount == amount (no toll); redeemAmount = amount - fee. + emit RedemptionQueued(0, user1, 500_000 - (500_000 * 10) / 10_000); + minter.redeem(500_000); + + // The full redeem amount is burned from the user, reducing supply. + assertEq(coin.totalSupply(), supplyBefore - 500_000); + assertEq(coin.balanceOf(user1), 999_000 - 500_000); + + // Tracked supply in the ReserveManager mirrors the burn. + assertEq(rm.totalSupplyTracked(), supplyBefore - 500_000); + assertEq(minter.getRedemptionCount(), 1); + } + + function test_redeem_revertsWithoutApproval() public { + vm.prank(admin); + minter.mint(user1, 1_000_000); + + // No approve() — burnFrom must revert on insufficient allowance. + vm.prank(user1); + vm.expectRevert(); + minter.redeem(100_000); + } +} diff --git a/forge-test/ReserveManagerPoR.t.sol b/forge-test/ReserveManagerPoR.t.sol new file mode 100644 index 0000000..801fe74 --- /dev/null +++ b/forge-test/ReserveManagerPoR.t.sol @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../contracts/ReserveManager.sol"; +import "../contracts/ChainlinkPoRAdapter.sol"; +import "../contracts/mocks/ChainlinkPoRMock.sol"; + +/// @title ReserveManagerPoRTest +/// @notice Strengthens reserve-management coverage on the parts the existing +/// ReserveManager.t.sol skips: the Chainlink PoR pull path, the +/// setPorAdapter / pullPorReserve / pullPorReserveAndCheck branches, +/// asset re-add semantics, inactive-asset guards, and emitted events. +contract ReserveManagerPoRTest is Test { + ReserveManager public rm; + ChainlinkPoRAdapter public adapter; + ChainlinkPoRMock public feed; + + address public admin = address(0xA); + bytes32 public usdBank = keccak256("USD_BANK"); + + // 1,000,000 USD at the feed's 8 decimals => converts to 6-dec units / 100. + int256 constant FEED_ANSWER = 100_000_000_000_000; // 1e14 + + event ReserveUpdated(bytes32 indexed assetId, string name, uint256 amount); + event PorAdapterSet(address indexed adapter); + event PorReservePulled(uint256 amount, uint256 updatedAt); + event SupplyUpdated(uint256 newSupply); + event MinimumRatioUpdated(uint256 newRatioBps); + + function setUp() public { + vm.startPrank(admin); + rm = new ReserveManager(10_000); + feed = new ChainlinkPoRMock(FEED_ANSWER); + adapter = new ChainlinkPoRAdapter(address(feed)); + vm.stopPrank(); + } + + // ==================== setPorAdapter ==================== + + function test_setPorAdapter_setsAndEmits() public { + vm.prank(admin); + vm.expectEmit(true, false, false, false); + emit PorAdapterSet(address(adapter)); + rm.setPorAdapter(address(adapter)); + + assertEq(address(rm.porAdapter()), address(adapter)); + } + + function test_setPorAdapter_rejectsZero() public { + vm.prank(admin); + vm.expectRevert(ReserveManager.PorAdapterNotSet.selector); + rm.setPorAdapter(address(0)); + } + + function test_setPorAdapter_onlyOwner() public { + vm.prank(address(0xBAD)); + vm.expectRevert(); + rm.setPorAdapter(address(adapter)); + } + + // ==================== pullPorReserve ==================== + + function test_pullPorReserve_revertsWhenAdapterUnset() public { + vm.prank(admin); + vm.expectRevert(ReserveManager.PorAdapterNotSet.selector); + rm.pullPorReserve(); + } + + function test_pullPorReserve_convertsFeedDecimalsAndStores() public { + vm.startPrank(admin); + rm.setPorAdapter(address(adapter)); + + uint256 expected = uint256(FEED_ANSWER) / 100; // 8-dec feed -> 6-dec units + vm.expectEmit(false, false, false, true); + emit PorReservePulled(expected, block.timestamp); + (uint256 amount, uint256 updatedAt) = rm.pullPorReserve(); + vm.stopPrank(); + + assertEq(amount, expected); + assertEq(updatedAt, block.timestamp); + assertEq(rm.totalReserves(), expected); + assertEq(rm.getReserveCount(), 1); + + // The canonical PoR reserve is stored under POR_RESERVE_ID and active. + (string memory name, uint256 storedAmount, , bool active) = + rm.reserves(rm.POR_RESERVE_ID()); + assertEq(name, "Chainlink PoR Reserve"); + assertEq(storedAmount, expected); + assertTrue(active); + } + + /// @dev Repeated pulls must upsert (not duplicate) the PoR reserve id. + function test_pullPorReserve_isIdempotentOnReserveIds() public { + vm.startPrank(admin); + rm.setPorAdapter(address(adapter)); + rm.pullPorReserve(); + assertEq(rm.getReserveCount(), 1); + + // Feed updates; pulling again overwrites the single PoR entry. + feed.setAnswer(FEED_ANSWER * 2); + rm.pullPorReserve(); + vm.stopPrank(); + + assertEq(rm.getReserveCount(), 1, "PoR id must not be duplicated"); + assertEq(rm.totalReserves(), (uint256(FEED_ANSWER) * 2) / 100); + } + + /// @dev A manual reserve and the PoR reserve sum into totalReserves. + function test_pullPorReserve_coexistsWithManualReserve() public { + vm.startPrank(admin); + rm.addReserveAsset(usdBank, "USD Bank", 5_000_000); + rm.setPorAdapter(address(adapter)); + rm.pullPorReserve(); + vm.stopPrank(); + + assertEq(rm.getReserveCount(), 2); + assertEq(rm.totalReserves(), 5_000_000 + uint256(FEED_ANSWER) / 100); + } + + // ==================== pullPorReserveAndCheck ==================== + + function test_pullPorReserveAndCheck_revertsWhenRatioTooLow() public { + vm.startPrank(admin); + rm.setPorAdapter(address(adapter)); + uint256 reserveUnits = uint256(FEED_ANSWER) / 100; + // Track a supply larger than reserves so the post-pull ratio check trips. + rm.updateTrackedSupply(reserveUnits * 2); + + vm.expectRevert( + abi.encodeWithSelector( + ReserveManager.ReserveRatioTooLow.selector, 5_000, 10_000 + ) + ); + rm.pullPorReserveAndCheck(); + vm.stopPrank(); + } + + function test_pullPorReserveAndCheck_passesWhenSufficient() public { + vm.startPrank(admin); + rm.setPorAdapter(address(adapter)); + uint256 reserveUnits = uint256(FEED_ANSWER) / 100; + rm.updateTrackedSupply(reserveUnits); // exactly 100% + rm.pullPorReserveAndCheck(); // must not revert + vm.stopPrank(); + + assertEq(rm.getReserveRatioBps(), 10_000); + } + + function test_pullPorReserveAndCheck_revertsWhenAdapterUnset() public { + vm.prank(admin); + vm.expectRevert(ReserveManager.PorAdapterNotSet.selector); + rm.pullPorReserveAndCheck(); + } + + // ==================== asset management edges ==================== + + function test_updateReserve_revertsOnInactiveAsset() public { + vm.prank(admin); + vm.expectRevert(bytes("Asset not active")); + rm.updateReserve(keccak256("NEVER_ADDED"), 1); + } + + /// @dev Re-adding an existing asset id overwrites its fields without + /// pushing a duplicate into reserveIds. + function test_addReserveAsset_reAddOverwritesWithoutDuplicateId() public { + vm.startPrank(admin); + rm.addReserveAsset(usdBank, "USD Bank", 5_000_000); + rm.addReserveAsset(usdBank, "USD Bank v2", 9_000_000); + vm.stopPrank(); + + assertEq(rm.getReserveCount(), 1, "re-add must not duplicate id"); + assertEq(rm.totalReserves(), 9_000_000); + (string memory name, , , ) = rm.reserves(usdBank); + assertEq(name, "USD Bank v2"); + } + + function test_addReserveAsset_emitsReserveUpdated() public { + vm.prank(admin); + vm.expectEmit(true, false, false, true); + emit ReserveUpdated(usdBank, "USD Bank", 5_000_000); + rm.addReserveAsset(usdBank, "USD Bank", 5_000_000); + } + + function test_updateTrackedSupply_emitsSupplyUpdated() public { + vm.prank(admin); + vm.expectEmit(false, false, false, true); + emit SupplyUpdated(123_456); + rm.updateTrackedSupply(123_456); + assertEq(rm.totalSupplyTracked(), 123_456); + } + + function test_setMinimumRatio_emitsAndStores() public { + vm.prank(admin); + vm.expectEmit(false, false, false, true); + emit MinimumRatioUpdated(10_500); + rm.setMinimumRatio(10_500); + assertEq(rm.minimumRatioBps(), 10_500); + } + + // ==================== onlyOwner gating on mutators ==================== + + function test_mutators_onlyOwner() public { + vm.startPrank(address(0xBAD)); + vm.expectRevert(); + rm.addReserveAsset(usdBank, "x", 1); + vm.expectRevert(); + rm.updateTrackedSupply(1); + vm.expectRevert(); + rm.setMinimumRatio(1); + vm.stopPrank(); + } +}