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 Perimeter 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 Perimeter 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 Perimeter 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/perimeter/IExitFeeController.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// SPDX-License-Identifier: MIT
// ─────────────────────────────────────────────────────────────────────────────
// Vendored copy of the Perimeter exit-fee controller interface, taken from
// DistributedCollective/perimeter @ 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 (Perimeter) 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 `ExitFeeSkipped` event was emitted instead of an
/// `ExitFeeApplied`. 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;
}
40 changes: 40 additions & 0 deletions contracts/TestContracts/EchidnaHarnessStubs.sol
Original file line number Diff line number Diff line change
@@ -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 {

}
Loading