Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ yarn-error.log

# echidna
/crytic-export
fuzzTests/corpus/

#typechain
types/generated/*
Expand Down
358 changes: 329 additions & 29 deletions contracts/BorrowerOperations.sol

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions contracts/CollSurplusPool.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions contracts/Interfaces/ICollSurplusPool.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
160 changes: 160 additions & 0 deletions contracts/Interfaces/colfee/IExitFeeController.sol
Original file line number Diff line number Diff line change
@@ -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;
}
133 changes: 133 additions & 0 deletions contracts/TestContracts/EchidnaColFeeTester.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading