From a6ad838ec4e0c783ed5bf1dd53cf5e701b5c6ef2 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Tue, 11 Aug 2026 20:47:02 +0300 Subject: [PATCH] Introduce the Sovryn Perimeter Fee on Zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the Sovryn security perimeter (SIP-0094): a minimal exit fee on user-initiated withdrawal surfaces, funding continuous exit monitoring. This change carries the Zero half of the system: - exit-fee hooks in BorrowerOperations on collateral withdrawal and trove closure, quoting through the shared ExitFeeController (Sovryn-perimeter repo) and paying the fee leg to the ExitFeeVault; every hook fails open — a fee fault forgoes the fee, never blocks a withdrawal; - the surplus-claim surface: CollSurplusPool.claimCollWithFee, a BO-only two-leg split that keeps claimColl byte-untouched, with the pool implementation upgrade ordered strictly before the BO upgrade inside SIP-0094 executable part 1; - impl-only deploy scripts for both contracts (the proxy swaps are governance actions), storage-layout zero-diff guards, and the ColFee test suite incl. reentrancy/fail-open matrices and Echidna invariants. The fee system deploys disabled and enables only by governance after post-deployment verification. --- .gitignore | 1 + contracts/BorrowerOperations.sol | 358 +++++++++- contracts/CollSurplusPool.sol | 42 ++ contracts/Interfaces/ICollSurplusPool.sol | 14 + .../Interfaces/colfee/IExitFeeController.sol | 160 +++++ .../TestContracts/EchidnaColFeeTester.sol | 133 ++++ .../TestContracts/EchidnaHarnessStubs.sol | 40 ++ contracts/TestContracts/EchidnaProxy.sol | 63 +- contracts/TestContracts/EchidnaTester.sol | 119 +++- .../TestContracts/ExitFeeControllerMock.sol | 81 +++ .../TestContracts/GasSinkFeeReceiver.sol | 25 + .../LegacyCollSurplusPoolMock.sol | 42 ++ .../TestContracts/ReentrantSurplusClaimer.sol | 51 ++ deployment/deploy/8-CollSurplusPool.ts | 18 + hardhat.config.ts | 21 + package.json | 20 +- scripts/helpers/helpers.ts | 2 +- tests-colfee/StorageLayout.zerodiff.test.js | 83 +++ tests-colfee/ZeroBorrowerExit.adjust.test.js | 556 +++++++++++++++ tests-colfee/ZeroBorrowerExit.close.test.js | 184 +++++ tests-colfee/ZeroBorrowerExit.notouch.test.js | 277 ++++++++ tests-colfee/ZeroClaimSurplus.test.js | 665 ++++++++++++++++++ tests-colfee/ZeroPreview.test.js | 221 ++++++ .../storage-layout.sovryn-perimeter-fee.json | 167 +++++ tests-colfee/utils/assertions.js | 57 ++ tests-colfee/utils/storageLayout.js | 49 ++ yarn.lock | 329 ++++++++- 27 files changed, 3720 insertions(+), 58 deletions(-) create mode 100644 contracts/Interfaces/colfee/IExitFeeController.sol create mode 100644 contracts/TestContracts/EchidnaColFeeTester.sol create mode 100644 contracts/TestContracts/EchidnaHarnessStubs.sol create mode 100644 contracts/TestContracts/ExitFeeControllerMock.sol create mode 100644 contracts/TestContracts/GasSinkFeeReceiver.sol create mode 100644 contracts/TestContracts/LegacyCollSurplusPoolMock.sol create mode 100644 contracts/TestContracts/ReentrantSurplusClaimer.sol create mode 100644 deployment/deploy/8-CollSurplusPool.ts create mode 100644 tests-colfee/StorageLayout.zerodiff.test.js create mode 100644 tests-colfee/ZeroBorrowerExit.adjust.test.js create mode 100644 tests-colfee/ZeroBorrowerExit.close.test.js create mode 100644 tests-colfee/ZeroBorrowerExit.notouch.test.js create mode 100644 tests-colfee/ZeroClaimSurplus.test.js create mode 100644 tests-colfee/ZeroPreview.test.js create mode 100644 tests-colfee/baselines/storage-layout.sovryn-perimeter-fee.json create mode 100644 tests-colfee/utils/assertions.js create mode 100644 tests-colfee/utils/storageLayout.js diff --git a/.gitignore b/.gitignore index b205806..fc124f5 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ yarn-error.log # echidna /crytic-export +fuzzTests/corpus/ #typechain types/generated/* diff --git a/contracts/BorrowerOperations.sol b/contracts/BorrowerOperations.sol index 8043271..3d78ac9 100644 --- a/contracts/BorrowerOperations.sol +++ b/contracts/BorrowerOperations.sol @@ -16,6 +16,7 @@ import "./Dependencies/console.sol"; import "./BorrowerOperationsStorage.sol"; import "./Dependencies/Mynt/MyntLib.sol"; import "./Interfaces/IPermit2.sol"; +import "./Interfaces/colfee/IExitFeeController.sol"; contract BorrowerOperations is LiquityBase, @@ -26,6 +27,37 @@ contract BorrowerOperations is /** CONSTANT / IMMUTABLE VARIABLE ONLY */ IPermit2 public immutable permit2; + // --- ColFee (exit-fee) hook --- + // No new regular storage: the controller pointer lives in an EIP-1967-style + // unstructured slot so `BorrowerOperations` storage-layout is unchanged. + bytes32 private constant EXIT_FEE_CONTROLLER_SLOT = + bytes32(uint256(keccak256("sovryn.exitFeeController")) - 1); + bytes32 private constant SURFACE_ZERO_WITHDRAW_COLL = + keccak256("COLFEE:SURFACE_ZERO_WITHDRAW_COLL"); + bytes32 private constant SURFACE_ZERO_CLAIM_SURPLUS = + keccak256("COLFEE:SURFACE_ZERO_CLAIM_SURPLUS"); + + event ExitFeeControllerSet(address indexed previous, address indexed current); + event ExitFeeApplied( + bytes32 indexed surfaceId, + address indexed actor, + address indexed asset, + address subProduct, + address recipient, + uint256 grossAmount, + uint256 feeAmount, + uint256 netAmount, + address feeReceiver + ); + event ExitFeeSkipped( + bytes32 indexed surfaceId, + address indexed actor, + address indexed asset, + uint256 grossAmount, + uint16 rateBps, + uint8 reason + ); + /* --- Variable container structs --- Used to hold, return and assign variables inside a function, in order to avoid the error: @@ -374,7 +406,16 @@ contract BorrowerOperations is ISignatureTransfer.PermitTransferFrom memory _permit, bytes calldata _signature ) external override { - _adjustNueTroveWithPermit2(0, 0, _dllrAmount, false, _upperHint, _lowerHint, _permit, _signature); + _adjustNueTroveWithPermit2( + 0, + 0, + _dllrAmount, + false, + _upperHint, + _lowerHint, + _permit, + _signature + ); } function adjustTrove( @@ -710,7 +751,10 @@ contract BorrowerOperations is _closeTrove(); } - function closeNueTroveWithPermit2(ISignatureTransfer.PermitTransferFrom memory _permit, bytes calldata _signature) external override { + function closeNueTroveWithPermit2( + ISignatureTransfer.PermitTransferFrom memory _permit, + bytes calldata _signature + ) external override { require(address(massetManager) != address(0), "Masset address not set"); uint256 debt = troveManager.getTroveDebt(msg.sender); @@ -768,16 +812,83 @@ contract BorrowerOperations is ZUSD_GAS_COMPENSATION ); - // Send the collateral back to the user - activePoolCached.sendETH(msg.sender, coll); + // Send the collateral back to the user (charging the ColFee exit fee) + _sendCollWithExitFee(activePoolCached, msg.sender, coll); } /** - * Claim remaining collateral from a redemption or from a liquidation with ICR > MCR in Recovery Mode + * Claim remaining collateral from a redemption or from a liquidation with ICR > MCR in Recovery Mode, + * charging the ColFee exit fee when the SURFACE_ZERO_CLAIM_SURPLUS policy is active. + * Fail-open like every ColFee hook: on any ColFee failure (controller missing/ + * reverting, invalid quote, fee-leg transfer failure inside the pool) the claimant + * receives the full surplus — a ColFee failure can never brick a claim. The + * non-charging path is the untouched claimColl flow (plus the ExitFeeSkipped + * event, same convention as _sendCollWithExitFee). */ function claimCollateral() external override { - // send ETH from CollSurplus Pool to owner - collSurplusPool.claimColl(msg.sender); + uint256 gross = collSurplusPool.getCollateral(msg.sender); + // Single Zero deployment: subProduct = address(0). Asset is native RBTC. + IExitFeeController.ExitFeeQuote memory q = _safeQuote( + SURFACE_ZERO_CLAIM_SURPLUS, + address(0), + msg.sender, + gross + ); + + // Defensive: a quote that charges into address(0) would burn the fee (a + // value call to a no-code address succeeds). Demote to the non-charging + // path — DISABLED is the enum's "feeReceiver == address(0)" reason. + if (q.active && q.feeAmount > 0 && q.feeReceiver == address(0)) { + q.active = false; + q.netAmount = gross; + q.reason = uint8(IExitFeeController.SkipReason.DISABLED); + } + + if (q.active && q.feeAmount > 0) { + // Two-leg split inside the pool (fee → feeReceiver, net → claimant); + // the pool's fee leg is fail-open and reports which event is truthful. + bool feePaid = collSurplusPool.claimCollWithFee( + msg.sender, + q.feeReceiver, + q.feeAmount + ); + if (feePaid) { + emit ExitFeeApplied( + SURFACE_ZERO_CLAIM_SURPLUS, + msg.sender, + address(0), + address(0), + msg.sender, + gross, + q.feeAmount, + q.netAmount, + q.feeReceiver + ); + } else { + emit ExitFeeSkipped( + SURFACE_ZERO_CLAIM_SURPLUS, + msg.sender, + address(0), + gross, + q.rateBps, + uint8(IExitFeeController.SkipReason.VAULT_REVERT) + ); + } + } else { + // !active (INACTIVE / DISABLED / INVALID_QUOTE / CONTROLLER_REVERT) + // OR active-but-zero-fee (dust / zero-rate / gross == 0 → reason NONE). + emit ExitFeeSkipped( + SURFACE_ZERO_CLAIM_SURPLUS, + msg.sender, + address(0), + gross, + q.rateBps, + q.reason + ); + // send ETH from CollSurplus Pool to owner — untouched original path + // (gross == 0 falls through to claimColl's own revert, identical to today) + collSurplusPool.claimColl(msg.sender); + } } // --- Helper functions --- @@ -804,11 +915,10 @@ contract BorrowerOperations is return usdValue; } - function _getCollChange(uint256 _collReceived, uint256 _requestedCollWithdrawal) - internal - pure - returns (uint256 collChange, bool isCollIncrease) - { + function _getCollChange( + uint256 _collReceived, + uint256 _requestedCollWithdrawal + ) internal pure returns (uint256 collChange, bool isCollIncrease) { if (_collReceived != 0) { collChange = _collReceived; isCollIncrease = true; @@ -862,8 +972,198 @@ contract BorrowerOperations is if (_isCollIncrease) { _activePoolAddColl(_activePool, _collChange); } else { - _activePool.sendETH(_borrower, _collChange); + _sendCollWithExitFee(_activePool, _borrower, _collChange); + } + } + + // --- ColFee (exit-fee) helpers --- + + /// @notice Address of the ColFee controller this instance consults. Held in + /// an EIP-1967-style unstructured slot (no regular-storage footprint). + function exitFeeController() public view returns (address ctrl) { + bytes32 slot = EXIT_FEE_CONTROLLER_SLOT; + assembly { + ctrl := sload(slot) + } + } + + /// @notice Set (or rotate) the ColFee controller this instance consults. + /// Owner-only, one call, effective for every subsequent exit. + function setExitFeeController(address ctrl) external onlyOwner { + require(ctrl != address(0), "EFC:zero"); + // A no-code controller would make the high-level quoteExitFee call + // revert with "function call to a non-contract account", which 0.6.11 + // try/catch does NOT catch — bricking borrower exits. Reject it here + // (and fail open in _safeQuote if it later becomes code-less). + checkContract(ctrl); + address prev = exitFeeController(); + bytes32 slot = EXIT_FEE_CONTROLLER_SLOT; + assembly { + sstore(slot, ctrl) + } + emit ExitFeeControllerSet(prev, ctrl); + } + + /// @dev Fail-open quote wrapper. On a missing/reverting controller or a + /// semantically invalid quote, returns a non-charging quote with + /// `netAmount == gross`. The validity gate uses subtraction only + /// (`feeAmount > gross`), never an unchecked addition, and recomputes + /// `netAmount = gross - feeAmount` so the fee + user legs always sum to + /// exactly `gross` — protecting ActivePool liquidity from a bad or + /// upgraded controller. + function _safeQuote( + bytes32 surfaceId, + address subProduct, + address actor, + uint256 gross + ) private view returns (IExitFeeController.ExitFeeQuote memory q) { + address ctrl = exitFeeController(); + // Fail open on a missing OR code-less controller. The address(0) check + // alone is not enough: a high-level call to any no-code address (EOA, + // or a controller that self-destructed after being set) reverts with + // "function call to a non-contract account", which 0.6.11 try/catch + // does NOT catch — so guard on extcodesize before the call. + uint256 ctrlSize; + assembly { + ctrlSize := extcodesize(ctrl) + } + if (ctrl == address(0) || ctrlSize == 0) { + q.netAmount = gross; + q.reason = uint8(IExitFeeController.SkipReason.CONTROLLER_REVERT); + return q; + } + try IExitFeeController(ctrl).quoteExitFee(surfaceId, subProduct, actor, gross) returns ( + IExitFeeController.ExitFeeQuote memory got + ) { + // Pool conservation is the consumer's own concern: it holds exactly `gross` + // wei to distribute, so it must never be asked to pay out more. A + // feeAmount > gross would underflow the net recompute below (bricking the + // exit) and a fee leg > gross could draw OTHER troves' collateral out of + // ActivePool. Rate, receiver, and fee policy are the configured + // controller's responsibility — not re-validated here. + if (got.feeAmount > gross) { + // Override only the verdict; leave the controller's raw feeAmount / + // rateBps / feeReceiver intact (active=false gates charging downstream). + got.active = false; + got.netAmount = gross; // non-charging shape: net == gross + got.reason = uint8(IExitFeeController.SkipReason.INVALID_QUOTE); + return got; + } + got.netAmount = gross - got.feeAmount; // fee + net == gross (no residue) + return got; + } catch { + q.netAmount = gross; + q.reason = uint8(IExitFeeController.SkipReason.CONTROLLER_REVERT); + } + } + + /// @dev Settle a borrower collateral payout, charging the ColFee exit fee + /// when the resolved policy is active. The fee leg uses `try/catch` + /// (0.6.11 native) so a fee-receiver failure never bricks the exit; on + /// any non-charging path the full `gross` is sent to the borrower via + /// the existing fail-closed `sendETH`. ActivePool's recorded ETH + /// decrements by exactly `gross` either way (the reverted fee-leg + /// subcall rolls back its `ETH.sub`). + function _sendCollWithExitFee( + IActivePool _activePool, + address borrower, + uint256 gross + ) private { + // Debt-only adjustments (repay / debt-decrease) reach here with gross == 0: + // no collateral leaves the pool, so there is nothing to settle. Skip the + // controller round-trip and the ColFee event. (Baseline called + // sendETH(borrower, 0) here — a value-less no-op that only emitted + // EtherSent(_, 0) / ActivePoolETHBalanceUpdated; we drop that redundant + // transfer, so debt-only ops emit fewer events than pre-ColFee.) + if (gross == 0) { + return; + } + + // Single Zero deployment: subProduct = address(0). Asset is native RBTC. + IExitFeeController.ExitFeeQuote memory q = _safeQuote( + SURFACE_ZERO_WITHDRAW_COLL, + address(0), + borrower, + gross + ); + + if (q.active && q.feeAmount > 0) { + try _activePool.sendETH(q.feeReceiver, q.feeAmount) { + _activePool.sendETH(borrower, q.netAmount); // user leg: existing fail-closed behavior + // Emit only after BOTH legs settle, so an ExitFeeApplied event always + // implies a completed borrower payout (truthful by construction). + emit ExitFeeApplied( + SURFACE_ZERO_WITHDRAW_COLL, + borrower, + address(0), + address(0), + borrower, + gross, + q.feeAmount, + q.netAmount, + q.feeReceiver + ); + return; + } catch { + emit ExitFeeSkipped( + SURFACE_ZERO_WITHDRAW_COLL, + borrower, + address(0), + gross, + q.rateBps, + uint8(IExitFeeController.SkipReason.VAULT_REVERT) + ); + } + } else { + // !active (INACTIVE / DISABLED / INVALID_QUOTE / CONTROLLER_REVERT) + // OR active-but-zero-fee (dust / zero-rate policy → q.reason == NONE). + emit ExitFeeSkipped( + SURFACE_ZERO_WITHDRAW_COLL, + borrower, + address(0), + gross, + q.rateBps, + q.reason + ); } + _activePool.sendETH(borrower, gross); // full-gross fallback (any non-charging path) + } + + /// @notice Read-only preview of the ColFee exit fee on a Zero borrower collateral + /// payout of `grossColl` for `borrower`. Hard-wired to + /// SURFACE_ZERO_WITHDRAW_COLL / subProduct=address(0) / actor=borrower, and + /// routes through the same `_safeQuote` the live hook uses — so the synthesized + /// fail-open quote on controller failure matches execution wei-for-wise. The + /// caller passes `grossColl` (computed from trove state); this is a thin policy + /// lookup, not a re-derivation of the per-function gross. + /// @return rateBps resolved rate (0 when not charging / fail-open) + /// @return feeAmount fee that would be taken (0 unless active && rateBps>0 && not dust) + /// @return netAmount amount the borrower would receive (== grossColl when not charging) + /// @return feeReceiver fee destination from the quote + /// @return active resolved policy active flag (the "will charge" test is active && feeAmount>0) + /// @return reason SkipReason (NONE on an honest/charging quote) + function previewZeroCollWithdrawExitFee( + address borrower, + uint256 grossColl + ) + external + view + returns ( + uint16 rateBps, + uint256 feeAmount, + uint256 netAmount, + address feeReceiver, + bool active, + uint8 reason + ) + { + IExitFeeController.ExitFeeQuote memory q = _safeQuote( + SURFACE_ZERO_WITHDRAW_COLL, + address(0), + borrower, + grossColl + ); + return (q.rateBps, q.feeAmount, q.netAmount, q.feeReceiver, q.active, q.reason); } /// Send ETH to Active Pool and increase its recorded ETH balance @@ -911,10 +1211,10 @@ contract BorrowerOperations is ); } - function _requireNonZeroAdjustment(uint256 _collWithdrawal, uint256 _ZUSDChange) - internal - view - { + function _requireNonZeroAdjustment( + uint256 _collWithdrawal, + uint256 _ZUSDChange + ) internal view { require( msg.value != 0 || _collWithdrawal != 0 || _ZUSDChange != 0, "BorrowerOps: There must be either a collateral change or a debt change" @@ -926,10 +1226,10 @@ contract BorrowerOperations is require(status == 1, "BorrowerOps: Trove does not exist or is closed"); } - function _requireTroveisNotActive(ITroveManager _troveManager, address _borrower) - internal - view - { + function _requireTroveisNotActive( + ITroveManager _troveManager, + address _borrower + ) internal view { uint256 status = _troveManager.getTroveStatus(_borrower); require(status != 1, "BorrowerOps: Trove is active"); } @@ -1026,10 +1326,10 @@ contract BorrowerOperations is ); } - function _requireValidZUSDRepayment(uint256 _currentDebt, uint256 _debtRepayment) - internal - pure - { + function _requireValidZUSDRepayment( + uint256 _currentDebt, + uint256 _debtRepayment + ) internal pure { require( _debtRepayment <= _currentDebt.sub(ZUSD_GAS_COMPENSATION), "BorrowerOps: Amount repaid must not be larger than the Trove's debt" @@ -1051,10 +1351,10 @@ contract BorrowerOperations is ); } - function _requireValidMaxFeePercentage(uint256 _maxFeePercentage, bool _isRecoveryMode) - internal - view - { + function _requireValidMaxFeePercentage( + uint256 _maxFeePercentage, + bool _isRecoveryMode + ) internal view { if (_isRecoveryMode) { require( _maxFeePercentage <= DECIMAL_PRECISION, diff --git a/contracts/CollSurplusPool.sol b/contracts/CollSurplusPool.sol index 2d93c09..954d2d1 100644 --- a/contracts/CollSurplusPool.sol +++ b/contracts/CollSurplusPool.sol @@ -75,6 +75,48 @@ contract CollSurplusPool is CollSurplusPoolStorage, CheckContract, ICollSurplusP require(success, "CollSurplusPool: sending ETH failed"); } + /// @dev Gas forwarded to the fee receiver's receive hook. Ample for a receiver + /// that only accepts the transfer and logs, while guaranteeing a + /// gas-sinking receiver can never starve the claimant leg: a receiver + /// needing more gas makes the fee leg fail, which is fail-open — the + /// claimant then receives the full balance. + uint256 private constant FEE_LEG_GAS_CAP = 100_000; + + /// @notice Two-leg claim: `_feeAmount` to `_feeReceiver`, remainder to `_account`. + /// Only callable by BorrowerOperations (the ColFee surplus-claim hook); + /// `claimColl` remains the untouched non-charging path. + /// CEI: all effects (balance zeroing, ETH accounting) precede both external + /// calls, so a reentrant claim sees balances == 0 and reverts. The single + /// `ETH` decrement equals fee + net exactly. The fee leg is fail-open — + /// if it fails, the claimant receives the full balance; the user leg stays + /// fail-closed like `claimColl`. + /// @return feePaid true iff the fee transfer succeeded (caller emits the matching event) + function claimCollWithFee( + address _account, + address _feeReceiver, + uint256 _feeAmount + ) external override returns (bool feePaid) { + _requireCallerIsBorrowerOperations(); + uint256 claimableColl = balances[_account]; + require(claimableColl > 0, "CollSurplusPool: No collateral available to claim"); + require(_feeAmount <= claimableColl, "CollSurplusPool: fee exceeds claimable"); + + balances[_account] = 0; + emit CollBalanceUpdated(_account, 0); + + ETH = ETH.sub(claimableColl); + + (feePaid, ) = _feeReceiver.call{ value: _feeAmount, gas: FEE_LEG_GAS_CAP }(""); + uint256 userAmount = feePaid ? claimableColl.sub(_feeAmount) : claimableColl; + if (feePaid) { + emit EtherSent(_feeReceiver, _feeAmount); + } + + emit EtherSent(_account, userAmount); + (bool success, ) = _account.call{ value: userAmount }(""); + require(success, "CollSurplusPool: sending ETH failed"); + } + // --- 'require' functions --- function _requireCallerIsBorrowerOperations() internal view { diff --git a/contracts/Interfaces/ICollSurplusPool.sol b/contracts/Interfaces/ICollSurplusPool.sol index c917a83..d5fd30d 100644 --- a/contracts/Interfaces/ICollSurplusPool.sol +++ b/contracts/Interfaces/ICollSurplusPool.sol @@ -43,4 +43,18 @@ interface ICollSurplusPool { /// @notice claims collateral for given account. Only callable by BorrowerOperations. /// @param _account account to send claimable collateral function claimColl(address _account) external; + + /// @notice Two-leg claim: `_feeAmount` to `_feeReceiver`, remainder to `_account`. + /// Only callable by BorrowerOperations (the ColFee surplus-claim hook). + /// The fee leg is fail-open: if the fee transfer fails, `_account` + /// receives the full claimable balance. + /// @param _account account whose claimable collateral is paid out + /// @param _feeReceiver ColFee fee destination for the fee leg + /// @param _feeAmount fee in wei; must not exceed the account's claimable balance + /// @return feePaid true iff the fee transfer succeeded (caller emits the matching event) + function claimCollWithFee( + address _account, + address _feeReceiver, + uint256 _feeAmount + ) external returns (bool feePaid); } diff --git a/contracts/Interfaces/colfee/IExitFeeController.sol b/contracts/Interfaces/colfee/IExitFeeController.sol new file mode 100644 index 0000000..24927d9 --- /dev/null +++ b/contracts/Interfaces/colfee/IExitFeeController.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +// ───────────────────────────────────────────────────────────────────────────── +// Vendored copy of the ColFee exit-fee controller interface, taken from +// DistributedCollective/colfee @ c85f60aef91bc644517cf1b3ea7c5e8c565f4ca5 +// src/interfaces/IExitFeeController.sol +// Do not change the declarations here: the binding property is ABI equality with +// the deployed controller. To pick up an interface change, change it upstream, +// re-copy, and bump the SHA above. Local formatting follows this repo's +// formatter, so the file is not byte-identical to the upstream source. +// ───────────────────────────────────────────────────────────────────────────── +// Range pragma is intentional: the same declarations are compiled under Solidity +// 0.5.17, 0.6.11 (this repo), and 0.8.20. +// aderyn-ignore-next-line(unspecific-solidity-pragma) +pragma solidity >=0.5.17 <0.9.0; +// `pragma experimental ABIEncoderV2;` is required for the 0.5.17 leg — that +// compiler needs the directive to emit/decode struct returns (ExitFeeQuote) +// across the ABI boundary. The modern `pragma abicoder v2;` was only added +// in 0.7.4 and is incompatible with 0.5.x, so the experimental pragma is the +// only spelling that works across all three target compilers. On 0.6+/0.8+ +// the experimental pragma is accepted (silently on 0.6.x; with a deprecation +// notice on 0.8.x that does NOT enable the historical encoder bugs — those +// bugs were fixed long before 0.6.0). This is a pure interface (no +// implementation, no storage), so there is no exposure to encoder-bug +// surface area beyond the ABI itself. Removing it would require a separate +// file per pragma, reintroducing declaration drift between the consumers. +// aderyn-ignore-next-line(experimental-encoder) +pragma experimental ABIEncoderV2; + +/// @title IExitFeeController +/// @notice Cross-pragma interface for the Sovryn ExitFee (ColFee) controller. +/// One declaration shared by every consumer so they all resolve the +/// same ABI. Products compiled under a pragma this file cannot span +/// declare their own ABI-equivalent variant instead. +/// Zero calls only `quoteExitFee`; the rest is declared for completeness. +interface IExitFeeController { + // ─── Types ──────────────────────────────────────────────────────────── + + /// @notice Reason a `ColFeeSkipped` event was emitted instead of an + /// `ColFeeApplied`. NONE covers honest paths (positive charge, + /// dust, or actor-exemption); the rest cover off-state outcomes. + enum SkipReason { + NONE, // Controller computed an honest quote (charge / dust / zero-rate). + INACTIVE, // exitFeeEnabled == false. + DISABLED, // feeReceiver == address(0), OR surface gate off. + INVALID_QUOTE, // Defensive: overflow or fee > gross. + CONTROLLER_REVERT, // Set by the product's local _safeQuote on staticcall failure. + VAULT_REVERT // Set by the product hook when the fee transfer itself failed. + } + + /// @notice A single rate-policy entry. Lives at each of the three tiers + /// (actor → sub-product → surface). + struct RatePolicy { + bool active; + uint16 rateBps; + } + + /// @notice Quote returned by `quoteExitFee`. `reason` carries the precise + /// off-state code; `active` is the resolved policy state (true iff + /// a RatePolicy.active entry was used and reason ∈ {NONE}). + struct ExitFeeQuote { + bool active; + uint16 rateBps; + uint256 feeAmount; + uint256 netAmount; + address feeReceiver; + uint8 reason; + } + + // ─── Events ─────────────────────────────────────────────────────────── + + event ExitFeeEnabledSet(bool enabled); + event FeeReceiverSet(address indexed feeReceiver); + event SurfacePolicySet(bytes32 indexed surfaceId, bool active, uint16 rateBps); + event SubProductPolicySet( + bytes32 indexed surfaceId, + address indexed subProduct, + bool active, + uint16 rateBps + ); + event ActorPolicySet( + bytes32 indexed surfaceId, + address indexed actor, + bool active, + uint16 rateBps + ); + event SubProductPolicyRemoved(bytes32 indexed surfaceId, address indexed subProduct); + event ActorPolicyRemoved(bytes32 indexed surfaceId, address indexed actor); + + // ─── Quote ──────────────────────────────────────────────────────────── + + /// @notice Resolve the fee policy for `(surfaceId, subProduct, actor)` and + /// compute the fee on `grossAmount`. Reads only; never reverts on + /// policy lookups (returns active=false with a SkipReason instead). + /// May revert only on internal arithmetic invariants (caught by + /// the product's local _safeQuote helper as CONTROLLER_REVERT). + function quoteExitFee( + bytes32 surfaceId, + address subProduct, + address actor, + uint256 grossAmount + ) external view returns (ExitFeeQuote memory); + + // ─── State views ────────────────────────────────────────────────────── + + function exitFeeEnabled() external view returns (bool); + + function feeReceiver() external view returns (address); + + function surfacePolicy(bytes32 surfaceId) external view returns (RatePolicy memory); + + function subProductPolicy( + bytes32 surfaceId, + address subProduct + ) external view returns (RatePolicy memory); + + function actorPolicy( + bytes32 surfaceId, + address actor + ) external view returns (RatePolicy memory); + + function subProductKeys(bytes32 surfaceId) external view returns (address[] memory); + + function actorKeys(bytes32 surfaceId) external view returns (address[] memory); + + // ─── Admin ──────────────────────────────────────────────────────────── + + function setExitFeeEnabled(bool enabled) external; + + function setFeeReceiver(address newReceiver) external; + + function setSurfacePolicy(bytes32 surfaceId, RatePolicy calldata policy) external; + + function setSubProductPolicy( + bytes32 surfaceId, + address subProduct, + RatePolicy calldata policy + ) external; + + function setSubProductPolicies( + bytes32 surfaceId, + address[] calldata subProducts, + RatePolicy[] calldata policies + ) external; + + function setActorPolicy(bytes32 surfaceId, address actor, RatePolicy calldata policy) external; + + function setActorPolicies( + bytes32 surfaceId, + address[] calldata actors, + RatePolicy[] calldata policies + ) external; + + function removeSubProductPolicy(bytes32 surfaceId, address subProduct) external; + + function removeSubProductPolicies(bytes32 surfaceId, address[] calldata subProducts) external; + + function removeActorPolicy(bytes32 surfaceId, address actor) external; + + function removeActorPolicies(bytes32 surfaceId, address[] calldata actors) external; +} diff --git a/contracts/TestContracts/EchidnaColFeeTester.sol b/contracts/TestContracts/EchidnaColFeeTester.sol new file mode 100644 index 0000000..bd4ce01 --- /dev/null +++ b/contracts/TestContracts/EchidnaColFeeTester.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.6.11; +pragma experimental ABIEncoderV2; + +import "./EchidnaTester.sol"; +import "./ExitFeeControllerMock.sol"; + +/// @title EchidnaColFeeTester +/// @notice Re-runs the full Zero Echidna campaign with the ColFee exit fee +/// ACTIVE, so every collateral exit driven by the actor proxies routes +/// through the fee hook (`_sendCollWithExitFee`) with a real fee leg. +/// +/// The ColFee load-bearing invariant is the inherited +/// `echidna_ETH_balances`: +/// - `borrowerOperations` holds 0 ETH — the fee leg never strands ETH +/// in BorrowerOperations; +/// - each pool's real balance == its internal `getETH()` accounting — +/// the fee leg's `ActivePool.sendETH` keeps balance and accounting +/// in sync, so no ETH is created, destroyed, or double-counted by +/// the fee. +/// A fee-leg accounting bug breaks it. The two added invariants below +/// guard that the run is genuinely exercising ColFee (not vacuous) and +/// that ETH reaches the fee receiver only through the accounted leg. +/// +/// Note: the inherited `echidna_canary_*` properties are Liquity's +/// coverage markers and are EXPECTED to be falsified once the actors +/// open troves / fund the pool — that is their purpose, not a ColFee +/// failure. The meaningful result is that `echidna_ETH_balances`, +/// `echidna_trove_properties`, `echidna_troves_order`, +/// `echidna_ZUSD_global_balances`, and the two `echidna_colfee_*` +/// invariants below HOLD with the fee active. +/// +/// Run (from repo root, project/hardhat mode so the CryptoEnv-wrapped +/// compile resolves — the single-file form needs a bare `solc` on PATH): +/// __decryptionAlreadyDone__=TRUE echidna . \ +/// --contract EchidnaColFeeTester \ +/// --config fuzzTests/js/echidna_config.yaml +contract EchidnaColFeeTester is EchidnaTester { + // Canonical deterministic Permit2 deployment address. Permit2 is not on any + // ColFee path, so a fixed (codeless-in-VM) address is inert here; pinning it + // lets Echidna deploy this tester with NO constructor arguments. + address internal constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + + ExitFeeControllerMock public exitFeeCtrl; + ColFeeEchidnaSink public feeSink; + + constructor() public payable EchidnaTester(PERMIT2) { + feeSink = new ColFeeEchidnaSink(); + exitFeeCtrl = new ExitFeeControllerMock(); + // Active policy: 1% (100 bps) of the borrower's gross collateral, paid + // to a sink that accepts ETH (so the fee leg settles, exercising the + // ExitFeeApplied path rather than only the VAULT_REVERT fallback). + exitFeeCtrl.configure(true, 100, address(feeSink), 0); + // owner == this tester (Zero's setAddresses does not renounce). + borrowerOperations.setExitFeeController(address(exitFeeCtrl)); + } + + /// Guards against a vacuous run: the controller stays pinned and active, so + /// the inherited invariants are genuinely exercised WITH the fee in the loop. + function echidna_colfee_controller_pinned() public view returns (bool) { + return borrowerOperations.exitFeeController() == address(exitFeeCtrl); + } + + /// ETH only reaches the fee receiver via the accounted fee leg: the sink's + /// real balance equals the total it recorded receiving. (Combined with the + /// inherited pool `balance == getETH()` invariant, this closes the loop on + /// fee-leg value conservation.) + function echidna_colfee_sink_synced() public view returns (bool) { + return address(feeSink).balance == feeSink.totalReceived(); + } + + function exerciseColFeeExt() external { + EchidnaProxy echidnaProxy = echidnaProxies[0]; + if (troveManager.getTroveDebt(address(echidnaProxy)) == 0) { + openTroveExt(0, 1e23, 1e21); + } + + uint amount = getAdjustedCollWithdrawal(address(echidnaProxy), 1e18); + if (amount > 0) { + echidnaProxy.withdrawCollPrx(amount, address(0), address(0)); + } + } + + /// Canary: EXPECTED to be falsified once any exit charges a fee. If this + /// stays passing, the campaign never exercised ColFee. + function echidna_canary_colfee_charged() public view returns (bool) { + return feeSink.totalReceived() == 0; + } + + /// Fund a collateral surplus for an actor and claim it through the real + /// BorrowerOperations hook with the fee ACTIVE. The tester owns the pool and + /// Zero's setAddresses is re-callable, so it impersonates TroveManager and + /// ActivePool for one atomic funding step (accountSurplus + backing ETH), + /// restores the real wiring, then claims — exercising both the charging + /// two-leg split (fee >= 1 wei at 100 bps needs amount >= 100) and, for dust + /// amounts, the untouched claimColl fallback. + function fundAndClaimSurplusExt(uint _i, uint _amount) external { + EchidnaProxy echidnaProxy = echidnaProxies[_i % 100]; // 100 == NUMBER_OF_ACTORS (private in base) + uint amount = 1 + (_amount % 1e21); // 1 wei .. 1000 ETH; tester balance is ample + if (address(this).balance < amount) { + return; + } + + collSurplusPool.setAddresses(address(borrowerOperations), address(this), address(this)); + collSurplusPool.accountSurplus(address(echidnaProxy), amount); + (bool funded, ) = address(collSurplusPool).call{ value: amount }(""); + require(funded); + collSurplusPool.setAddresses( + address(borrowerOperations), + address(troveManager), + address(activePool) + ); + + echidnaProxy.claimCollateralPrx(); + } + + /// CollSurplusPool conservation under the two-leg split: raw balance always + /// equals the recorded ETH accounting (mirrors the inherited per-pool checks + /// in echidna_ETH_balances, which predates ColFee and does not cover this pool). + function echidna_colfee_surplus_pool_synced() public view returns (bool) { + return address(collSurplusPool).balance == collSurplusPool.getETH(); + } +} + +/// Minimal payable fee receiver that records what it is paid. +contract ColFeeEchidnaSink { + uint256 public totalReceived; + + receive() external payable { + totalReceived += msg.value; + } +} diff --git a/contracts/TestContracts/EchidnaHarnessStubs.sol b/contracts/TestContracts/EchidnaHarnessStubs.sol new file mode 100644 index 0000000..04eb60a --- /dev/null +++ b/contracts/TestContracts/EchidnaHarnessStubs.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.6.11; + +/// @title Echidna harness stubs +/// @notice Inert stand-ins for the peripheral contracts the core `setAddresses` +/// calls now `checkContract`, but which the fuzzed trove/SP lifecycle +/// only ever stores (zero token / staking) or calls in a way that is +/// value-neutral (fee distributor, community issuance). They exist so +/// the full system deploys; they deliberately do nothing so the +/// inherited Liquity invariants keep holding under the fuzzer. + +/// Fee distributor: the borrowing-fee leg mints ZUSD here and the redemption +/// leg sends RBTC here (via `ActivePool.sendETH`, a `call` that requires +/// success) before invoking `distributeFees`. Leaving the value parked is +/// exactly what `echidna_ZUSD_global_balances` expects (ZUSD at external +/// addresses) and is invisible to `echidna_ETH_balances` (not a checked pool). +contract EchidnaFeeDistributorStub { + function distributeFees() external {} + + receive() external payable {} +} + +/// Community issuance: `StabilityPool` calls `issueSOV` on every +/// deposit/withdraw/offset and `sendSOV` to pay gains. Returning 0 issuance +/// makes `_updateG` early-return, so no SOV gain ever accrues — value-neutral +/// for the ZUSD/ETH invariants. +contract EchidnaCommunityIssuanceStub { + function issueSOV(uint256) external returns (uint256) { + return 0; + } + + function sendSOV(address, uint256) external {} +} + +/// ZERO token / staking: only `checkContract`-ed at wiring time and stored; +/// never called on the fuzzed path, so bytecode presence is all that is needed. +contract EchidnaInertStub { + +} diff --git a/contracts/TestContracts/EchidnaProxy.sol b/contracts/TestContracts/EchidnaProxy.sol index 4cd4094..134e364 100644 --- a/contracts/TestContracts/EchidnaProxy.sol +++ b/contracts/TestContracts/EchidnaProxy.sol @@ -52,23 +52,42 @@ contract EchidnaProxy { uint _maxIterations, uint _maxFee ) external { - troveManager.redeemCollateral(_ZUSDAmount, _firstRedemptionHint, _upperPartialRedemptionHint, _lowerPartialRedemptionHint, _partialRedemptionHintNICR, _maxIterations, _maxFee); + troveManager.redeemCollateral( + _ZUSDAmount, + _firstRedemptionHint, + _upperPartialRedemptionHint, + _lowerPartialRedemptionHint, + _partialRedemptionHintNICR, + _maxIterations, + _maxFee + ); } // Borrower Operations - function openTrovePrx(uint _ETH, uint _ZUSDAmount, address _upperHint, address _lowerHint, uint _maxFee) external payable { - borrowerOperations.openTrove{value: _ETH}(_maxFee, _ZUSDAmount, _upperHint, _lowerHint); + function openTrovePrx( + uint _ETH, + uint _ZUSDAmount, + address _upperHint, + address _lowerHint, + uint _maxFee + ) external payable { + borrowerOperations.openTrove{ value: _ETH }(_maxFee, _ZUSDAmount, _upperHint, _lowerHint); } function addCollPrx(uint _ETH, address _upperHint, address _lowerHint) external payable { - borrowerOperations.addColl{value: _ETH}(_upperHint, _lowerHint); + borrowerOperations.addColl{ value: _ETH }(_upperHint, _lowerHint); } function withdrawCollPrx(uint _amount, address _upperHint, address _lowerHint) external { borrowerOperations.withdrawColl(_amount, _upperHint, _lowerHint); } - function withdrawZUSDPrx(uint _amount, address _upperHint, address _lowerHint, uint _maxFee) external { + function withdrawZUSDPrx( + uint _amount, + address _upperHint, + address _lowerHint, + uint _maxFee + ) external { borrowerOperations.withdrawZUSD(_maxFee, _amount, _upperHint, _lowerHint); } @@ -80,8 +99,27 @@ contract EchidnaProxy { borrowerOperations.closeTrove(); } - function adjustTrovePrx(uint _ETH, uint _collWithdrawal, uint _debtChange, bool _isDebtIncrease, address _upperHint, address _lowerHint, uint _maxFee) external payable { - borrowerOperations.adjustTrove{value: _ETH}(_maxFee, _collWithdrawal, _debtChange, _isDebtIncrease, _upperHint, _lowerHint); + function claimCollateralPrx() external { + borrowerOperations.claimCollateral(); + } + + function adjustTrovePrx( + uint _ETH, + uint _collWithdrawal, + uint _debtChange, + bool _isDebtIncrease, + address _upperHint, + address _lowerHint, + uint _maxFee + ) external payable { + borrowerOperations.adjustTrove{ value: _ETH }( + _maxFee, + _collWithdrawal, + _debtChange, + _isDebtIncrease, + _upperHint, + _lowerHint + ); } // Pool Manager @@ -103,7 +141,11 @@ contract EchidnaProxy { return zusdToken.approve(spender, amount); } - function transferFromPrx(address sender, address recipient, uint256 amount) external returns (bool) { + function transferFromPrx( + address sender, + address recipient, + uint256 amount + ) external returns (bool) { return zusdToken.transferFrom(sender, recipient, amount); } @@ -111,7 +153,10 @@ contract EchidnaProxy { return zusdToken.increaseAllowance(spender, addedValue); } - function decreaseAllowancePrx(address spender, uint256 subtractedValue) external returns (bool) { + function decreaseAllowancePrx( + address spender, + uint256 subtractedValue + ) external returns (bool) { return zusdToken.decreaseAllowance(spender, subtractedValue); } } diff --git a/contracts/TestContracts/EchidnaTester.sol b/contracts/TestContracts/EchidnaTester.sol index 38cbaae..ed94914 100644 --- a/contracts/TestContracts/EchidnaTester.sol +++ b/contracts/TestContracts/EchidnaTester.sol @@ -18,6 +18,7 @@ import "../ZUSDToken.sol"; import "./PriceFeedTestnet.sol"; import "../SortedTroves.sol"; import "./EchidnaProxy.sol"; +import "./EchidnaHarnessStubs.sol"; //import "../Dependencies/console.sol"; @@ -53,6 +54,7 @@ contract EchidnaTester { constructor(address _permit2) public payable { liquityBaseParams = new LiquityBaseParams(); + liquityBaseParams.initialize(); // sets MCR (110%) / CCR (150%) troveManagerRedeemOps = new TroveManagerRedeemOps(14 * 86400, _permit2); troveManager = new TroveManager(14 days, _permit2); borrowerOperations = new BorrowerOperations(_permit2); @@ -72,9 +74,16 @@ contract EchidnaTester { sortedTroves = new SortedTroves(); + // Inert peripherals so the core `setAddresses` `checkContract` passes; + // value-neutral under the fuzzer (see EchidnaHarnessStubs.sol). + address feeDistributor = address(new EchidnaFeeDistributorStub()); + address communityIssuance = address(new EchidnaCommunityIssuanceStub()); + address zeroToken = address(new EchidnaInertStub()); + address zeroStaking = address(new EchidnaInertStub()); + troveManager.setAddresses( ITroveManager.TroveManagerInitAddressesParams( - address(0), + feeDistributor, address(troveManagerRedeemOps), address(liquityBaseParams), address(borrowerOperations), @@ -86,13 +95,13 @@ contract EchidnaTester { address(priceFeedTestnet), address(zusdToken), address(sortedTroves), - address(0), - address(0) + zeroToken, + zeroStaking ) ); borrowerOperations.setAddresses( - address(0), + feeDistributor, address(liquityBaseParams), address(troveManager), address(activePool), @@ -103,7 +112,7 @@ contract EchidnaTester { address(priceFeedTestnet), address(sortedTroves), address(zusdToken), - address(0) + zeroStaking ); activePool.setAddresses( @@ -123,7 +132,7 @@ contract EchidnaTester { address(zusdToken), address(sortedTroves), address(priceFeedTestnet), - address(0) + communityIssuance ); collSurplusPool.setAddresses( @@ -219,16 +228,73 @@ contract EchidnaTester { return ZUSDAmount; } + function getMinCollForRatio(uint debt, uint ratio, uint price) internal pure returns (uint) { + if (price == 0 || debt > uint(-1).div(ratio)) { + return uint(-1); + } + + uint minColl = debt.mul(ratio).div(price); + if (minColl == uint(-1)) { + return uint(-1); + } + return minColl.add(1); + } + + function getAdjustedCollWithdrawal( + address borrower, + uint _amount + ) internal view returns (uint) { + uint price = priceFeedTestnet.getPrice(); + if (price == 0 || troveManager.checkRecoveryMode(price)) { + return 0; + } + + uint debt = troveManager.getTroveDebt(borrower); + uint coll = troveManager.getTroveColl(borrower); + if (debt == 0 || coll == 0) { + return 0; + } + + uint minBorrowerColl = getMinCollForRatio(debt, CCR, price); + if (coll <= minBorrowerColl) { + return 0; + } + + uint maxWithdrawal = coll.sub(minBorrowerColl); + uint systemDebt = activePool.getZUSDDebt().add(defaultPool.getZUSDDebt()); + uint systemColl = activePool.getETH().add(defaultPool.getETH()); + uint minSystemColl = getMinCollForRatio(systemDebt, CCR, price); + if (systemColl <= minSystemColl) { + return 0; + } + + uint systemRoom = systemColl.sub(minSystemColl); + if (maxWithdrawal > systemRoom) { + maxWithdrawal = systemRoom; + } + if (maxWithdrawal == 0) { + return 0; + } + + uint minWithdrawal = 100; // 1% fee rounds to at least 1 wei. + if (maxWithdrawal < minWithdrawal) { + return 0; + } + return minWithdrawal + (_amount % (maxWithdrawal.sub(minWithdrawal).add(1))); + } + function openTroveExt(uint _i, uint _ETH, uint _ZUSDAmount) public payable { uint actor = _i % NUMBER_OF_ACTORS; EchidnaProxy echidnaProxy = echidnaProxies[actor]; uint actorBalance = address(echidnaProxy).balance; - // we pass in CCR instead of MCR in case it’s the first one - uint ETH = getAdjustedETH(actorBalance, _ETH, CCR); - uint ZUSDAmount = getAdjustedZUSD(ETH, _ZUSDAmount, CCR); + // Keep convenience opens comfortably above CCR so collateral-exit + // wrappers have room to exercise withdrawal paths. + uint openRatio = CCR.mul(2); + uint ETH = getAdjustedETH(actorBalance, _ETH, openRatio); + uint ZUSDAmount = getAdjustedZUSD(ETH, _ZUSDAmount, openRatio); - echidnaProxy.openTrovePrx(ETH, ZUSDAmount, address(0), address(0), 0); + echidnaProxy.openTrovePrx(ETH, ZUSDAmount, address(0), address(0), 1e18); numberOfTroves = troveManager.getTroveOwnersCount(); assert(numberOfTroves > 0); @@ -245,7 +311,8 @@ contract EchidnaTester { uint _maxFee ) public payable { uint actor = _i % NUMBER_OF_ACTORS; - echidnaProxies[actor].openTrovePrx(_ETH, _ZUSDAmount, _upperHint, _lowerHint, _maxFee); + uint maxFee = _clampMaxFee(_maxFee); + echidnaProxies[actor].openTrovePrx(_ETH, _ZUSDAmount, _upperHint, _lowerHint, maxFee); } function addCollExt(uint _i, uint _ETH) external payable { @@ -275,7 +342,12 @@ contract EchidnaTester { address _lowerHint ) external { uint actor = _i % NUMBER_OF_ACTORS; - echidnaProxies[actor].withdrawCollPrx(_amount, _upperHint, _lowerHint); + EchidnaProxy echidnaProxy = echidnaProxies[actor]; + uint amount = getAdjustedCollWithdrawal(address(echidnaProxy), _amount); + if (amount == 0) { + return; + } + echidnaProxy.withdrawCollPrx(amount, _upperHint, _lowerHint); } function withdrawZUSDExt( @@ -286,7 +358,8 @@ contract EchidnaTester { uint _maxFee ) external { uint actor = _i % NUMBER_OF_ACTORS; - echidnaProxies[actor].withdrawZUSDPrx(_amount, _upperHint, _lowerHint, _maxFee); + uint maxFee = _clampMaxFee(_maxFee); + echidnaProxies[actor].withdrawZUSDPrx(_amount, _upperHint, _lowerHint, maxFee); } function repayZUSDExt(uint _i, uint _amount, address _upperHint, address _lowerHint) external { @@ -324,7 +397,7 @@ contract EchidnaTester { _isDebtIncrease, address(0), address(0), - 0 + 1e18 ); } @@ -339,6 +412,7 @@ contract EchidnaTester { uint _maxFee ) external payable { uint actor = _i % NUMBER_OF_ACTORS; + uint maxFee = _clampMaxFee(_maxFee); echidnaProxies[actor].adjustTrovePrx( _ETH, _collWithdrawal, @@ -346,7 +420,7 @@ contract EchidnaTester { _isDebtIncrease, _upperHint, _lowerHint, - _maxFee + maxFee ); } @@ -405,10 +479,21 @@ contract EchidnaTester { // PriceFeed function setPriceExt(uint256 _price) external { + if (_price == 0) { + _price = 1; + } bool result = priceFeedTestnet.setPrice(_price); assert(result); } + function _clampMaxFee(uint _maxFee) internal pure returns (uint) { + uint maxFee = _maxFee % (1e18 + 1); + if (maxFee < 5e15) { + maxFee = 5e15; + } + return maxFee; + } + // -------------------------- // Invariants and properties // -------------------------- @@ -535,6 +620,10 @@ contract EchidnaTester { // Total ZUSD matches function echidna_ZUSD_global_balances() public view returns (bool) { uint totalSupply = zusdToken.totalSupply(); + if (totalSupply == 0) { + return true; + } + uint gasPoolBalance = zusdToken.balanceOf(address(gasPool)); uint activePoolBalance = activePool.getZUSDDebt(); diff --git a/contracts/TestContracts/ExitFeeControllerMock.sol b/contracts/TestContracts/ExitFeeControllerMock.sol new file mode 100644 index 0000000..a825a19 --- /dev/null +++ b/contracts/TestContracts/ExitFeeControllerMock.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.6.11; +pragma experimental ABIEncoderV2; + +import "../Interfaces/colfee/IExitFeeController.sol"; + +/// @title ExitFeeControllerMock +/// @notice Test double for the ColFee controller. NOT production code — lives in +/// TestContracts/ only. The production controller is Solidity 0.8.20 and +/// cannot be compiled into the 0.6.11 zero-contracts workspace, so the +/// hooks are exercised against this configurable stand-in. +/// +/// Only `quoteExitFee` is implemented (the single selector the product +/// hook calls). It deliberately does NOT inherit `IExitFeeController` so +/// we avoid stubbing the full admin/view surface; the selector + ABI of +/// `quoteExitFee` match, which is all `IExitFeeController(ctrl).quoteExitFee` +/// needs at the call site. +contract ExitFeeControllerMock { + bool public doRevert; // when true, quoteExitFee reverts → exercises CONTROLLER_REVERT fail-open + bool public activeFlag; + uint16 public rateBps; + address public feeReceiverAddr; + uint8 public reasonCode; + + // Optional override so tests can return a deliberately malformed quote + // (e.g. feeAmount > gross) to exercise the INVALID_QUOTE guard. + bool public overrideAmounts; + uint256 public forcedFeeAmount; + uint256 public forcedNetAmount; + + function configure( + bool _active, + uint16 _rateBps, + address _feeReceiver, + uint8 _reason + ) external { + activeFlag = _active; + rateBps = _rateBps; + feeReceiverAddr = _feeReceiver; + reasonCode = _reason; + } + + function setRevert(bool _v) external { + doRevert = _v; + } + + /// @dev Simulate a controller that becomes code-less after being wired in + /// (e.g. a self-destructed/destroyed proxy) to exercise the product's + /// _safeQuote extcodesize fail-open guard. + function destroy() external { + selfdestruct(msg.sender); + } + + function setForcedAmounts(bool _on, uint256 _fee, uint256 _net) external { + overrideAmounts = _on; + forcedFeeAmount = _fee; + forcedNetAmount = _net; + } + + function quoteExitFee( + bytes32, + address, + address, + uint256 gross + ) external view returns (IExitFeeController.ExitFeeQuote memory q) { + require(!doRevert, "EFCMock: forced revert"); + q.active = activeFlag; + q.rateBps = rateBps; + q.feeReceiver = feeReceiverAddr; + q.reason = reasonCode; + if (overrideAmounts) { + q.feeAmount = forcedFeeAmount; + q.netAmount = forcedNetAmount; + } else if (activeFlag) { + q.feeAmount = (gross * uint256(rateBps)) / 10000; + q.netAmount = gross - q.feeAmount; + } else { + q.netAmount = gross; + } + } +} diff --git a/contracts/TestContracts/GasSinkFeeReceiver.sol b/contracts/TestContracts/GasSinkFeeReceiver.sol new file mode 100644 index 0000000..6066a78 --- /dev/null +++ b/contracts/TestContracts/GasSinkFeeReceiver.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.6.11; + +/// @notice ColFee test double: a fee receiver that burns essentially all the +/// gas forwarded to it. consumeAll=false → burns down to a small floor +/// then RETURNS SUCCESS (the starvation shape: without the pool's +/// FEE_LEG_GAS_CAP this would leave the claimant leg out of gas); +/// consumeAll=true → burns until it OOGs (fee leg fails → fail-open). +contract GasSinkFeeReceiver { + bool public consumeAll; + uint256 public totalReceived; + + function setConsumeAll(bool _v) external { + consumeAll = _v; + } + + receive() external payable { + totalReceived += msg.value; + uint256 floor = consumeAll ? 0 : 5000; + bytes32 h; + while (gasleft() > floor) { + h = keccak256(abi.encode(h)); + } + } +} diff --git a/contracts/TestContracts/LegacyCollSurplusPoolMock.sol b/contracts/TestContracts/LegacyCollSurplusPoolMock.sol new file mode 100644 index 0000000..ce64ff2 --- /dev/null +++ b/contracts/TestContracts/LegacyCollSurplusPoolMock.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.6.11; + +/// @notice ColFee test double simulating the LIVE (pre-upgrade) CollSurplusPool +/// implementation: `claimColl` exists but `claimCollWithFee` does NOT, +/// and there is no fallback — so the hook's pool call reverts on the +/// missing selector, reproducing the surface-activated-before-pool- +/// upgrade ordering hazard. Deliberately does not implement +/// ICollSurplusPool (which now declares claimCollWithFee). +contract LegacyCollSurplusPoolMock { + address public borrowerOperationsAddress; + uint256 internal ETH; + mapping(address => uint256) internal balances; + + function setBO(address _bo) external { + borrowerOperationsAddress = _bo; + } + + /// Test-only funding shortcut (the real pool is fed via TroveManager/ActivePool). + function setSurplus(address _account) external payable { + balances[_account] = balances[_account] + msg.value; + ETH = ETH + msg.value; + } + + function getETH() external view returns (uint256) { + return ETH; + } + + function getCollateral(address _account) external view returns (uint256) { + return balances[_account]; + } + + function claimColl(address _account) external { + require(msg.sender == borrowerOperationsAddress, "Legacy: caller is not BO"); + uint256 claimableColl = balances[_account]; + require(claimableColl > 0, "CollSurplusPool: No collateral available to claim"); + balances[_account] = 0; + ETH = ETH - claimableColl; + (bool success, ) = _account.call{ value: claimableColl }(""); + require(success, "CollSurplusPool: sending ETH failed"); + } +} diff --git a/contracts/TestContracts/ReentrantSurplusClaimer.sol b/contracts/TestContracts/ReentrantSurplusClaimer.sol new file mode 100644 index 0000000..07cefea --- /dev/null +++ b/contracts/TestContracts/ReentrantSurplusClaimer.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.6.11; + +import "../Interfaces/IBorrowerOperations.sol"; + +/// @notice Test attacker: attempts to re-enter BorrowerOperations.claimCollateral() +/// from its receive() while the surplus payout is in flight. CEI in +/// CollSurplusPool zeroes the balance before paying, so the reentrant +/// claim must hit "No collateral available to claim"; the attacker +/// swallows that revert (recording it) so the outer claim completes. +contract ReentrantSurplusClaimer { + IBorrowerOperations public borrowerOperations; + bool public reentryAttempted; + bool public reentrySucceeded; + uint256 public totalReceived; + + constructor(IBorrowerOperations _borrowerOperations) public { + borrowerOperations = _borrowerOperations; + } + + /// Open a trove owned by this contract (so a full redemption parks its + /// surplus here and claimCollateral() pays out to this receive()). + function openTrove( + uint256 _maxFee, + uint256 _zusdAmount, + address _upperHint, + address _lowerHint + ) external payable { + borrowerOperations.openTrove{ value: msg.value }( + _maxFee, + _zusdAmount, + _upperHint, + _lowerHint + ); + } + + function claim() external { + borrowerOperations.claimCollateral(); + } + + receive() external payable { + totalReceived += msg.value; + if (!reentryAttempted) { + reentryAttempted = true; + try borrowerOperations.claimCollateral() { + reentrySucceeded = true; + } catch {} + } + } +} diff --git a/deployment/deploy/8-CollSurplusPool.ts b/deployment/deploy/8-CollSurplusPool.ts new file mode 100644 index 0000000..093b25e --- /dev/null +++ b/deployment/deploy/8-CollSurplusPool.ts @@ -0,0 +1,18 @@ +import { DeployFunction } from "hardhat-deploy/types"; +import { deployWithCustomProxy } from "../../scripts/helpers/helpers"; +import { getContractNameFromScriptFileName } from "../../scripts/helpers/utils"; +const path = require("path"); +const deploymentName = getContractNameFromScriptFileName(path.basename(__filename)); + +const func: DeployFunction = async (hre) => { + const { getNamedAccounts } = hre; + const { deployer } = await getNamedAccounts(); + + // CollSurplusPool has no constructor args: its wiring lives in proxy storage + // (set once via setAddresses), so the implementation deploys bare and the + // existing proxy keeps its state across the upgrade. + await deployWithCustomProxy(hre, deployer, deploymentName, "UpgradableProxy"); +}; + +func.tags = [deploymentName]; +export default func; diff --git a/hardhat.config.ts b/hardhat.config.ts index 87ddb60..074c4a5 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -158,6 +158,16 @@ const config: HardhatUserConfig = { enabled: true, runs: 100, }, + // Emit per-contract storageLayout so the ColFee storage-layout + // zero-diff regression (tests-colfee/StorageLayout.zerodiff.test.js) + // can assert the surplus-claim fee hook adds NO state to the + // upgradeable BorrowerOperations / CollSurplusPool proxies or + // ActivePool. Additive solc output; does not affect bytecode. + outputSelection: { + "*": { + "*": ["storageLayout"], + }, + }, }, }, { @@ -240,6 +250,13 @@ const config: HardhatUserConfig = { timeout: 100000, gasPrice: 66000000, blockGasLimit: 6800000, + // Source verification target for `hardhat etherscan-verify` (hardhat-deploy): + // Rootstock Blockscout, etherscan-compatible API. The task submits the + // standard-JSON input stored in the deployment record; Blockscout accepts + // any non-empty --api-key value. + verify: { + etherscan: { apiUrl: "https://rootstock-testnet.blockscout.com" }, + }, //timeout: 20000, // increase if needed; 20000 is the default value //allowUnlimitedContractSize, //EIP170 contrtact size restriction temporal testnet workaround }, @@ -275,6 +292,10 @@ const config: HardhatUserConfig = { gasPrice: 66000000, blockGasLimit: 6800000, gas: "auto", + // Source verification target for `hardhat etherscan-verify` (see testnet note). + verify: { + etherscan: { apiUrl: "https://rootstock.blockscout.com" }, + }, //timeout: 20000, // increase if needed; 20000 is the default value }, rskForkedMainnet: { diff --git a/package.json b/package.json index 994bdad..95c49b6 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "prepare-artifacts": "node scripts/prepare-artifacts.js", "prepack:prepare-dist": "echo 'commented out: yarn prepare-dist'", "test": "hardhat test", + "test:colfee": "hardhat test tests-colfee/*.test.js", + "test:all": "hardhat test && hardhat test tests-colfee/*.test.js", "coverage": "hardhat coverage", "coveralls": "cat coverage/lcov.info | coveralls", "hh:fork-testnet": "yarn hardhat node --fork https://testnet.sovryn.app/rpc --no-deploy", @@ -49,8 +51,8 @@ "author": "", "license": "ISC", "dependencies": { - "@openzeppelin/contracts": "^3.3.0", "@nomicfoundation/hardhat-ethers": "^3.0.4", + "@openzeppelin/contracts": "^3.3.0", "cross-env": "^7.0.3", "decimal.js": "^10.2.0", "eth-mutants": "^0.1.1", @@ -94,6 +96,8 @@ "hardhat-deploy": "^0.11.37", "hardhat-deploy-ethers": "^0.4.1", "hardhat-gas-reporter": "^1.0.9", + "husky": "^4.3.8", + "lint-staged": "^13.2.0", "node-logs": "^1.1.0", "npm-run-all": "^4.1.5", "prettier": "^2.8.4", @@ -105,5 +109,19 @@ "typechain": "^8.3.1", "typescript": "^4.9.5", "web3": "^1.3.4" + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged", + "pre-push": "yarn lint" + } + }, + "lint-staged": { + "*.sol": [ + "prettier --write" + ], + "*.{js,ts,json,md,yml,yaml}": [ + "prettier --write" + ] } } diff --git a/scripts/helpers/helpers.ts b/scripts/helpers/helpers.ts index 372bd3d..62d8cf2 100644 --- a/scripts/helpers/helpers.ts +++ b/scripts/helpers/helpers.ts @@ -385,7 +385,7 @@ const deployWithCustomProxy = async ( const proxyDeployment = await get(proxyDeployedName); await deploymentsSave(logicName, { abi: tx.abi, - address: proxy.address, // used to override receipt.contractAddress (useful for proxies) + address: proxyDeployment.address, // used to override receipt.contractAddress (useful for proxies); the ethers v6 Contract has no .address, which would silently fall back to the implementation address here receipt: tx.receipt, bytecode: tx.bytecode, deployedBytecode: tx.deployedBytecode, diff --git a/tests-colfee/StorageLayout.zerodiff.test.js b/tests-colfee/StorageLayout.zerodiff.test.js new file mode 100644 index 0000000..6159b71 --- /dev/null +++ b/tests-colfee/StorageLayout.zerodiff.test.js @@ -0,0 +1,83 @@ +// ColFee security perimeter — storage-layout ZERO-DIFF regression. +// +// The Zero surplus-claim exit-fee hook adds NO storage to any deployed +// upgradeable contract: the surface id is a constant, the exit-fee controller +// pointer lives in an EIP-1967-style unstructured slot, and the hook declares +// no new state variables on the BorrowerOperations or CollSurplusPool proxies. +// That is true BY CONSTRUCTION today — but nothing GUARDS a future edit from +// appending a `uint256` to the proxy and silently corrupting every live +// trove's storage on the next upgrade. +// +// This test is that guard. It compares the current, normalized solc +// `storageLayout` of BorrowerOperations, CollSurplusPool, and ActivePool +// against a committed baseline and FAILS on any label/slot/offset/type +// difference. The ColFee lending side carries an equivalent Hardhat guard over +// its own upgradeable contracts. +// +// SCOPE OF THE BASELINE (be precise about what this proves): the committed +// baseline was captured at `sovryn-perimeter-fee @ b6584a6`, a tree that ALREADY +// contains the borrower-exit hook (`_sendCollWithExitFee`, the unstructured +// controller slot, the surface-id constants). So this guard proves the +// SURPLUS-CLAIM hook appended no state, and forbids any future append to all +// three contracts. It does NOT independently re-prove the borrower-exit hook's +// zero-diff — that holds by construction (constants + an EIP-1967-style slot, +// neither of which occupies a regular-storage slot) and is reviewable in the +// contract source, but it is not what this baseline compares against. +// +// Requires `storageLayout` in the 0.6.11 compiler outputSelection +// (hardhat.config.ts) — the shared helper throws (never silently passes) if the +// layout is missing or empty, closing the "two empty layouts compare equal" +// false-PASS hole. +// +// REGENERATE BASELINE (only on an INTENTIONAL, reviewed layout change): +// 1. git worktree add sovryn-perimeter-fee +// 2. overlay this repo's hardhat.config.ts (storageLayout output) into +// 3. (cd && npx hardhat compile --force) +// 4. extract the normalized layout for the three targets and overwrite +// tests-colfee/baselines/storage-layout.sovryn-perimeter-fee.json (keep _meta). + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const { normalizedLayout } = require("./utils/storageLayout.js"); + +const BASELINE = path.join(__dirname, "baselines", "storage-layout.sovryn-perimeter-fee.json"); + +const TARGETS = [ + "contracts/BorrowerOperations.sol:BorrowerOperations", // hooked upgradeable proxy + "contracts/ActivePool.sol:ActivePool", // native pusher — must stay untouched + "contracts/CollSurplusPool.sol:CollSurplusPool", // gains claimCollWithFee — functions only, no state +]; + +describe("ColFee — storage-layout zero-diff (Zero surplus-claim exit fee)", () => { + let baseline; + + before(() => { + baseline = JSON.parse(fs.readFileSync(BASELINE, "utf8")); + }); + + it("baseline snapshot is present and non-empty for every target", () => { + for (const fq of TARGETS) { + assert.ok(Array.isArray(baseline[fq]), `baseline missing ${fq}`); + assert.ok( + baseline[fq].length > 0, + `baseline for ${fq} is empty (would be a false pass)` + ); + } + }); + + for (const fq of TARGETS) { + it(`${fq}: current layout == sovryn-perimeter-fee baseline (no appended state)`, async () => { + const current = await normalizedLayout(fq); + const base = baseline[fq]; + // Exact structural equality: label/slot/offset/type per entry, in order. + assert.deepStrictEqual( + current, + base, + `STORAGE LAYOUT DIFF for ${fq} vs sovryn-perimeter-fee baseline:\n` + + ` baseline entries: ${base.length}\n current entries : ${current.length}\n` + + ` current: ${JSON.stringify(current)}` + ); + }); + } +}); diff --git a/tests-colfee/ZeroBorrowerExit.adjust.test.js b/tests-colfee/ZeroBorrowerExit.adjust.test.js new file mode 100644 index 0000000..7a65d86 --- /dev/null +++ b/tests-colfee/ZeroBorrowerExit.adjust.test.js @@ -0,0 +1,556 @@ +// ColFee — Zero borrower collateral-exit hook: withdrawColl / adjustTrove legs. +// Surface: SURFACE_ZERO_WITHDRAW_COLL +// Covers the `_moveTokensAndETHfromAdjustment` hook reached by: +// - withdrawColl(amount, ...) +// - adjustTrove(_collWithdrawal>0, _isDebtIncrease=false, msg.value=0) +// +// In-process full-system deployment (the zero-contracts test convention; the +// production controller is 0.8.20 so the hook is exercised against +// ExitFeeControllerMock, a 0.6.11 stand-in). _closeTrove has its own suite, as +// do the no-touch / failure-passthrough invariants. + +const deploymentHelper = require("../utils/js/deploymentHelpers.js"); +const testHelpers = require("../utils/js/testHelpers.js"); +const { + assertRevertWithReason, + assertSurface, + SURFACE_ZERO_WITHDRAW_COLL, +} = require("./utils/assertions.js"); +const timeMachine = require("ganache-time-traveler"); + +const BorrowerOperationsTester = artifacts.require("./BorrowerOperationsTester.sol"); +const TroveManagerTester = artifacts.require("TroveManagerTester"); +const MassetManagerTester = artifacts.require("MassetManagerTester"); +const ExitFeeControllerMock = artifacts.require("ExitFeeControllerMock"); + +const th = testHelpers.TestHelper; +const dec = th.dec; +const toBN = th.toBN; +const ZERO_ADDRESS = th.ZERO_ADDRESS; + +// SkipReason enum (mirrors IExitFeeController.SkipReason) +const NONE = 0; +const INACTIVE = 1; +const INVALID_QUOTE = 3; +const CONTROLLER_REVERT = 4; + +const GAS_PRICE = toBN(dec(1, 9)); // 1 gwei — used to back out gas cost from the borrower's RBTC delta + +contract("ColFee — Zero borrower collateral exit (adjust/withdraw)", async (accounts) => { + const [owner, alice, bob] = accounts; + const feeReceiver = accounts[995]; + const multisig = accounts[999]; + + let priceFeed; + let troveManager; + let activePool; + let sortedTroves; + let borrowerOperations; + let controller; + let contracts; + + const openTrove = async (params) => th.openTrove(contracts, params); + + const getEvent = (tx, name) => tx.logs.find((l) => l.event === name); + + before(async () => { + contracts = await deploymentHelper.deployLiquityCore(); + const permit2 = contracts.permit2; + + contracts.borrowerOperations = await BorrowerOperationsTester.new(permit2.address); + contracts.massetManager = await MassetManagerTester.new(); + contracts.troveManager = await TroveManagerTester.new(permit2.address); + contracts = await deploymentHelper.deployZUSDTokenTester(contracts); + const ZEROContracts = await deploymentHelper.deployZEROTesterContractsHardhat(multisig); + + await ZEROContracts.zeroToken.unprotectedMint(multisig, toBN(dec(20, 24))); + + await deploymentHelper.connectZEROContracts(ZEROContracts); + await deploymentHelper.connectCoreContracts(contracts, ZEROContracts); + await deploymentHelper.connectZEROContractsToCore(ZEROContracts, contracts); + + priceFeed = contracts.priceFeedTestnet; + troveManager = contracts.troveManager; + activePool = contracts.activePool; + sortedTroves = contracts.sortedTroves; + borrowerOperations = contracts.borrowerOperations; + + await borrowerOperations.setMassetManagerAddress(contracts.massetManager.address); + }); + + let snapshotId; + beforeEach(async () => { + const snap = await timeMachine.takeSnapshot(); + snapshotId = snap["result"]; + controller = await ExitFeeControllerMock.new(); + }); + afterEach(async () => { + await timeMachine.revertToSnapshot(snapshotId); + }); + + // Open a roomy trove so a meaningful collateral withdrawal stays well above MCR. + const openRoomyTrove = async (from) => + openTrove({ + ICR: toBN(dec(10, 18)), + extraParams: { from, value: toBN(dec(100, "ether")) }, + }); + + // --- setExitFeeController (rotatable, owner-gated) --- + + it("setExitFeeController: owner-gated, rejects zero, emits ExitFeeControllerSet", async () => { + assert.equal(await borrowerOperations.exitFeeController(), ZERO_ADDRESS); + + await assertRevertWithReason( + borrowerOperations.setExitFeeController(controller.address, { from: bob }), + "Ownable:: access denied" + ); + // Reason-checked on purpose: `checkContract(address(0))` would ALSO revert + // here ("Account cannot be zero address"), so an unchecked assertRevert + // would still pass with the explicit EFC:zero guard deleted. + await assertRevertWithReason( + borrowerOperations.setExitFeeController(ZERO_ADDRESS, { from: owner }), + "EFC:zero" + ); + + const tx = await borrowerOperations.setExitFeeController(controller.address, { + from: owner, + }); + const ev = getEvent(tx, "ExitFeeControllerSet"); + assert.isDefined(ev, "ExitFeeControllerSet not emitted"); + assert.equal(ev.args.previous, ZERO_ADDRESS); + assert.equal(ev.args.current, controller.address); + assert.equal(await borrowerOperations.exitFeeController(), controller.address); + }); + + // --- withdrawColl charging path --- + + it("withdrawColl (fee active): ActivePool -= gross, feeReceiver += fee, borrower += net", async () => { + await openRoomyTrove(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); // 50 bps + + const gross = toBN(dec(1, "ether")); + const fee = gross.mul(toBN(50)).div(toBN(10000)); + const net = gross.sub(fee); + + const apEthBefore = await activePool.getETH(); + const apRawBefore = toBN(await web3.eth.getBalance(activePool.address)); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { + from: alice, + gasPrice: GAS_PRICE, + }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue( + (await activePool.getETH()).eq(apEthBefore.sub(gross)), + "ActivePool ETH != -gross" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(activePool.address)).eq(apRawBefore.sub(gross)), + "ActivePool raw ether != -gross" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore.add(fee)), + "feeReceiver != +fee" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(net).sub(gasCost)), + "borrower != +net (minus gas)" + ); + + const ev = getEvent(tx, "ExitFeeApplied"); + assert.isDefined(ev, "ExitFeeApplied not emitted"); + assertSurface(ev, SURFACE_ZERO_WITHDRAW_COLL, "withdrawColl ExitFeeApplied"); + assert.equal(ev.args.actor, alice); + assert.equal(ev.args.asset, ZERO_ADDRESS); + assert.equal(ev.args.subProduct, ZERO_ADDRESS); + assert.equal(ev.args.recipient, alice); + assert.equal(ev.args.feeReceiver, feeReceiver); + assert.isTrue(toBN(ev.args.grossAmount).eq(gross)); + assert.isTrue(toBN(ev.args.feeAmount).eq(fee)); + assert.isTrue(toBN(ev.args.netAmount).eq(net)); + }); + + it("withdrawColl: trove accounting + ICR + TCR + sorted position are fee-independent (vs baseline)", async () => { + await openRoomyTrove(alice); + const gross = toBN(dec(1, "ether")); + const collBefore = await troveManager.getTroveColl(alice); + const price = await priceFeed.getPrice(); + + // local snapshot so we can run the SAME withdrawal twice: baseline then fee-active + const inner = (await timeMachine.takeSnapshot())["result"]; + + // baseline: no controller set → full gross to borrower + await borrowerOperations.withdrawColl(gross, alice, alice, { from: alice }); + const baseColl = await troveManager.getTroveColl(alice); + const baseICR = await troveManager.getCurrentICR(alice, price); + const baseTCR = await th.getTCR(contracts); + const baseInList = await sortedTroves.contains(alice); + + await timeMachine.revertToSnapshot(inner); + + // fee-active path + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); + await borrowerOperations.withdrawColl(gross, alice, alice, { from: alice }); + const feeColl = await troveManager.getTroveColl(alice); + const feeICR = await troveManager.getCurrentICR(alice, price); + const feeTCR = await th.getTCR(contracts); + const feeInList = await sortedTroves.contains(alice); + + // trove collateral falls by full GROSS in both cases (fee comes out of the payout, not the trove) + assert.isTrue(baseColl.eq(collBefore.sub(gross)), "baseline coll != before-gross"); + assert.isTrue(feeColl.eq(baseColl), "fee-path coll != baseline coll"); + assert.isTrue(feeICR.eq(baseICR), "ICR differs from baseline"); + assert.isTrue(toBN(feeTCR).eq(toBN(baseTCR)), "TCR differs from baseline"); + assert.equal(feeInList, baseInList); + }); + + it("adjustTrove (collWithdrawal>0, debt unchanged): same split as withdrawColl", async () => { + await openRoomyTrove(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); + + const gross = toBN(dec(1, "ether")); + const fee = gross.mul(toBN(50)).div(toBN(10000)); + const net = gross.sub(fee); + + const apEthBefore = await activePool.getETH(); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + + // _maxFeePercentage, _collWithdrawal, _ZUSDChange=0, _isDebtIncrease=false, no msg.value + const tx = await borrowerOperations.adjustTrove(0, gross, 0, false, alice, alice, { + from: alice, + gasPrice: GAS_PRICE, + }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue( + (await activePool.getETH()).eq(apEthBefore.sub(gross)), + "ActivePool != -gross" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore.add(fee)), + "feeReceiver != +fee" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(net).sub(gasCost)), + "borrower != +net (minus gas)" + ); + const ev = getEvent(tx, "ExitFeeApplied"); + assert.isDefined(ev, "ExitFeeApplied not emitted"); + assert.equal(ev.args.recipient, alice); + assert.isTrue(toBN(ev.args.grossAmount).eq(gross)); + assert.isTrue(toBN(ev.args.feeAmount).eq(fee)); + assert.isTrue(toBN(ev.args.netAmount).eq(net)); + }); + + // --- arithmetic edges: truncation and dust --- + + it("withdrawColl: non-round gross at a truncating rate — fee + net == gross exactly, remainder to the borrower", async () => { + // The 1-ether/50-bps cases divide exactly, so they cannot catch a rounding + // bug. Here gross * rateBps is NOT a multiple of 10000: the truncated wei + // must land with the BORROWER (net = gross - fee), and the pool must still + // drain by exactly gross with no residue. + await openRoomyTrove(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + const rateBps = toBN(37); + await controller.configure(true, 37, feeReceiver, NONE); + + const gross = toBN("1234567890123456789"); // deliberately non-round + const fee = gross.mul(rateBps).div(toBN(10000)); // floor division + const net = gross.sub(fee); + assert.isTrue( + fee.mul(toBN(10000)).lt(gross.mul(rateBps)), + "chosen gross/rate must truncate, else this test proves nothing" + ); + + const apEthBefore = await activePool.getETH(); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { + from: alice, + gasPrice: GAS_PRICE, + }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue(fee.add(net).eq(gross), "fee + net != gross"); + assert.isTrue( + (await activePool.getETH()).eq(apEthBefore.sub(gross)), + "ActivePool != -gross" + ); + const frDelta = toBN(await web3.eth.getBalance(feeReceiver)).sub(frBefore); + const aliceDelta = toBN(await web3.eth.getBalance(alice)) + .sub(aliceBefore) + .add(gasCost); + assert.isTrue(frDelta.eq(fee), "feeReceiver delta != truncated fee"); + assert.isTrue(aliceDelta.eq(net), "borrower delta != gross - fee"); + assert.isTrue(frDelta.add(aliceDelta).eq(gross), "measured legs do not sum to gross"); + + const ev = getEvent(tx, "ExitFeeApplied"); + assert.isDefined(ev); + assert.isTrue(toBN(ev.args.feeAmount).eq(fee)); + assert.isTrue(toBN(ev.args.netAmount).eq(net)); + }); + + it("withdrawColl: dust gross where the fee truncates to 0 → ExitFeeSkipped(NONE), full gross, nothing charged", async () => { + // 199 wei at 50 bps floors to 0. `q.active && q.feeAmount > 0` is false, so + // the hook must take the non-charging path — not send a 0-value fee leg and + // not emit ExitFeeApplied. + await openRoomyTrove(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); + + const gross = toBN(199); // 199 * 50 / 10000 == 0 + const apEthBefore = await activePool.getETH(); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { + from: alice, + gasPrice: GAS_PRICE, + }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue((await activePool.getETH()).eq(apEthBefore.sub(gross))); + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore), + "dust fee must not be charged" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(gross).sub(gasCost)), + "borrower must receive the FULL dust gross" + ); + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev, "ExitFeeSkipped not emitted"); + assert.equal(toBN(ev.args.reason).toNumber(), NONE); + assert.equal(toBN(ev.args.rateBps).toNumber(), 50, "resolved rate must survive the skip"); + assert.isUndefined(getEvent(tx, "ExitFeeApplied")); + }); + + // --- fail-open branches --- + + it("withdrawColl: controller unset → full gross to borrower, ExitFeeSkipped(CONTROLLER_REVERT)", async () => { + await openRoomyTrove(alice); + const gross = toBN(dec(1, "ether")); + + const apEthBefore = await activePool.getETH(); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { from: alice }); + + assert.isTrue((await activePool.getETH()).eq(apEthBefore.sub(gross))); + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore), + "feeReceiver should be untouched" + ); + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev, "ExitFeeSkipped not emitted"); + assertSurface(ev, SURFACE_ZERO_WITHDRAW_COLL, "withdrawColl ExitFeeSkipped"); + assert.equal(toBN(ev.args.reason).toNumber(), CONTROLLER_REVERT); + assert.isUndefined(getEvent(tx, "ExitFeeApplied")); + }); + + it("setExitFeeController: rejects a no-code address (EOA / destroyed)", async () => { + // Defense-in-depth: a no-code controller would make the high-level + // quoteExitFee call revert with "function call to a non-contract account", + // which 0.6.11 try/catch does NOT catch — so reject it at config time. + await assertRevertWithReason( + borrowerOperations.setExitFeeController(bob, { from: owner }), // bob = EOA, no code + "Account code size cannot be zero" + ); + }); + + it("withdrawColl: controller becomes no-code after being set → fail open, ExitFeeSkipped(CONTROLLER_REVERT)", async () => { + await openRoomyTrove(alice); + // Controller has code when wired in (passes the setter check)... + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); + // ...then becomes code-less (simulates a destroyed/self-destructed proxy). + await controller.destroy(); + + const gross = toBN(dec(1, "ether")); + const apEthBefore = await activePool.getETH(); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + + // Must NOT revert — the borrower exit must complete (fail open). + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { from: alice }); + + assert.isTrue( + (await activePool.getETH()).eq(apEthBefore.sub(gross)), + "ActivePool != -gross" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore), + "feeReceiver should be untouched" + ); + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev, "ExitFeeSkipped not emitted"); + assert.equal(toBN(ev.args.reason).toNumber(), CONTROLLER_REVERT); + assert.isUndefined(getEvent(tx, "ExitFeeApplied")); + }); + + it("withdrawColl: controller reverts → full gross to borrower, ExitFeeSkipped(CONTROLLER_REVERT)", async () => { + await openRoomyTrove(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.setRevert(true); + const gross = toBN(dec(1, "ether")); + + const apEthBefore = await activePool.getETH(); + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { from: alice }); + + assert.isTrue((await activePool.getETH()).eq(apEthBefore.sub(gross))); + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev); + assert.equal(toBN(ev.args.reason).toNumber(), CONTROLLER_REVERT); + }); + + it("withdrawColl: malformed quote (feeAmount > gross) → INVALID_QUOTE, full gross to borrower", async () => { + await openRoomyTrove(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + const gross = toBN(dec(1, "ether")); + await controller.configure(true, 50, feeReceiver, NONE); + await controller.setForcedAmounts(true, gross.add(toBN(1)), 0); // fee > gross + + const apEthBefore = await activePool.getETH(); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { from: alice }); + + assert.isTrue((await activePool.getETH()).eq(apEthBefore.sub(gross))); + assert.isTrue(toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore)); + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev); + assert.equal(toBN(ev.args.reason).toNumber(), INVALID_QUOTE); + // the resolved rate must survive onto the skip event (parity with VAULT_REVERT path) + assert.equal( + toBN(ev.args.rateBps).toNumber(), + 50, + "INVALID_QUOTE skip must preserve the controller's rateBps" + ); + }); + + it("withdrawColl: consumer trusts the controller's feeAmount (no fee↔rate reconciliation), enforcing only pool safety", async () => { + // SRP: the consumer does NOT reproduce the controller's fee-from-rate formula. A quote with + // feeAmount == gross (<= gross, so pool-safe) is charged in full even though rateBps looks + // inconsistent — fee↔rate correctness is the configured controller's job. + // The consumer's guarantee is unchanged: ActivePool drains by exactly gross, no residue. + await openRoomyTrove(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + const gross = toBN(dec(1, "ether")); + await controller.configure(true, 0, feeReceiver, NONE); + await controller.setForcedAmounts(true, gross, 0); // fee == gross (pool-safe), rateBps == 0 + + const apEthBefore = await activePool.getETH(); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { from: alice }); + + assert.isTrue( + (await activePool.getETH()).eq(apEthBefore.sub(gross)), + "pool must drain by exactly gross" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore.add(gross)), + "fee leg charges feeAmount" + ); + const ev = getEvent(tx, "ExitFeeApplied"); + assert.isDefined(ev); + assert.isTrue(toBN(ev.args.feeAmount).eq(gross)); + assert.isTrue(toBN(ev.args.netAmount).eq(toBN(0)), "net = gross - fee recomputed"); + }); + + it("withdrawColl: forced divergent netAmount is ignored — net recomputed as gross-fee", async () => { + // Pins the defensive recompute: a controller returning a bogus netAmount must not + // be trusted; the charged net is always gross - feeAmount. + await openRoomyTrove(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + const gross = toBN(dec(1, "ether")); + const fee = gross.mul(toBN(50)).div(toBN(10000)); + await controller.configure(true, 50, feeReceiver, NONE); + await controller.setForcedAmounts(true, fee, toBN(0)); // bogus net=0 (correct is gross-fee) + + const apEthBefore = await activePool.getETH(); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { from: alice }); + + assert.isTrue( + (await activePool.getETH()).eq(apEthBefore.sub(gross)), + "ActivePool != -gross" + ); + assert.isTrue(toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore.add(fee))); + const ev = getEvent(tx, "ExitFeeApplied"); + assert.isDefined(ev); + assert.isTrue( + toBN(ev.args.netAmount).eq(gross.sub(fee)), + "net must be recomputed, not the bogus 0" + ); + }); + + it("withdrawColl: active policy with zero rate (feeAmount==0) → ExitFeeSkipped(NONE), full gross", async () => { + await openRoomyTrove(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 0, feeReceiver, NONE); // active, but 0 bps → fee 0 + const gross = toBN(dec(1, "ether")); + + const apEthBefore = await activePool.getETH(); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { from: alice }); + + assert.isTrue((await activePool.getETH()).eq(apEthBefore.sub(gross))); + assert.isTrue(toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore)); + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev); + assert.equal(toBN(ev.args.reason).toNumber(), NONE); + assert.isUndefined(getEvent(tx, "ExitFeeApplied")); + }); + + it("withdrawColl: inactive policy propagates the controller's reason (INACTIVE) onto the skip event", async () => { + // Distinct from the zero-rate (active, NONE) case above: proves a non-zero + // SkipReason from the controller is plumbed through to ExitFeeSkipped. + await openRoomyTrove(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(false, 0, feeReceiver, INACTIVE); + const gross = toBN(dec(1, "ether")); + + const apEthBefore = await activePool.getETH(); + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { from: alice }); + + assert.isTrue((await activePool.getETH()).eq(apEthBefore.sub(gross))); + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev); + assert.equal(toBN(ev.args.reason).toNumber(), INACTIVE); + }); + + it("debt-only adjustTrove (gross==0) emits no ColFee event and skips the controller", async () => { + // Repay / debt-only adjustments move no collateral → the hook must short-circuit + // (no wasted quoteExitFee round-trip, no spurious ExitFeeSkipped). + await openRoomyTrove(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); + + const debtBefore = (await troveManager.Troves(alice))[0]; // [0] = debt + // debt increase, no collateral change, no msg.value → gross == 0 at the hook + const tx = await borrowerOperations.adjustTrove( + dec(1, 18), + 0, + toBN(dec(100, 18)), + true, + alice, + alice, + { + from: alice, + } + ); + + assert.isTrue( + (await troveManager.Troves(alice))[0].gt(debtBefore), + "debt did not increase — setup invalid" + ); + assert.isUndefined(getEvent(tx, "ExitFeeApplied"), "no ColFee event on a debt-only op"); + assert.isUndefined(getEvent(tx, "ExitFeeSkipped"), "no ColFee event on a debt-only op"); + }); +}); diff --git a/tests-colfee/ZeroBorrowerExit.close.test.js b/tests-colfee/ZeroBorrowerExit.close.test.js new file mode 100644 index 0000000..55ff6d2 --- /dev/null +++ b/tests-colfee/ZeroBorrowerExit.close.test.js @@ -0,0 +1,184 @@ +// ColFee — Zero borrower collateral-exit hook: closeTrove leg. +// Surface: SURFACE_ZERO_WITHDRAW_COLL +// +// closeTrove() returns the trove's entire collateral to the borrower; this +// suite proves the exit fee is charged on that payout and that close invariants +// (trove removed, ActivePool drained by exactly the collateral) are preserved. +// The _sendCollWithExitFee branches themselves are covered in the adjust suite. + +const deploymentHelper = require("../utils/js/deploymentHelpers.js"); +const testHelpers = require("../utils/js/testHelpers.js"); +const { assertSurface, SURFACE_ZERO_WITHDRAW_COLL } = require("./utils/assertions.js"); +const timeMachine = require("ganache-time-traveler"); + +const BorrowerOperationsTester = artifacts.require("./BorrowerOperationsTester.sol"); +const TroveManagerTester = artifacts.require("TroveManagerTester"); +const MassetManagerTester = artifacts.require("MassetManagerTester"); +const ExitFeeControllerMock = artifacts.require("ExitFeeControllerMock"); + +const th = testHelpers.TestHelper; +const dec = th.dec; +const toBN = th.toBN; +const ZERO_ADDRESS = th.ZERO_ADDRESS; + +const NONE = 0; +const CONTROLLER_REVERT = 4; +const GAS_PRICE = toBN(dec(1, 9)); + +contract("ColFee — Zero borrower collateral exit (closeTrove)", async (accounts) => { + const [owner, alice, dennis] = accounts; + const feeReceiver = accounts[995]; + const multisig = accounts[999]; + + let zusdToken; + let troveManager; + let activePool; + let sortedTroves; + let borrowerOperations; + let controller; + let contracts; + + const openTrove = async (params) => th.openTrove(contracts, params); + const getTroveEntireColl = async (trove) => th.getTroveEntireColl(contracts, trove); + const getEvent = (tx, name) => tx.logs.find((l) => l.event === name); + + before(async () => { + contracts = await deploymentHelper.deployLiquityCore(); + const permit2 = contracts.permit2; + + contracts.borrowerOperations = await BorrowerOperationsTester.new(permit2.address); + contracts.massetManager = await MassetManagerTester.new(); + contracts.troveManager = await TroveManagerTester.new(permit2.address); + contracts = await deploymentHelper.deployZUSDTokenTester(contracts); + const ZEROContracts = await deploymentHelper.deployZEROTesterContractsHardhat(multisig); + + await ZEROContracts.zeroToken.unprotectedMint(multisig, toBN(dec(20, 24))); + + await deploymentHelper.connectZEROContracts(ZEROContracts); + await deploymentHelper.connectCoreContracts(contracts, ZEROContracts); + await deploymentHelper.connectZEROContractsToCore(ZEROContracts, contracts); + + zusdToken = contracts.zusdToken; + troveManager = contracts.troveManager; + activePool = contracts.activePool; + sortedTroves = contracts.sortedTroves; + borrowerOperations = contracts.borrowerOperations; + + await borrowerOperations.setMassetManagerAddress(contracts.massetManager.address); + }); + + let snapshotId; + beforeEach(async () => { + const snap = await timeMachine.takeSnapshot(); + snapshotId = snap["result"]; + controller = await ExitFeeControllerMock.new(); + }); + afterEach(async () => { + await timeMachine.revertToSnapshot(snapshotId); + }); + + // Open a pair of troves; give `alice` enough ZUSD (from dennis) to repay her + // debt + borrowing fee so closeTrove() succeeds. + const setupCloseable = async () => { + await openTrove({ + extraZUSDAmount: toBN(dec(10000, 18)), + ICR: toBN(dec(2, 18)), + extraParams: { from: dennis }, + }); + await openTrove({ + extraZUSDAmount: toBN(dec(10000, 18)), + ICR: toBN(dec(2, 18)), + extraParams: { from: alice }, + }); + await zusdToken.transfer(alice, await zusdToken.balanceOf(dennis), { from: dennis }); + }; + + it("closeTrove (fee active): ActivePool -= coll, feeReceiver += fee, borrower += net; trove removed", async () => { + await setupCloseable(); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); // 50 bps + + const gross = await getTroveEntireColl(alice); + const fee = gross.mul(toBN(50)).div(toBN(10000)); + const net = gross.sub(fee); + + const apEthBefore = await activePool.getETH(); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + + const tx = await borrowerOperations.closeTrove({ from: alice, gasPrice: GAS_PRICE }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue( + (await activePool.getETH()).eq(apEthBefore.sub(gross)), + "ActivePool != -coll" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore.add(fee)), + "feeReceiver != +fee" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(net).sub(gasCost)), + "borrower != +net (minus gas)" + ); + assert.isFalse(await sortedTroves.contains(alice), "trove not removed from sorted list"); + assert.equal((await troveManager.Troves(alice))[3].toString(), "2"); // closedByOwner + + const ev = getEvent(tx, "ExitFeeApplied"); + assert.isDefined(ev, "ExitFeeApplied not emitted"); + // closeTrove settles through the same borrower-exit surface as withdrawColl + assertSurface(ev, SURFACE_ZERO_WITHDRAW_COLL, "closeTrove ExitFeeApplied"); + assert.equal(ev.args.actor, alice); + assert.equal(ev.args.recipient, alice); + assert.equal(ev.args.asset, ZERO_ADDRESS); + assert.isTrue(toBN(ev.args.grossAmount).eq(gross)); + assert.isTrue(toBN(ev.args.feeAmount).eq(fee)); + assert.isTrue(toBN(ev.args.netAmount).eq(net)); + }); + + it("closeTrove: ActivePool drain + trove removal identical to baseline (fee vs no-fee)", async () => { + await setupCloseable(); + const gross = await getTroveEntireColl(alice); + const apEthBefore = await activePool.getETH(); + + const inner = (await timeMachine.takeSnapshot())["result"]; + + // baseline: no controller → full coll to borrower + await borrowerOperations.closeTrove({ from: alice }); + const baseDrain = apEthBefore.sub(await activePool.getETH()); + const baseInList = await sortedTroves.contains(alice); + const baseStatus = (await troveManager.Troves(alice))[3].toString(); + + await timeMachine.revertToSnapshot(inner); + + // fee-active + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); + await borrowerOperations.closeTrove({ from: alice }); + const feeDrain = apEthBefore.sub(await activePool.getETH()); + + assert.isTrue(baseDrain.eq(gross), "baseline drain != coll"); + assert.isTrue(feeDrain.eq(baseDrain), "fee-path drain != baseline (residue!)"); + assert.equal(await sortedTroves.contains(alice), baseInList); + assert.equal((await troveManager.Troves(alice))[3].toString(), baseStatus); + }); + + it("closeTrove: controller unset → full coll to borrower, ExitFeeSkipped(CONTROLLER_REVERT)", async () => { + await setupCloseable(); + const gross = await getTroveEntireColl(alice); + + const apEthBefore = await activePool.getETH(); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const tx = await borrowerOperations.closeTrove({ from: alice }); + + assert.isTrue((await activePool.getETH()).eq(apEthBefore.sub(gross))); + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore), + "feeReceiver should be untouched" + ); + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev, "ExitFeeSkipped not emitted"); + assert.equal(toBN(ev.args.reason).toNumber(), CONTROLLER_REVERT); + assert.isUndefined(getEvent(tx, "ExitFeeApplied")); + }); +}); diff --git a/tests-colfee/ZeroBorrowerExit.notouch.test.js b/tests-colfee/ZeroBorrowerExit.notouch.test.js new file mode 100644 index 0000000..1daea43 --- /dev/null +++ b/tests-colfee/ZeroBorrowerExit.notouch.test.js @@ -0,0 +1,277 @@ +// ColFee — Zero borrower-exit hook: no-touch + invariant suite. +// +// Proves: +// - Fee-receiver failure passthrough: a reverting feeReceiver is caught by the +// try/catch fee leg; the borrower still receives the full gross and the exit +// completes (ExitFeeSkipped(VAULT_REVERT)). ColFee infra failure cannot brick +// a borrower exit. +// - No-touch: redemption, liquidation, and stability-pool ETH-gain withdrawals +// route their collateral through TroveManager / StabilityPool — NOT through +// BorrowerOperations._sendCollWithExitFee — so an ACTIVE controller charges +// nothing on those paths (feeReceiver balance unchanged). + +const deploymentHelper = require("../utils/js/deploymentHelpers.js"); +const testHelpers = require("../utils/js/testHelpers.js"); +const { assertSurface, SURFACE_ZERO_WITHDRAW_COLL } = require("./utils/assertions.js"); +const timeMachine = require("ganache-time-traveler"); + +const BorrowerOperationsTester = artifacts.require("./BorrowerOperationsTester.sol"); +const TroveManagerTester = artifacts.require("TroveManagerTester"); +const MassetManagerTester = artifacts.require("MassetManagerTester"); +const ExitFeeControllerMock = artifacts.require("ExitFeeControllerMock"); +const NonPayable = artifacts.require("NonPayable"); + +const th = testHelpers.TestHelper; +const dec = th.dec; +const toBN = th.toBN; +const timeValues = testHelpers.TimeValues; +const ZERO_ADDRESS = th.ZERO_ADDRESS; + +const NONE = 0; +const VAULT_REVERT = 5; +const GAS_PRICE = toBN(dec(1, 9)); + +contract("ColFee — Zero borrower exit: no-touch + invariants", async (accounts) => { + const [owner, alice, bob, whale, defaulter_1] = accounts; + const feeReceiver = accounts[995]; + const multisig = accounts[999]; + + let priceFeed; + let zusdToken; + let troveManager; + let activePool; + let stabilityPool; + let borrowerOperations; + let controller; + let contracts; + + const openTrove = async (params) => th.openTrove(contracts, params); + const getOpenTroveZUSDAmount = async (totalDebt) => + th.getOpenTroveZUSDAmount(contracts, totalDebt); + const getTroveEntireColl = async (trove) => th.getTroveEntireColl(contracts, trove); + const getEvent = (tx, name) => tx.logs.find((l) => l.event === name); + + // POSITIVE CONTROL for every no-touch case below. + // + // "feeReceiver balance unchanged" is only evidence of an exemption if the fee + // system would otherwise have charged. Run under the SAME controller wiring and + // rate the no-touch assertion relied on, this drives a genuinely chargeable + // borrower exit and requires the fee to land. Without it, deleting + // `_sendCollWithExitFee` outright — or a fixture where `setExitFeeController` + // silently did nothing — would leave the no-touch tests passing and vacuous. + const assertChargeableTwinDoesCharge = async (borrower, rateBps) => { + const gross = toBN(dec(1, "ether")); + const expectedFee = gross.mul(toBN(rateBps)).div(toBN(10000)); + assert.isTrue(expectedFee.gt(toBN(0)), "positive control needs a non-zero fee"); + + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const tx = await borrowerOperations.withdrawColl(gross, borrower, borrower, { + from: borrower, + }); + + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore.add(expectedFee)), + "POSITIVE CONTROL FAILED: a chargeable exit charged nothing under this same " + + "controller config — the no-touch assertion above proves nothing" + ); + const ev = getEvent(tx, "ExitFeeApplied"); + assert.isDefined(ev, "positive control: ExitFeeApplied not emitted"); + assertSurface(ev, SURFACE_ZERO_WITHDRAW_COLL, "positive control ExitFeeApplied"); + assert.isTrue(toBN(ev.args.feeAmount).eq(expectedFee)); + }; + + before(async () => { + contracts = await deploymentHelper.deployLiquityCore(); + const permit2 = contracts.permit2; + + contracts.borrowerOperations = await BorrowerOperationsTester.new(permit2.address); + contracts.massetManager = await MassetManagerTester.new(); + contracts.troveManager = await TroveManagerTester.new(permit2.address); + contracts = await deploymentHelper.deployZUSDTokenTester(contracts); + const ZEROContracts = await deploymentHelper.deployZEROTesterContractsHardhat(multisig); + + await ZEROContracts.zeroToken.unprotectedMint(multisig, toBN(dec(20, 24))); + + await deploymentHelper.connectZEROContracts(ZEROContracts); + await deploymentHelper.connectCoreContracts(contracts, ZEROContracts); + await deploymentHelper.connectZEROContractsToCore(ZEROContracts, contracts); + + priceFeed = contracts.priceFeedTestnet; + zusdToken = contracts.zusdToken; + troveManager = contracts.troveManager; + activePool = contracts.activePool; + stabilityPool = contracts.stabilityPool; + borrowerOperations = contracts.borrowerOperations; + + await borrowerOperations.setMassetManagerAddress(contracts.massetManager.address); + }); + + let snapshotId; + beforeEach(async () => { + const snap = await timeMachine.takeSnapshot(); + snapshotId = snap["result"]; + controller = await ExitFeeControllerMock.new(); + }); + afterEach(async () => { + await timeMachine.revertToSnapshot(snapshotId); + }); + + // --- fee-receiver failure passthrough --- + + it("withdrawColl: reverting feeReceiver → full gross to borrower, ExitFeeSkipped(VAULT_REVERT), exit completes", async () => { + await openTrove({ + ICR: toBN(dec(10, 18)), + extraParams: { from: alice, value: toBN(dec(100, "ether")) }, + }); + + const badReceiver = await NonPayable.new(); // receive() reverts while isPayable=false + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, badReceiver.address, NONE); + + const gross = toBN(dec(1, "ether")); + const apEthBefore = await activePool.getETH(); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + const collBefore = await getTroveEntireColl(alice); + + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { + from: alice, + gasPrice: GAS_PRICE, + }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + // ActivePool drained by exactly gross (the reverted fee-leg subcall rolled back its ETH.sub) + assert.isTrue( + (await activePool.getETH()).eq(apEthBefore.sub(gross)), + "ActivePool != -gross" + ); + // borrower received the FULL gross (no fee skimmed) minus gas + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(gross).sub(gasCost)), + "borrower != +gross" + ); + // fee receiver got nothing + assert.isTrue( + toBN(await web3.eth.getBalance(badReceiver.address)).eq(toBN(0)), + "bad receiver got ETH" + ); + // trove accounting still correct (coll reduced by gross) → exit completed + assert.isTrue((await getTroveEntireColl(alice)).eq(collBefore.sub(gross))); + + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev, "ExitFeeSkipped not emitted"); + assertSurface(ev, SURFACE_ZERO_WITHDRAW_COLL, "VAULT_REVERT ExitFeeSkipped"); + assert.equal(toBN(ev.args.reason).toNumber(), VAULT_REVERT); + assert.equal( + toBN(ev.args.rateBps).toNumber(), + 50, + "rateBps should be the rate the controller used" + ); + assert.isUndefined(getEvent(tx, "ExitFeeApplied")); + }); + + // --- no-touch: liquidation --- + + it("liquidation: charges no exit fee (collateral routes via TroveManager, not the BO hook)", async () => { + await priceFeed.setPrice(dec(200, 18)); + await openTrove({ + ICR: toBN(dec(10, 18)), + extraParams: { from: whale, value: toBN(dec(1000, "ether")) }, + }); + await openTrove({ ICR: toBN(dec(2, 18)), extraParams: { from: defaulter_1 } }); + + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 500, feeReceiver, NONE); // 5% — would be very visible if it fired + + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + + await priceFeed.setPrice(dec(100, 18)); // defaulter_1 now under MCR + assert.isFalse(await th.checkRecoveryMode(contracts)); + await troveManager.liquidate(defaulter_1, { from: owner }); + + assert.equal((await troveManager.Troves(defaulter_1))[3].toString(), "3"); // closedByLiquidation + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore), + "feeReceiver charged on a liquidation" + ); + + await assertChargeableTwinDoesCharge(whale, 500); + }); + + // --- no-touch: redemption --- + + it("redemption: charges no exit fee (collateral routes via TroveManager, not the BO hook)", async () => { + await priceFeed.setPrice(dec(200, 18)); + // whale holds plenty of ZUSD to redeem with + await openTrove({ + ICR: toBN(dec(20, 18)), + extraZUSDAmount: toBN(dec(50000, 18)), + extraParams: { from: whale, value: toBN(dec(1000, "ether")) }, + }); + await openTrove({ ICR: toBN(dec(2, 18)), extraParams: { from: alice } }); + + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 500, feeReceiver, NONE); + + // pass the redemption bootstrap window + await th.fastForwardTime( + timeValues.SECONDS_IN_ONE_WEEK * 2 + timeValues.SECONDS_IN_ONE_DAY, + web3.currentProvider + ); + + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const apEthBefore = await activePool.getETH(); + await th.redeemCollateral(whale, contracts, toBN(dec(1000, 18))); + + // the redemption must actually move collateral, else the no-touch claim is vacuous + assert.isTrue( + (await activePool.getETH()).lt(apEthBefore), + "redemption moved no collateral — no-touch assertion would be vacuous" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore), + "feeReceiver charged on a redemption" + ); + + await assertChargeableTwinDoesCharge(whale, 500); + }); + + // --- no-touch: stability pool ETH-gain withdrawal --- + + it("stability-pool ETH-gain withdrawal: charges no exit fee", async () => { + await priceFeed.setPrice(dec(200, 18)); + await openTrove({ + ICR: toBN(dec(10, 18)), + extraParams: { from: whale, value: toBN(dec(1000, "ether")) }, + }); + // alice deposits to the Stability Pool + await openTrove({ + ICR: toBN(dec(10, 18)), + extraZUSDAmount: toBN(dec(20000, 18)), + extraParams: { from: alice, value: toBN(dec(200, "ether")) }, + }); + await stabilityPool.provideToSP(toBN(dec(10000, 18)), ZERO_ADDRESS, { from: alice }); + + // a defaulter is liquidated and offset against the SP → alice accrues an ETH gain + await openTrove({ ICR: toBN(dec(2, 18)), extraParams: { from: defaulter_1 } }); + + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 500, feeReceiver, NONE); + + await priceFeed.setPrice(dec(100, 18)); + await troveManager.liquidate(defaulter_1, { from: owner }); + await priceFeed.setPrice(dec(200, 18)); + + const gain = await stabilityPool.getDepositorETHGain(alice); + assert.isTrue(gain.gt(toBN(0)), "no ETH gain accrued — setup invalid"); + + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + await stabilityPool.withdrawFromSP(toBN(dec(10000, 18)), { from: alice }); + + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore), + "feeReceiver charged on an SP ETH-gain withdrawal" + ); + + await assertChargeableTwinDoesCharge(alice, 500); + }); +}); diff --git a/tests-colfee/ZeroClaimSurplus.test.js b/tests-colfee/ZeroClaimSurplus.test.js new file mode 100644 index 0000000..34868e5 --- /dev/null +++ b/tests-colfee/ZeroClaimSurplus.test.js @@ -0,0 +1,665 @@ +// ColFee — Zero surplus-claim exit fee (SURFACE_ZERO_CLAIM_SURPLUS) +// +// Surplus enters CollSurplusPool on full redemption (TroveManagerRedeemOps) or +// recovery-mode liquidation with ICR > MCR; the ONLY outlet is +// BorrowerOperations.claimCollateral(). This suite proves the pool-side two-leg +// split (claimCollWithFee) charges the fee when the policy is active, fails +// open on every ColFee failure, and leaves the non-charging path +// state-equivalent to the untouched claimColl flow. + +const deploymentHelper = require("../utils/js/deploymentHelpers.js"); +const testHelpers = require("../utils/js/testHelpers.js"); +const { + assertRevertWithReason, + assertSurface, + SURFACE_ZERO_CLAIM_SURPLUS, +} = require("./utils/assertions.js"); +const timeMachine = require("ganache-time-traveler"); + +const BorrowerOperationsTester = artifacts.require("./BorrowerOperationsTester.sol"); +const TroveManagerTester = artifacts.require("TroveManagerTester"); +const MassetManagerTester = artifacts.require("MassetManagerTester"); +const ExitFeeControllerMock = artifacts.require("ExitFeeControllerMock"); +const NonPayable = artifacts.require("NonPayable"); +const ReentrantSurplusClaimer = artifacts.require("ReentrantSurplusClaimer"); +const GasSinkFeeReceiver = artifacts.require("GasSinkFeeReceiver"); +const LegacyCollSurplusPoolMock = artifacts.require("LegacyCollSurplusPoolMock"); + +const th = testHelpers.TestHelper; +const dec = th.dec; +const toBN = th.toBN; +const timeValues = testHelpers.TimeValues; +const ZERO_ADDRESS = th.ZERO_ADDRESS; +const assertRevert = th.assertRevert; + +const NONE = 0; +const INACTIVE = 1; +const DISABLED = 2; +const INVALID_QUOTE = 3; +const CONTROLLER_REVERT = 4; +const VAULT_REVERT = 5; +const GAS_PRICE = toBN(dec(1, 9)); + +contract("ColFee — Zero surplus-claim exit fee", async (accounts) => { + const [owner, alice, whale] = accounts; + const feeReceiver = accounts[995]; + const multisig = accounts[999]; + + let priceFeed; + let zusdToken; + let troveManager; + let activePool; + let collSurplusPool; + let borrowerOperations; + let controller; + let contracts; + let zeroStakingAddr; + + const openTrove = async (params) => th.openTrove(contracts, params); + const getEvent = (tx, name) => tx.logs.find((l) => l.event === name); + + before(async () => { + contracts = await deploymentHelper.deployLiquityCore(); + const permit2 = contracts.permit2; + + contracts.borrowerOperations = await BorrowerOperationsTester.new(permit2.address); + contracts.massetManager = await MassetManagerTester.new(); + contracts.troveManager = await TroveManagerTester.new(permit2.address); + contracts = await deploymentHelper.deployZUSDTokenTester(contracts); + const ZEROContracts = await deploymentHelper.deployZEROTesterContractsHardhat(multisig); + + await ZEROContracts.zeroToken.unprotectedMint(multisig, toBN(dec(20, 24))); + + await deploymentHelper.connectZEROContracts(ZEROContracts); + await deploymentHelper.connectCoreContracts(contracts, ZEROContracts); + await deploymentHelper.connectZEROContractsToCore(ZEROContracts, contracts); + + // Captured for the pre-upgrade-pool test, which re-calls setAddresses (the + // 12-arg form) to rewire collSurplusPool to a legacy mock; zeroStaking is the + // only address not exposed on `contracts`. + zeroStakingAddr = ZEROContracts.zeroStaking.address; + + priceFeed = contracts.priceFeedTestnet; + zusdToken = contracts.zusdToken; + troveManager = contracts.troveManager; + activePool = contracts.activePool; + collSurplusPool = contracts.collSurplusPool; + borrowerOperations = contracts.borrowerOperations; + + await borrowerOperations.setMassetManagerAddress(contracts.massetManager.address); + }); + + let snapshotId; + beforeEach(async () => { + const snap = await timeMachine.takeSnapshot(); + snapshotId = snap["result"]; + controller = await ExitFeeControllerMock.new(); + }); + afterEach(async () => { + await timeMachine.revertToSnapshot(snapshotId); + }); + + // Create a claimable surplus for `claimant` (an EOA) by fully redeeming their + // ~200%-ICR trove at ETH:USD = 100 (mirrors tests/js/CollSurplusPool.js): + // surplus == coll - netDebt/price stays in CollSurplusPool for the claimant. + const setupSurplus = async (claimant) => { + const price = toBN(dec(100, 18)); + await priceFeed.setPrice(price); + const { netDebt } = await openTrove({ + ICR: toBN(dec(200, 16)), + extraParams: { from: claimant }, + }); + await openTrove({ + extraZUSDAmount: netDebt, + extraParams: { from: whale, value: dec(3000, "ether") }, + }); + await th.fastForwardTime(timeValues.SECONDS_IN_ONE_WEEK * 2, web3.currentProvider); + await th.redeemCollateralAndGetTxObject(whale, contracts, netDebt); + const gross = await collSurplusPool.getCollateral(claimant); + assert.isTrue(gross.gt(toBN(0)), "setup failed: no surplus created"); + return gross; + }; + + // --- claimCollWithFee access control (pool-side) --- + + it("claimCollWithFee: reverts when caller is not BorrowerOperations", async () => { + // The surplus is funded FIRST and the reason string is checked, so the + // caller gate is the only thing that can reject this call. Without both, + // the test passes on the later `claimableColl > 0` require and would + // survive deleting `_requireCallerIsBorrowerOperations()` entirely. + const gross = await setupSurplus(alice); + await assertRevertWithReason( + collSurplusPool.claimCollWithFee(alice, feeReceiver, 0, { from: alice }), + "CollSurplusPool: Caller is not Borrower Operations" + ); + assert.isTrue( + (await collSurplusPool.getCollateral(alice)).eq(gross), + "surplus touched by a rejected call" + ); + }); + + // --- fee-active claim --- + + it("claimCollateral (fee active): feeReceiver += fee, claimant += net, pool getETH -= gross, ExitFeeApplied exact", async () => { + const gross = await setupSurplus(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); // 50 bps + + const fee = gross.mul(toBN(50)).div(toBN(10000)); + const net = gross.sub(fee); + + const poolEthBefore = await collSurplusPool.getETH(); + const poolRawBefore = toBN(await web3.eth.getBalance(collSurplusPool.address)); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + + const tx = await borrowerOperations.claimCollateral({ from: alice, gasPrice: GAS_PRICE }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue( + (await collSurplusPool.getETH()).eq(poolEthBefore.sub(gross)), + "pool getETH != -gross" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(collSurplusPool.address)).eq(poolRawBefore.sub(gross)), + "pool raw balance != -gross" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(collSurplusPool.address)).eq( + await collSurplusPool.getETH() + ), + "pool raw balance drifted from getETH()" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore.add(fee)), + "feeReceiver != +fee" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(net).sub(gasCost)), + "claimant != +net (minus gas)" + ); + assert.isTrue( + (await collSurplusPool.getCollateral(alice)).eq(toBN(0)), + "claimable not zeroed" + ); + + const ev = getEvent(tx, "ExitFeeApplied"); + assert.isDefined(ev, "ExitFeeApplied not emitted"); + // Pins the surplus claim to its OWN surface: the controller mock ignores + // surfaceId, so a hook quoting SURFACE_ZERO_WITHDRAW_COLL here would charge + // the borrower-exit policy on surplus claims and every other assertion in + // this file would still pass. + assertSurface(ev, SURFACE_ZERO_CLAIM_SURPLUS, "claimCollateral ExitFeeApplied"); + assert.equal(ev.args.actor, alice); + assert.equal(ev.args.recipient, alice); + assert.equal(ev.args.asset, ZERO_ADDRESS); + assert.equal(ev.args.subProduct, ZERO_ADDRESS); + assert.equal(ev.args.feeReceiver, feeReceiver); + assert.isTrue(toBN(ev.args.grossAmount).eq(gross)); + assert.isTrue(toBN(ev.args.feeAmount).eq(fee)); + assert.isTrue(toBN(ev.args.netAmount).eq(net)); + assert.isUndefined(getEvent(tx, "ExitFeeSkipped")); + }); + + it("claimCollateral: controller unset → claimant receives FULL gross, ExitFeeSkipped(CONTROLLER_REVERT), state == baseline", async () => { + const gross = await setupSurplus(alice); + + const poolEthBefore = await collSurplusPool.getETH(); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + + const tx = await borrowerOperations.claimCollateral({ from: alice, gasPrice: GAS_PRICE }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue((await collSurplusPool.getETH()).eq(poolEthBefore.sub(gross))); + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore), + "feeReceiver must be untouched" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(gross).sub(gasCost)), + "claimant != +FULL gross" + ); + assert.isTrue((await collSurplusPool.getCollateral(alice)).eq(toBN(0))); + + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev, "ExitFeeSkipped not emitted"); + assertSurface(ev, SURFACE_ZERO_CLAIM_SURPLUS, "claimCollateral ExitFeeSkipped"); + assert.equal(toBN(ev.args.reason).toNumber(), CONTROLLER_REVERT); + assert.isTrue(toBN(ev.args.grossAmount).eq(gross)); + assert.isUndefined(getEvent(tx, "ExitFeeApplied")); + }); + + it("claimCollateral: fee-path pool drain identical to baseline no-fee drain (no residue)", async () => { + const gross = await setupSurplus(alice); + const poolEthBefore = await collSurplusPool.getETH(); + + const inner = (await timeMachine.takeSnapshot())["result"]; + + // baseline: no controller → untouched claimColl path + await borrowerOperations.claimCollateral({ from: alice }); + const baseDrain = poolEthBefore.sub(await collSurplusPool.getETH()); + const baseRaw = toBN(await web3.eth.getBalance(collSurplusPool.address)); + + await timeMachine.revertToSnapshot(inner); + + // fee-active + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); + await borrowerOperations.claimCollateral({ from: alice }); + const feeDrain = poolEthBefore.sub(await collSurplusPool.getETH()); + + assert.isTrue(baseDrain.eq(gross), "baseline drain != gross"); + assert.isTrue(feeDrain.eq(baseDrain), "fee-path drain != baseline (residue!)"); + assert.isTrue( + toBN(await web3.eth.getBalance(collSurplusPool.address)).eq(baseRaw), + "fee-path raw pool balance != baseline" + ); + }); + + // --- fail-open matrix --- + + it("claimCollateral: controller inactive → full gross, ExitFeeSkipped(INACTIVE)", async () => { + const gross = await setupSurplus(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(false, 50, feeReceiver, INACTIVE); + + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const tx = await borrowerOperations.claimCollateral({ from: alice, gasPrice: GAS_PRICE }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(gross).sub(gasCost)) + ); + assert.isTrue(toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore)); + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev); + assert.equal(toBN(ev.args.reason).toNumber(), INACTIVE); + assert.isUndefined(getEvent(tx, "ExitFeeApplied")); + }); + + it("claimCollateral: controller destroyed after set → full gross, ExitFeeSkipped(CONTROLLER_REVERT)", async () => { + const gross = await setupSurplus(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); + await controller.destroy(); // code-less controller → extcodesize fail-open path + + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + const tx = await borrowerOperations.claimCollateral({ from: alice, gasPrice: GAS_PRICE }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(gross).sub(gasCost)) + ); + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev); + assert.equal(toBN(ev.args.reason).toNumber(), CONTROLLER_REVERT); + }); + + it("claimCollateral: controller reverts → full gross, ExitFeeSkipped(CONTROLLER_REVERT)", async () => { + const gross = await setupSurplus(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.setRevert(true); + + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + const tx = await borrowerOperations.claimCollateral({ from: alice, gasPrice: GAS_PRICE }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(gross).sub(gasCost)) + ); + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev); + assert.equal(toBN(ev.args.reason).toNumber(), CONTROLLER_REVERT); + }); + + it("claimCollateral: malformed quote (fee > gross) → full gross, ExitFeeSkipped(INVALID_QUOTE)", async () => { + const gross = await setupSurplus(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); + await controller.setForcedAmounts(true, gross.add(toBN(1)), 0); + + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const tx = await borrowerOperations.claimCollateral({ from: alice, gasPrice: GAS_PRICE }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(gross).sub(gasCost)) + ); + assert.isTrue(toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore)); + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev); + assert.equal(toBN(ev.args.reason).toNumber(), INVALID_QUOTE); + assert.equal( + toBN(ev.args.rateBps).toNumber(), + 50, + "skip must preserve the controller's rateBps" + ); + }); + + it("claimCollateral: reverting feeReceiver → feePaid=false, full gross to claimant, ExitFeeSkipped(VAULT_REVERT)", async () => { + const gross = await setupSurplus(alice); + const badReceiver = await NonPayable.new(); // receive() reverts while isPayable=false + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, badReceiver.address, NONE); + + const poolEthBefore = await collSurplusPool.getETH(); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + const tx = await borrowerOperations.claimCollateral({ from: alice, gasPrice: GAS_PRICE }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue( + (await collSurplusPool.getETH()).eq(poolEthBefore.sub(gross)), + "pool != -gross" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(gross).sub(gasCost)), + "claimant must receive FULL gross when the fee leg fails" + ); + assert.isTrue(toBN(await web3.eth.getBalance(badReceiver.address)).eq(toBN(0))); + assert.isTrue( + toBN(await web3.eth.getBalance(collSurplusPool.address)).eq( + await collSurplusPool.getETH() + ), + "pool raw balance drifted from getETH()" + ); + + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev); + assert.equal(toBN(ev.args.reason).toNumber(), VAULT_REVERT); + assert.equal(toBN(ev.args.rateBps).toNumber(), 50); + assert.isUndefined(getEvent(tx, "ExitFeeApplied")); + }); + + // --- edges --- + + it("claimCollateral: zero surplus reverts exactly as today (fee active and inactive)", async () => { + // Reason-checked: the point of this test is that the fee hook does not + // change WHICH revert a zero-surplus claim produces, so asserting merely + // "it reverted" would not prove the claim. + await assertRevertWithReason( + borrowerOperations.claimCollateral({ from: alice }), + "CollSurplusPool: No collateral available to claim" + ); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); + await assertRevertWithReason( + borrowerOperations.claimCollateral({ from: alice }), + "CollSurplusPool: No collateral available to claim" + ); + }); + + it("claimCollateral: 100% fee policy (fee == gross) → user leg sends 0 and succeeds, ExitFeeApplied(net=0)", async () => { + const gross = await setupSurplus(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 0, feeReceiver, NONE); + await controller.setForcedAmounts(true, gross, 0); // fee == gross (pool-safe) + + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + const tx = await borrowerOperations.claimCollateral({ from: alice, gasPrice: GAS_PRICE }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore.add(gross)), + "feeReceiver != +gross" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.sub(gasCost)), + "claimant should net 0" + ); + assert.isTrue((await collSurplusPool.getCollateral(alice)).eq(toBN(0))); + + const ev = getEvent(tx, "ExitFeeApplied"); + assert.isDefined(ev); + assert.isTrue(toBN(ev.args.feeAmount).eq(gross)); + assert.isTrue(toBN(ev.args.netAmount).eq(toBN(0))); + }); + + // --- reentrancy --- + + it("claimCollateral: reentrant claimant gets 'No collateral' on the inner call; single net payout only", async () => { + const price = toBN(dec(100, 18)); + await priceFeed.setPrice(price); + + const attacker = await ReentrantSurplusClaimer.new(borrowerOperations.address); + // Attacker opens its own ~200%-ICR trove (mirrors the NonPayable pattern in + // tests/js/CollSurplusPool.js), then whale fully redeems it → surplus parked + // for the attacker contract. + const zusdAmount = toBN(dec(3000, 18)); + const netDebt = await th.getAmountWithBorrowingFee(contracts, zusdAmount); + await attacker.openTrove( + toBN(dec(1, 18)), + zusdAmount, + attacker.address, + attacker.address, + { + value: toBN(dec(60, 18)), + } + ); + await openTrove({ + extraZUSDAmount: netDebt, + extraParams: { from: whale, value: dec(3000, "ether") }, + }); + await th.fastForwardTime(timeValues.SECONDS_IN_ONE_WEEK * 2, web3.currentProvider); + await th.redeemCollateralAndGetTxObject(whale, contracts, netDebt); + + const gross = await collSurplusPool.getCollateral(attacker.address); + assert.isTrue(gross.gt(toBN(0)), "setup failed: no attacker surplus"); + + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); + const fee = gross.mul(toBN(50)).div(toBN(10000)); + const net = gross.sub(fee); + + await attacker.claim(); + + assert.isTrue(await attacker.reentryAttempted(), "attacker never re-entered"); + assert.isFalse(await attacker.reentrySucceeded(), "reentrant inner claim MUST revert"); + assert.isTrue( + toBN(await attacker.totalReceived()).eq(net), + "attacker got more than one net payout" + ); + assert.isTrue((await collSurplusPool.getCollateral(attacker.address)).eq(toBN(0))); + assert.isTrue( + toBN(await web3.eth.getBalance(collSurplusPool.address)).eq( + await collSurplusPool.getETH() + ), + "pool raw balance drifted from getETH()" + ); + }); + + // --- hardening: fee-leg gas cap (gas-sink receiver cannot starve the claim) --- + + it("claimCollateral: gas-sink feeReceiver (success mode) → FEE_LEG_GAS_CAP bounds the burn, both legs settle, ExitFeeApplied", async () => { + const gross = await setupSurplus(alice); + const sink = await GasSinkFeeReceiver.new(); + await sink.setConsumeAll(false); // burn to floor, then RETURN SUCCESS + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, sink.address, NONE); + + const fee = gross.mul(toBN(50)).div(toBN(10000)); + const net = gross.sub(fee); + + const poolEthBefore = await collSurplusPool.getETH(); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + + // Explicit generous gas limit: without the cap the sink would burn ~63/64 of + // it (~1.4M). The gasUsed bound below is the actual cap regression guard: with + // FEE_LEG_GAS_CAP the whole tx fits comfortably under 500k; delete the cap and + // the sink's burn pushes gasUsed to ~1.45M, failing this test even though an + // EOA claimant would still get paid. + const tx = await borrowerOperations.claimCollateral({ + from: alice, + gas: 1500000, + gasPrice: GAS_PRICE, + }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isBelow(tx.receipt.gasUsed, 500000, "fee-leg burn not confined by FEE_LEG_GAS_CAP"); + assert.isTrue( + (await collSurplusPool.getETH()).eq(poolEthBefore.sub(gross)), + "pool getETH != -gross" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(net).sub(gasCost)), + "claimant != +net (minus gas)" + ); + assert.isTrue(toBN(await sink.totalReceived()).eq(fee), "sink totalReceived != fee"); + assert.isTrue( + (await collSurplusPool.getCollateral(alice)).eq(toBN(0)), + "claimable not zeroed" + ); + + const ev = getEvent(tx, "ExitFeeApplied"); + assert.isDefined(ev, "ExitFeeApplied not emitted"); + assert.isTrue(toBN(ev.args.feeAmount).eq(fee)); + assert.isTrue(toBN(ev.args.netAmount).eq(net)); + assert.equal(ev.args.feeReceiver, sink.address); + assert.isUndefined(getEvent(tx, "ExitFeeSkipped")); + }); + + it("claimCollateral: gas-sink feeReceiver (OOG mode) → fee leg fails fail-open, FULL gross to claimant, ExitFeeSkipped(VAULT_REVERT)", async () => { + const gross = await setupSurplus(alice); + const sink = await GasSinkFeeReceiver.new(); + await sink.setConsumeAll(true); // burn until OOG → the fee-leg call fails + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, sink.address, NONE); + + const poolEthBefore = await collSurplusPool.getETH(); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + + const tx = await borrowerOperations.claimCollateral({ + from: alice, + gas: 1500000, + gasPrice: GAS_PRICE, + }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + // Cap regression guard (see success-mode test): an uncapped OOG-mode sink + // burns its full 63/64 forwarded allowance (~1.4M) before failing. + assert.isBelow(tx.receipt.gasUsed, 500000, "fee-leg burn not confined by FEE_LEG_GAS_CAP"); + assert.isTrue( + (await collSurplusPool.getETH()).eq(poolEthBefore.sub(gross)), + "pool != -gross" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(gross).sub(gasCost)), + "claimant must receive FULL gross when the fee leg OOGs" + ); + assert.isTrue( + toBN(await sink.totalReceived()).eq(toBN(0)), + "sink received despite its receive reverting" + ); + + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev); + assert.equal(toBN(ev.args.reason).toNumber(), VAULT_REVERT); + assert.isUndefined(getEvent(tx, "ExitFeeApplied")); + }); + + // --- hardening: zero-receiver demotion (fee-burn-to-0x0 hole) --- + + it("claimCollateral: feeReceiver == address(0) demoted → FULL gross to claimant, ExitFeeSkipped(DISABLED), no fee burned", async () => { + const gross = await setupSurplus(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, ZERO_ADDRESS, NONE); // charging quote into 0x0 + + const poolEthBefore = await collSurplusPool.getETH(); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + + const tx = await borrowerOperations.claimCollateral({ from: alice, gasPrice: GAS_PRICE }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue( + (await collSurplusPool.getETH()).eq(poolEthBefore.sub(gross)), + "pool != -gross" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq(aliceBefore.add(gross).sub(gasCost)), + "claimant must receive FULL gross (no fee burned to 0x0)" + ); + assert.isTrue((await collSurplusPool.getCollateral(alice)).eq(toBN(0))); + + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev, "ExitFeeSkipped not emitted"); + assert.equal(toBN(ev.args.reason).toNumber(), DISABLED); + assert.isUndefined(getEvent(tx, "ExitFeeApplied")); + }); + + // --- ordering precondition guard (activation-ordering hazard, documented) --- + + it("claimCollateral: pre-upgrade pool + active surface REVERTS — pool setImplementation MUST precede surface activation", async () => { + // Simulate the LIVE pool implementation before the proxy is upgraded: it has + // claimColl but NOT claimCollWithFee (no fallback). There is intentionally no + // try/catch in the hook — a pool-side revert must surface loudly rather than + // silently degrade a fee-active claim into an unfee'd one. This test PINS the + // deployment ordering: the CollSurplusPool implementation upgrade must land + // before SURFACE_ZERO_CLAIM_SURPLUS is activated (handled atomically in one SIP). + const mock = await LegacyCollSurplusPoolMock.new(); + await mock.setBO(borrowerOperations.address); + await mock.setSurplus(alice, { value: dec(1, "ether") }); + + // Rewire BO to the legacy mock (setAddresses is re-callable, onlyOwner). Same + // 12 addresses as the original wiring, only collSurplusPool swapped. + await borrowerOperations.setAddresses( + contracts.feeDistributor.address, + contracts.liquityBaseParams.address, + contracts.troveManager.address, + contracts.activePool.address, + contracts.defaultPool.address, + contracts.stabilityPool.address, + contracts.gasPool.address, + mock.address, // collSurplusPool → legacy (pre-upgrade) mock + contracts.priceFeedTestnet.address, + contracts.sortedTroves.address, + contracts.zusdToken.address, + zeroStakingAddr, + { from: owner } + ); + + // Active surface with feeAmount > 0 → the hook calls the missing selector. + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); + + await assertRevert(borrowerOperations.claimCollateral({ from: alice })); + + // Surplus untouched by the reverted claim. + assert.isTrue( + (await mock.getCollateral(alice)).eq(toBN(dec(1, "ether"))), + "surplus consumed despite revert" + ); + + // Companion safe case: with the surface inactive the claim degrades to the + // untouched claimColl path, which the legacy pool DOES implement — proving only + // the active-fee path depends on the pool upgrade. + await controller.configure(false, 50, feeReceiver, INACTIVE); + + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const tx = await borrowerOperations.claimCollateral({ from: alice, gasPrice: GAS_PRICE }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq( + aliceBefore.add(toBN(dec(1, "ether"))).sub(gasCost) + ), + "inactive-surface claim must succeed full-gross via legacy claimColl" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore), + "feeReceiver must be untouched" + ); + assert.isTrue( + (await mock.getCollateral(alice)).eq(toBN(0)), + "mock surplus not zeroed by claimColl" + ); + + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev, "ExitFeeSkipped not emitted"); + assert.equal(toBN(ev.args.reason).toNumber(), INACTIVE); + assert.isUndefined(getEvent(tx, "ExitFeeApplied")); + }); +}); diff --git a/tests-colfee/ZeroPreview.test.js b/tests-colfee/ZeroPreview.test.js new file mode 100644 index 0000000..80ddad5 --- /dev/null +++ b/tests-colfee/ZeroPreview.test.js @@ -0,0 +1,221 @@ +// ColFee — Zero exit-fee preview helper. +// previewZeroCollWithdrawExitFee(borrower, grossColl): read-only policy lookup +// hard-wired to SURFACE_ZERO_WITHDRAW_COLL / subProduct=address(0) / actor=borrower. +// Must agree wei-for-wei with the live _sendCollWithExitFee charge. + +const deploymentHelper = require("../utils/js/deploymentHelpers.js"); +const testHelpers = require("../utils/js/testHelpers.js"); +const timeMachine = require("ganache-time-traveler"); + +const BorrowerOperationsTester = artifacts.require("./BorrowerOperationsTester.sol"); +const TroveManagerTester = artifacts.require("TroveManagerTester"); +const MassetManagerTester = artifacts.require("MassetManagerTester"); +const ExitFeeControllerMock = artifacts.require("ExitFeeControllerMock"); + +const th = testHelpers.TestHelper; +const dec = th.dec; +const toBN = th.toBN; +const ZERO_ADDRESS = th.ZERO_ADDRESS; + +const NONE = 0; +const CONTROLLER_REVERT = 4; +const GAS_PRICE = toBN(dec(1, 9)); // 1 gwei — used to back gas out of the borrower's RBTC delta + +contract("ColFee — Zero exit-fee preview", async (accounts) => { + const [owner, alice, bob] = accounts; + const feeReceiver = accounts[995]; + const multisig = accounts[999]; + + let activePool; + let borrowerOperations; + let controller; + let contracts; + + const openTrove = async (params) => th.openTrove(contracts, params); + const getEvent = (tx, name) => tx.logs.find((l) => l.event === name); + + before(async () => { + contracts = await deploymentHelper.deployLiquityCore(); + const permit2 = contracts.permit2; + + contracts.borrowerOperations = await BorrowerOperationsTester.new(permit2.address); + contracts.massetManager = await MassetManagerTester.new(); + contracts.troveManager = await TroveManagerTester.new(permit2.address); + contracts = await deploymentHelper.deployZUSDTokenTester(contracts); + const ZEROContracts = await deploymentHelper.deployZEROTesterContractsHardhat(multisig); + await ZEROContracts.zeroToken.unprotectedMint(multisig, toBN(dec(20, 24))); + await deploymentHelper.connectZEROContracts(ZEROContracts); + await deploymentHelper.connectCoreContracts(contracts, ZEROContracts); + await deploymentHelper.connectZEROContractsToCore(ZEROContracts, contracts); + + activePool = contracts.activePool; + borrowerOperations = contracts.borrowerOperations; + await borrowerOperations.setMassetManagerAddress(contracts.massetManager.address); + }); + + let snapshotId; + beforeEach(async () => { + const snap = await timeMachine.takeSnapshot(); + snapshotId = snap["result"]; + controller = await ExitFeeControllerMock.new(); + }); + afterEach(async () => { + await timeMachine.revertToSnapshot(snapshotId); + }); + + const openRoomyTrove = async (from) => + openTrove({ + ICR: toBN(dec(10, 18)), + extraParams: { from, value: toBN(dec(100, "ether")) }, + }); + + it("preview matches the live withdrawColl charge wei-for-wei (active fee)", async () => { + await openRoomyTrove(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 50, feeReceiver, NONE); + + const gross = toBN(dec(1, "ether")); + const expectedFee = gross.mul(toBN(50)).div(toBN(10000)); + + // preview (read-only, callable by anyone — here bob simulates for alice) + const p = await borrowerOperations.previewZeroCollWithdrawExitFee(alice, gross, { + from: bob, + }); + assert.equal(p.active, true); + assert.equal(toBN(p.rateBps).toNumber(), 50); + assert.isTrue(toBN(p.feeAmount).eq(expectedFee)); + assert.isTrue(toBN(p.netAmount).eq(gross.sub(expectedFee))); + assert.equal(p.feeReceiver, feeReceiver); + assert.equal(toBN(p.reason).toNumber(), NONE); + + // execute and assert the live charge equals the preview wei-for-wei + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { from: alice }); + const ev = getEvent(tx, "ExitFeeApplied"); + assert.isTrue( + toBN(ev.args.feeAmount).eq(toBN(p.feeAmount)), + "feeAmount preview != execution" + ); + assert.isTrue( + toBN(ev.args.netAmount).eq(toBN(p.netAmount)), + "netAmount preview != execution" + ); + assert.equal(ev.args.feeReceiver, p.feeReceiver); + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore.add(toBN(p.feeAmount))), + "feeReceiver delta != preview feeAmount" + ); + }); + + // A preview is only useful if it agrees with EXECUTION. Each non-charging case + // below therefore executes the same withdrawal and pins the live outcome to the + // previewed numbers, rather than only asserting the preview's own shape. + const assertLiveMatchesPreview = async (p, gross) => { + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { + from: alice, + gasPrice: GAS_PRICE, + }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq( + aliceBefore.add(toBN(p.netAmount)).sub(gasCost) + ), + "borrower delta != previewed netAmount" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore.add(toBN(p.feeAmount))), + "feeReceiver delta != previewed feeAmount" + ); + const ev = getEvent(tx, "ExitFeeSkipped"); + assert.isDefined(ev, "non-charging execution must emit ExitFeeSkipped"); + assert.equal( + toBN(ev.args.reason).toNumber(), + toBN(p.reason).toNumber(), + "live SkipReason != previewed reason" + ); + assert.equal( + toBN(ev.args.rateBps).toNumber(), + toBN(p.rateBps).toNumber(), + "live rateBps != previewed rateBps" + ); + assert.isUndefined(getEvent(tx, "ExitFeeApplied")); + }; + + it("preview reflects fail-open when controller is unset (active=false, CONTROLLER_REVERT, net==gross)", async () => { + await openRoomyTrove(alice); + const gross = toBN(dec(1, "ether")); + + const p = await borrowerOperations.previewZeroCollWithdrawExitFee(alice, gross); + assert.equal(p.active, false); + assert.isTrue(toBN(p.feeAmount).eq(toBN(0))); + assert.isTrue(toBN(p.netAmount).eq(gross), "net must equal gross when not charging"); + assert.equal(toBN(p.reason).toNumber(), CONTROLLER_REVERT); + + await assertLiveMatchesPreview(p, gross); + }); + + it("preview distinguishes an active zero-rate (exemption) from a positive charge", async () => { + await openRoomyTrove(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 0, feeReceiver, NONE); // active, 0 bps + const gross = toBN(dec(1, "ether")); + + const p = await borrowerOperations.previewZeroCollWithdrawExitFee(alice, gross); + assert.equal(p.active, true, "exemption is active=true"); + assert.equal(toBN(p.rateBps).toNumber(), 0); + assert.isTrue(toBN(p.feeAmount).eq(toBN(0))); + assert.isTrue(toBN(p.netAmount).eq(gross)); + assert.equal(toBN(p.reason).toNumber(), NONE); + + await assertLiveMatchesPreview(p, gross); + }); + + it("preview agrees with execution on a non-round gross that truncates", async () => { + // Round inputs (1 ether @ 50 bps) divide exactly, so they cannot show that + // the preview shares the live truncation. 37 bps on a non-round gross does. + await openRoomyTrove(alice); + await borrowerOperations.setExitFeeController(controller.address, { from: owner }); + await controller.configure(true, 37, feeReceiver, NONE); + + const gross = toBN("1234567890123456789"); + const p = await borrowerOperations.previewZeroCollWithdrawExitFee(alice, gross); + assert.equal(p.active, true); + assert.isTrue( + toBN(p.feeAmount) + .mul(toBN(10000)) + .lt(gross.mul(toBN(37))), + "chosen gross/rate must truncate, else this test proves nothing" + ); + assert.isTrue( + toBN(p.feeAmount).add(toBN(p.netAmount)).eq(gross), + "previewed fee + net != gross" + ); + + const frBefore = toBN(await web3.eth.getBalance(feeReceiver)); + const aliceBefore = toBN(await web3.eth.getBalance(alice)); + const tx = await borrowerOperations.withdrawColl(gross, alice, alice, { + from: alice, + gasPrice: GAS_PRICE, + }); + const gasCost = GAS_PRICE.mul(toBN(tx.receipt.gasUsed)); + + assert.isTrue( + toBN(await web3.eth.getBalance(feeReceiver)).eq(frBefore.add(toBN(p.feeAmount))), + "feeReceiver delta != previewed feeAmount" + ); + assert.isTrue( + toBN(await web3.eth.getBalance(alice)).eq( + aliceBefore.add(toBN(p.netAmount)).sub(gasCost) + ), + "borrower delta != previewed netAmount" + ); + const ev = getEvent(tx, "ExitFeeApplied"); + assert.isDefined(ev, "ExitFeeApplied not emitted"); + assert.isTrue(toBN(ev.args.feeAmount).eq(toBN(p.feeAmount))); + assert.isTrue(toBN(ev.args.netAmount).eq(toBN(p.netAmount))); + }); +}); diff --git a/tests-colfee/baselines/storage-layout.sovryn-perimeter-fee.json b/tests-colfee/baselines/storage-layout.sovryn-perimeter-fee.json new file mode 100644 index 0000000..fd1f2b9 --- /dev/null +++ b/tests-colfee/baselines/storage-layout.sovryn-perimeter-fee.json @@ -0,0 +1,167 @@ +{ + "_meta": { + "purpose": "Storage-layout zero-diff baseline for the Zero surplus-claim exit fee.", + "baseRef": "sovryn-perimeter-fee @ b6584a6", + "note": "Normalized solc storageLayout (AST id suffixes after ')' stripped) for the upgradeable BorrowerOperations and CollSurplusPool proxies plus ActivePool, captured from the UNMODIFIED b6584a6 tree, before the surplus-claim fee hook was applied. tests-colfee/StorageLayout.zerodiff.test.js asserts the current tree's layout is identical — proving the hook appends NO state. Regenerate only on an intentional, reviewed layout change (see the test header).", + "consumers": [ + "tests-colfee/StorageLayout.zerodiff.test.js", + "tests-colfee/utils/storageLayout.js" + ] + }, + "contracts/BorrowerOperations.sol:BorrowerOperations": [ + { + "label": "activePool", + "slot": "0", + "offset": 0, + "type": "t_contract(IActivePool)" + }, + { + "label": "defaultPool", + "slot": "1", + "offset": 0, + "type": "t_contract(IDefaultPool)" + }, + { + "label": "priceFeed", + "slot": "2", + "offset": 0, + "type": "t_contract(IPriceFeed)" + }, + { + "label": "liquityBaseParams", + "slot": "3", + "offset": 0, + "type": "t_contract(ILiquityBaseParams)" + }, + { + "label": "troveManager", + "slot": "4", + "offset": 0, + "type": "t_contract(ITroveManager)" + }, + { + "label": "stabilityPoolAddress", + "slot": "5", + "offset": 0, + "type": "t_address" + }, + { + "label": "gasPoolAddress", + "slot": "6", + "offset": 0, + "type": "t_address" + }, + { + "label": "collSurplusPool", + "slot": "7", + "offset": 0, + "type": "t_contract(ICollSurplusPool)" + }, + { + "label": "zeroStaking", + "slot": "8", + "offset": 0, + "type": "t_contract(IZEROStaking)" + }, + { + "label": "zeroStakingAddress", + "slot": "9", + "offset": 0, + "type": "t_address" + }, + { + "label": "zusdToken", + "slot": "10", + "offset": 0, + "type": "t_contract(IZUSDToken)" + }, + { + "label": "sortedTroves", + "slot": "11", + "offset": 0, + "type": "t_contract(ISortedTroves)" + }, + { + "label": "massetManager", + "slot": "12", + "offset": 0, + "type": "t_contract(IMassetManager)" + }, + { + "label": "feeDistributor", + "slot": "13", + "offset": 0, + "type": "t_contract(IFeeDistributor)" + } + ], + "contracts/ActivePool.sol:ActivePool": [ + { + "label": "borrowerOperationsAddress", + "slot": "0", + "offset": 0, + "type": "t_address" + }, + { + "label": "troveManagerAddress", + "slot": "1", + "offset": 0, + "type": "t_address" + }, + { + "label": "stabilityPoolAddress", + "slot": "2", + "offset": 0, + "type": "t_address" + }, + { + "label": "defaultPoolAddress", + "slot": "3", + "offset": 0, + "type": "t_address" + }, + { + "label": "ETH", + "slot": "4", + "offset": 0, + "type": "t_uint256" + }, + { + "label": "ZUSDDebt", + "slot": "5", + "offset": 0, + "type": "t_uint256" + } + ], + "contracts/CollSurplusPool.sol:CollSurplusPool": [ + { + "label": "borrowerOperationsAddress", + "slot": "0", + "offset": 0, + "type": "t_address" + }, + { + "label": "troveManagerAddress", + "slot": "1", + "offset": 0, + "type": "t_address" + }, + { + "label": "activePoolAddress", + "slot": "2", + "offset": 0, + "type": "t_address" + }, + { + "label": "ETH", + "slot": "3", + "offset": 0, + "type": "t_uint256" + }, + { + "label": "balances", + "slot": "4", + "offset": 0, + "type": "t_mapping(t_address,t_uint256)" + } + ] +} diff --git a/tests-colfee/utils/assertions.js b/tests-colfee/utils/assertions.js new file mode 100644 index 0000000..c012434 --- /dev/null +++ b/tests-colfee/utils/assertions.js @@ -0,0 +1,57 @@ +// Shared assertion helpers for the ColFee Zero test suites. +// +// `TestHelper.assertRevert(txPromise, message)` accepts an expected revert +// string but NEVER checks it (the comparison is commented out upstream), so a +// test written against it passes when the call reverts for a completely +// different reason — e.g. an access-control check can be deleted and the test +// still passes because a later `require` fires. `assertRevertWithReason` below +// asserts the reason string, so these suites prove WHICH guard rejected the +// call, not merely that something did. + +const { assert } = require("chai"); + +const NOT_REVERTED = "COLFEE_ASSERT_NOT_REVERTED"; + +/// Assert `txPromise` reverts AND that the revert reason contains `expected`. +async function assertRevertWithReason(txPromise, expected) { + assert.isString(expected, "assertRevertWithReason requires an expected reason string"); + try { + await txPromise; + } catch (err) { + assert.include(err.message, "revert", `expected a revert, got: ${err.message}`); + assert.include( + err.message, + expected, + `revert reason mismatch — expected to contain "${expected}", got: ${err.message}` + ); + return; + } + throw new Error(`${NOT_REVERTED}: expected revert containing "${expected}", but tx succeeded`); +} + +/// ColFee surface ids as the hooks compute them on-chain +/// (`keccak256("COLFEE:SURFACE_...")`). Asserting these on the emitted events +/// pins each hook to its OWN surface: the controller mock ignores `surfaceId`, +/// so without this a hook quoting the wrong surface would charge the wrong +/// policy in production and every test would still pass. +/// Computed lazily — `web3` is a test-runtime global, not available at require time. +const surfaceId = (name) => web3.utils.keccak256(`COLFEE:${name}`); +const SURFACE_ZERO_WITHDRAW_COLL = () => surfaceId("SURFACE_ZERO_WITHDRAW_COLL"); +const SURFACE_ZERO_CLAIM_SURPLUS = () => surfaceId("SURFACE_ZERO_CLAIM_SURPLUS"); + +/// Assert a ColFee event carries the expected surface id. +/// `expected` is one of the SURFACE_* thunks above. +function assertSurface(ev, expected, label) { + assert.equal( + ev.args.surfaceId, + expected(), + `${label || "ColFee event"} carries the wrong surfaceId` + ); +} + +module.exports = { + assertRevertWithReason, + assertSurface, + SURFACE_ZERO_WITHDRAW_COLL, + SURFACE_ZERO_CLAIM_SURPLUS, +}; diff --git a/tests-colfee/utils/storageLayout.js b/tests-colfee/utils/storageLayout.js new file mode 100644 index 0000000..124f12e --- /dev/null +++ b/tests-colfee/utils/storageLayout.js @@ -0,0 +1,49 @@ +// Shared helper for the storage-layout zero-diff regression guard. +// +// Extracts a NORMALIZED storage layout for a fully-qualified contract from the +// hardhat build-info (solc `storageLayout` output, enabled in hardhat.config.ts +// for 0.6.11). Normalization strips the solc AST node-id suffixes that follow a +// `)` in type strings (e.g. `t_struct(Foo)1234_storage` → +// `t_struct(Foo)_storage`) — those numeric ids shift with the compilation set +// and are NOT a storage-layout change. + +const hre = require("hardhat"); + +// Strip `)` id suffixes wherever they appear in a solc type string. +const normType = (t) => (typeof t === "string" ? t.replace(/\)[0-9]+/g, ")") : t); + +// Return a stable, comparable array of {label, slot, offset, type} for the +// contract's declared state variables. Throws (never silently empties) if the +// layout is missing/empty — an empty layout compared to an empty layout is a +// silent false PASS, which would make this guard useless. +async function normalizedLayout(fqName) { + const bi = await hre.artifacts.getBuildInfo(fqName); + if (!bi) throw new Error(`no build-info for ${fqName} (compile with storageLayout enabled)`); + const [source, name] = fqName.split(":"); + const artifact = bi.output.contracts[source] && bi.output.contracts[source][name]; + if (!artifact) throw new Error(`contract ${fqName} not found in its build-info output`); + const layout = artifact.storageLayout; + if (!layout || !Array.isArray(layout.storage)) { + throw new Error(`no storageLayout for ${fqName} — is "storageLayout" in outputSelection?`); + } + if (layout.storage.length === 0) { + throw new Error( + `${fqName} storageLayout has ZERO entries — refusing to treat as zero-diff (silent false pass)` + ); + } + return layout.storage + .map((s) => ({ + label: s.label, + slot: String(s.slot), + offset: s.offset, + type: normType(s.type), + })) + .sort( + (a, b) => + Number(a.slot) - Number(b.slot) || + a.offset - b.offset || + a.label.localeCompare(b.label) + ); +} + +module.exports = { normalizedLayout, normType }; diff --git a/yarn.lock b/yarn.lock index 4b0a3c0..2e0719f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1432,6 +1432,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.66.tgz#dd035d409df322acc83dff62a602f12a5783bbb3" integrity sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw== +"@types/parse-json@^4.0.0": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" + integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== + "@types/pbkdf2@^3.0.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@types/pbkdf2/-/pbkdf2-3.1.0.tgz#039a0e9b67da0cdc4ee5dab865caa6b267bb66b1" @@ -1622,6 +1627,13 @@ ansi-escapes@^4.2.1, ansi-escapes@^4.3.0: dependencies: type-fest "^0.21.3" +ansi-escapes@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-5.0.0.tgz#b6a0caf0eef0c41af190e9a749e0c00ec04bb2a6" + integrity sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA== + dependencies: + type-fest "^1.0.2" + ansi-regex@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" @@ -1642,6 +1654,11 @@ ansi-regex@^5.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== +ansi-regex@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + ansi-styles@^3.2.0, ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -1656,6 +1673,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +ansi-styles@^6.0.0, ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + antlr4@^4.11.0: version "4.13.0" resolved "https://registry.yarnpkg.com/antlr4/-/antlr4-4.13.0.tgz#25c0b17f0d9216de114303d38bafd6f181d5447f" @@ -2221,6 +2243,11 @@ chai@^4.2.0, chai@^4.3.4, chai@^4.3.8: pathval "^1.1.1" type-detect "^4.0.5" +chalk@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" + integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== + chalk@^2.3.2, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -2389,6 +2416,13 @@ cli-cursor@^3.1.0: dependencies: restore-cursor "^3.1.0" +cli-cursor@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-4.0.0.tgz#3cecfe3734bf4fe02a8361cbdc0f6fe28c6a57ea" + integrity sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg== + dependencies: + restore-cursor "^4.0.0" + cli-spinners@^2.5.0: version "2.9.0" resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.0.tgz#5881d0ad96381e117bbe07ad91f2008fe6ffd8db" @@ -2413,6 +2447,14 @@ cli-table3@^0.6.0: optionalDependencies: "@colors/colors" "1.5.0" +cli-truncate@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-3.1.0.tgz#3f23ab12535e3d73e839bb43e73c9de487db1389" + integrity sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA== + dependencies: + slice-ansi "^5.0.0" + string-width "^5.0.0" + cli-width@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" @@ -2495,6 +2537,11 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +colorette@^2.0.20: + version "2.0.20" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" + integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== + colors@1.4.0, colors@^1.1.2: version "1.4.0" resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" @@ -2532,6 +2579,11 @@ command-line-usage@^6.1.0: table-layout "^1.0.2" typical "^5.2.0" +commander@11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67" + integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ== + commander@3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e" @@ -2542,6 +2594,11 @@ commander@^10.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== +compare-versions@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.6.0.tgz#1a5689913685e5a87637b8d3ffca75514ec41d62" + integrity sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -2619,6 +2676,17 @@ cors@^2.8.1: object-assign "^4" vary "^1" +cosmiconfig@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" + integrity sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA== + dependencies: + "@types/parse-json" "^4.0.0" + import-fresh "^3.2.1" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.10.0" + cosmiconfig@^8.0.0: version "8.2.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.2.0.tgz#f7d17c56a590856cd1e7cee98734dca272b0d8fd" @@ -2703,6 +2771,15 @@ cross-spawn@^7.0.1, cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" +cross-spawn@^7.0.3: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + "crypt@>= 0.0.1": version "0.0.2" resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" @@ -2965,6 +3042,11 @@ dotenv@^16.0.3: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + ecc-jsbn@~0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" @@ -3001,6 +3083,11 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + encode-utf8@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" @@ -3573,6 +3660,11 @@ eventemitter3@4.0.4: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.4.tgz#b5463ace635a083d018bdc7c917b4c5f10a85384" integrity sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ== +eventemitter3@^5.0.1: + version "5.0.4" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" + integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== + evp_bytestokey@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" @@ -3581,6 +3673,21 @@ evp_bytestokey@^1.0.3: md5.js "^1.3.4" safe-buffer "^5.1.1" +execa@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-7.2.0.tgz#657e75ba984f42a70f38928cedc87d6f2d4fe4e9" + integrity sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.1" + human-signals "^4.3.0" + is-stream "^3.0.0" + merge-stream "^2.0.0" + npm-run-path "^5.1.0" + onetime "^6.0.0" + signal-exit "^3.0.7" + strip-final-newline "^3.0.0" + execa@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" @@ -3793,6 +3900,13 @@ find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" +find-versions@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/find-versions/-/find-versions-4.0.0.tgz#3c57e573bf97769b8cb8df16934b627915da4965" + integrity sha512-wgpWy002tA+wgmO27buH/9KzyEOQnKsG/R0yrcjPT9BOFm0zRBVQbZ95nRGXWMywS8YR5knRbpohio0bcJABxQ== + dependencies: + semver-regex "^3.1.2" + find-yarn-workspace-root@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd" @@ -4360,7 +4474,7 @@ hardhat-gas-reporter@^1.0.9: eth-gas-reporter "^0.2.25" sha1 "^1.1.1" -hardhat@^2.17.2: +hardhat@2.17.2: version "2.17.2" resolved "https://registry.yarnpkg.com/hardhat/-/hardhat-2.17.2.tgz#250a8c8e76029e9bfbfb9b9abee68d5b350b5d4a" integrity sha512-oUv40jBeHw0dKpbyQ+iH9cmNMziweLoTW3MnkNxJ2Gc0KGLrQR/1n4vV4xY60zn2LdmRgnwPqy3CgtY0mfwIIA== @@ -4618,6 +4732,27 @@ https-proxy-agent@^5.0.0: agent-base "6" debug "4" +human-signals@^4.3.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2" + integrity sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ== + +husky@^4.3.8: + version "4.3.8" + resolved "https://registry.yarnpkg.com/husky/-/husky-4.3.8.tgz#31144060be963fd6850e5cc8f019a1dfe194296d" + integrity sha512-LCqqsB0PzJQ/AlCgfrfzRe3e3+NvmefAdKQhRYpxS4u6clblBoDdzzvHi8fmxKRzvMxPY/1WZWzomPZww0Anow== + dependencies: + chalk "^4.0.0" + ci-info "^2.0.0" + compare-versions "^3.6.0" + cosmiconfig "^7.0.0" + find-versions "^4.0.0" + opencollective-postinstall "^2.0.2" + pkg-dir "^5.0.0" + please-upgrade-node "^3.2.0" + slash "^3.0.0" + which-pm-runs "^1.0.0" + iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -4847,6 +4982,11 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-fullwidth-code-point@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz#fae3167c729e7463f8461ce512b080a49268aa88" + integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== + is-function@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.2.tgz#4f097f30abf6efadac9833b17ca5dc03f8144e08" @@ -4930,6 +5070,11 @@ is-stream@^1.1.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== +is-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" + integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== + is-string@^1.0.5, is-string@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" @@ -5239,11 +5384,44 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lilconfig@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" + integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== +lint-staged@^13.2.0: + version "13.3.0" + resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-13.3.0.tgz#7965d72a8d6a6c932f85e9c13ccf3596782d28a5" + integrity sha512-mPRtrYnipYYv1FEE134ufbWpeggNTo+O/UPzngoaKzbzHAthvR55am+8GfHTnqNRQVRRrYQLGW9ZyUoD7DsBHQ== + dependencies: + chalk "5.3.0" + commander "11.0.0" + debug "4.3.4" + execa "7.2.0" + lilconfig "2.1.0" + listr2 "6.6.1" + micromatch "4.0.5" + pidtree "0.6.0" + string-argv "0.3.2" + yaml "2.3.1" + +listr2@6.6.1: + version "6.6.1" + resolved "https://registry.yarnpkg.com/listr2/-/listr2-6.6.1.tgz#08b2329e7e8ba6298481464937099f4a2cd7f95d" + integrity sha512-+rAXGHh0fkEWdXBmX+L6mmfmXmXvDGEKzkjxO+8mP3+nI/r/CWznVBvsibXdxda9Zz0OW2e2ikphN3OwCT/jSg== + dependencies: + cli-truncate "^3.1.0" + colorette "^2.0.20" + eventemitter3 "^5.0.1" + log-update "^5.0.1" + rfdc "^1.3.0" + wrap-ansi "^8.1.0" + load-json-file@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" @@ -5350,6 +5528,17 @@ log-symbols@4.1.0, log-symbols@^4.1.0: chalk "^4.1.0" is-unicode-supported "^0.1.0" +log-update@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-5.0.1.tgz#9e928bf70cb183c1f0c9e91d9e6b7115d597ce09" + integrity sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw== + dependencies: + ansi-escapes "^5.0.0" + cli-cursor "^4.0.0" + slice-ansi "^5.0.0" + strip-ansi "^7.0.1" + wrap-ansi "^8.0.1" + loupe@^2.3.1: version "2.3.6" resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.6.tgz#76e4af498103c532d1ecc9be102036a21f787b53" @@ -5477,6 +5666,11 @@ merge-descriptors@1.0.1: resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + merge2@^1.2.3, merge2@^1.3.0: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" @@ -5492,7 +5686,7 @@ micro-ftch@^0.3.1: resolved "https://registry.yarnpkg.com/micro-ftch/-/micro-ftch-0.3.1.tgz#6cb83388de4c1f279a034fb0cf96dfc050853c5f" integrity sha512-/0LLxhzP0tfiR5hcQebtudP56gUurs2CLkGarnCiB/OqEyUFQ6U3paQi/tgLv0hBJYt2rnr9MNpxz4fiiugstg== -micromatch@^4.0.2, micromatch@^4.0.4: +micromatch@4.0.5, micromatch@^4.0.2, micromatch@^4.0.4: version "4.0.5" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== @@ -5527,6 +5721,11 @@ mimic-fn@^2.0.0, mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +mimic-fn@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" + integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== + mimic-response@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" @@ -5961,6 +6160,13 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" +npm-run-path@^5.1.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-5.3.0.tgz#e23353d0ebb9317f174e93417e4a4d82d0249e9f" + integrity sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ== + dependencies: + path-key "^4.0.0" + nth-check@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" @@ -6065,6 +6271,13 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" +onetime@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-6.0.0.tgz#7c24c18ed1fd2e9bca4bd26806a33613c77d34b4" + integrity sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ== + dependencies: + mimic-fn "^4.0.0" + open@^7.4.2: version "7.4.2" resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" @@ -6073,6 +6286,11 @@ open@^7.4.2: is-docker "^2.0.0" is-wsl "^2.1.1" +opencollective-postinstall@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz#7a0fff978f6dbfa4d006238fbac98ed4198c3259" + integrity sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q== + optionator@^0.8.1: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" @@ -6370,6 +6588,11 @@ path-key@^3.1.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== +path-key@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18" + integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ== + path-parse@^1.0.6, path-parse@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" @@ -6427,6 +6650,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +pidtree@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c" + integrity sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g== + pidtree@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.3.1.tgz#ef09ac2cc0533df1f3250ccf2c4d366b0d12114a" @@ -6459,6 +6687,13 @@ pinkie@^2.0.0: resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" integrity sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg== +pkg-dir@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" + integrity sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA== + dependencies: + find-up "^5.0.0" + play-sound@^1.1.3: version "1.1.6" resolved "https://registry.yarnpkg.com/play-sound/-/play-sound-1.1.6.tgz#e62ed9daf8506aba959e8fd267c49d9b979d89fa" @@ -6466,6 +6701,13 @@ play-sound@^1.1.3: dependencies: find-exec "1.0.3" +please-upgrade-node@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942" + integrity sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg== + dependencies: + semver-compare "^1.0.0" + pluralize@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" @@ -6886,11 +7128,24 @@ restore-cursor@^3.1.0: onetime "^5.1.0" signal-exit "^3.0.2" +restore-cursor@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-4.0.0.tgz#519560a4318975096def6e609d44100edaa4ccb9" + integrity sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +rfdc@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" + integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== + rimraf@^2.2.8, rimraf@^2.6.3, rimraf@^2.7.1: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" @@ -7029,6 +7284,16 @@ secp256k1@^4.0.1: node-addon-api "^2.0.0" node-gyp-build "^4.2.0" +semver-compare@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" + integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== + +semver-regex@^3.1.2: + version "3.1.4" + resolved "https://registry.yarnpkg.com/semver-regex/-/semver-regex-3.1.4.tgz#13053c0d4aa11d070a2f2872b6b1e3ae1e1971b4" + integrity sha512-6IiqeZNgq01qGf0TId0t3NvKzSvUsjcpdEO3AQNeIjR6A2+ckTnQlDpl4qu1bjRv0RzN3FP9hzFmws3lKqRWkA== + "semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.5.0, semver@^5.6.0, semver@^5.7.0: version "5.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" @@ -7203,7 +7468,7 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" -signal-exit@^3.0.0, signal-exit@^3.0.2: +signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== @@ -7246,6 +7511,14 @@ slice-ansi@^4.0.0: astral-regex "^2.0.0" is-fullwidth-code-point "^3.0.0" +slice-ansi@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-5.0.0.tgz#b73063c57aa96f9cd881654b15294d95d285c42a" + integrity sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ== + dependencies: + ansi-styles "^6.0.0" + is-fullwidth-code-point "^4.0.0" + snake-case@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-2.1.0.tgz#41bdb1b73f30ec66a04d4e2cad1b76387d4d6d9f" @@ -7459,6 +7732,11 @@ strict-uri-encode@^1.0.0: resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" integrity sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ== +string-argv@0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" + integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== + string-format@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/string-format/-/string-format-2.0.0.tgz#f2df2e7097440d3b65de31b6d40d54c96eaffb9b" @@ -7499,6 +7777,15 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string-width@^5.0.0, string-width@^5.0.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + string.prototype.padend@^3.0.0: version "3.1.4" resolved "https://registry.yarnpkg.com/string.prototype.padend/-/string.prototype.padend-3.1.4.tgz#2c43bb3a89eb54b6750de5942c123d6c98dd65b6" @@ -7577,6 +7864,13 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" +strip-ansi@^7.0.1: + version "7.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== + dependencies: + ansi-regex "^6.2.2" + strip-bom@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" @@ -7594,6 +7888,11 @@ strip-eof@^1.0.0: resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" integrity sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q== +strip-final-newline@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" + integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== + strip-hex-prefix@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/strip-hex-prefix/-/strip-hex-prefix-1.0.0.tgz#0c5f155fef1151373377de9dbb588da05500e36f" @@ -7937,6 +8236,11 @@ type-fest@^0.7.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.7.1.tgz#8dda65feaf03ed78f0a3f9678f1869147f7c5c48" integrity sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg== +type-fest@^1.0.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" + integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== + type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" @@ -8705,6 +9009,11 @@ which-module@^2.0.0: resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== +which-pm-runs@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.1.0.tgz#35ccf7b1a0fce87bd8b92a478c9d045785d3bf35" + integrity sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA== + which-typed-array@^1.1.10, which-typed-array@^1.1.11, which-typed-array@^1.1.2: version "1.1.11" resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.11.tgz#99d691f23c72aab6768680805a271b69761ed61a" @@ -8800,6 +9109,15 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -8899,6 +9217,11 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== +yaml@2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b" + integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ== + yaml@^1.10.0, yaml@^1.10.2: version "1.10.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"