Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: savings with trusted address and harvester #114

Merged
merged 20 commits into from
Jun 3, 2024
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/ci-deep.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ jobs:
ETH_NODE_URI_ARBITRUM: ${{ secrets.ETH_NODE_URI_ARBITRUM }}
ETH_NODE_URI_OPTIMISM: ${{ secrets.ETH_NODE_URI_OPTIMISM }}
ETH_NODE_URI_MAINNET: ${{ secrets.ETH_NODE_URI_MAINNET }}
ETH_NODE_URI_BASE: ${{ secrets.ETH_NODE_URI_BASE }}

# test-invariant:
# needs: ["build", "lint"]
Expand Down Expand Up @@ -171,3 +172,4 @@ jobs:
ETH_NODE_URI_ARBITRUM: ${{ secrets.ETH_NODE_URI_ARBITRUM }}
ETH_NODE_URI_OPTIMISM: ${{ secrets.ETH_NODE_URI_OPTIMISM }}
ETH_NODE_URI_MAINNET: ${{ secrets.ETH_NODE_URI_MAINNET }}
ETH_NODE_URI_BASE: ${{ secrets.ETH_NODE_URI_BASE }}
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ jobs:
ETH_NODE_URI_ARBITRUM: ${{ secrets.ETH_NODE_URI_ARBITRUM }}
ETH_NODE_URI_OPTIMISM: ${{ secrets.ETH_NODE_URI_OPTIMISM }}
ETH_NODE_URI_MAINNET: ${{ secrets.ETH_NODE_URI_MAINNET }}
ETH_NODE_URI_BASE: ${{ secrets.ETH_NODE_URI_BASE }}

# test-invariant:
# needs: ["build", "lint"]
Expand All @@ -128,6 +129,7 @@ jobs:

# - name: Run Foundry tests
# run: yarn test:invariant
# TODO: when uncommenting: add env back

test-fuzz:
needs: ["build", "lint"]
Expand Down Expand Up @@ -159,6 +161,7 @@ jobs:
ETH_NODE_URI_ARBITRUM: ${{ secrets.ETH_NODE_URI_ARBITRUM }}
ETH_NODE_URI_OPTIMISM: ${{ secrets.ETH_NODE_URI_OPTIMISM }}
ETH_NODE_URI_MAINNET: ${{ secrets.ETH_NODE_URI_MAINNET }}
ETH_NODE_URI_BASE: ${{ secrets.ETH_NODE_URI_BASE }}

coverage:
needs: ["build", "lint"]
Expand Down Expand Up @@ -190,6 +193,7 @@ jobs:
ETH_NODE_URI_ARBITRUM: ${{ secrets.ETH_NODE_URI_ARBITRUM }}
ETH_NODE_URI_OPTIMISM: ${{ secrets.ETH_NODE_URI_OPTIMISM }}
ETH_NODE_URI_MAINNET: ${{ secrets.ETH_NODE_URI_MAINNET }}
ETH_NODE_URI_BASE: ${{ secrets.ETH_NODE_URI_BASE }}

- name: "Upload coverage report to Codecov"
uses: "codecov/codecov-action@v3"
Expand Down
4 changes: 0 additions & 4 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,6 @@
path = lib/prb-math
url = https://github.com/PaulRBerg/prb-math
ignore = dirty
[submodule "lib/borrow-contracts"]
path = lib/borrow-contracts
url = https://github.com/AngleProtocol/borrow-contracts
tags = v2.3
[submodule "lib/openzeppelin-contracts-upgradeable"]
path = lib/openzeppelin-contracts-upgradeable
url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Transmuter is an autonomous and modular price stability module for decentralized
- It is conceived as a basket of different assets (normally stablecoins) backing a stablecoin and comes with guarantees on the maximum exposure the stablecoin can have to each asset in the basket.
- A stablecoin issued through the Transmuter system can be minted at oracle value from any of the assets with adaptive fees, and it can be burnt for any of the assets in the backing with variable fees as well. It can also be redeemed at any time against a proportional amount of each asset in the backing.

Transmuter is compatible with other common mechanisms often used to issue stablecoins like collateralized-debt position models. It should notably be used as a standalone module within the Angle Protocol for agEUR in parallel with the Borrowing module.
Transmuter is compatible with other common mechanisms often used to issue stablecoins like collateralized-debt position models. It is notably used as a standalone module within the Angle Protocol for EURA in parallel with the Borrowing module.

---

Expand Down Expand Up @@ -69,7 +69,8 @@ For contracts deployed for the Angle Protocol, a bug bounty is open on [Immunefi

## Deployment Addresses 🚦

- Transmuter for agEUR on Ethereum: [0x00253582b2a3FE112feEC532221d9708c64cEFAb](https://etherscan.io/address/0x00253582b2a3FE112feEC532221d9708c64cEFAb)
- Transmuter for EURA on Ethereum: [0x00253582b2a3FE112feEC532221d9708c64cEFAb](https://etherscan.io/address/0x00253582b2a3FE112feEC532221d9708c64cEFAb)
- Transmuter for USDA on Ethereum: [0x222222fD79264BBE280b4986F6FEfBC3524d0137](https://etherscan.io/address/0x222222fD79264BBE280b4986F6FEfBC3524d0137)

---

Expand Down
192 changes: 192 additions & 0 deletions contracts/helpers/Harvester.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.23;

import { IERC20 } from "oz/interfaces/IERC20.sol";
import { IERC4626 } from "interfaces/external/IERC4626.sol";
import { SafeERC20 } from "oz/token/ERC20/utils/SafeERC20.sol";
import { SafeCast } from "oz/utils/math/SafeCast.sol";

import { ITransmuter } from "interfaces/ITransmuter.sol";

import { AccessControl, IAccessControlManager } from "../utils/AccessControl.sol";
import "../utils/Constants.sol";
import "../utils/Errors.sol";

import { RebalancerFlashloan } from "./RebalancerFlashloan.sol";

struct CollatParams {
// Vault associated to the collateral
address vault;
// Target exposure to the collateral asset used in the vault
uint64 targetExposure;
// Maximum exposure within the Transmuter to the vault asset
uint64 maxExposureYieldAsset;
// Minimum exposure within the Transmuter to the vault asset
uint64 minExposureYieldAsset;
// Whether limit exposures should be overriden or read onchain through the Transmuter
// This value should be 1 to override exposures or 2 if these shouldn't be overriden
uint64 overrideExposures;
}

/// @title Harvester
/// @author Angle Labs, Inc.
/// @dev Contract for anyone to permissionlessly adjust the reserves of Angle Transmuter through
/// the RebalancerFlashloan contract
contract Harvester is AccessControl {
using SafeERC20 for IERC20;
using SafeCast for uint256;

/// @notice Reference to the `transmuter` implementation this contract aims at rebalancing
ITransmuter public immutable TRANSMUTER;
/// @notice Permissioned rebalancer contract
RebalancerFlashloan public rebalancer;
/// @notice Max slippage when dealing with the Transmuter
uint96 public maxSlippage;
/// @notice Data associated to a collateral
mapping(address => CollatParams) public collateralData;

/*//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
INITIALIZATION
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/

constructor(
address _rebalancer,
address vault,
uint64 targetExposure,
uint64 overrideExposures,
uint64 maxExposureYieldAsset,
Dismissed Show dismissed Hide dismissed
Dismissed Show dismissed Hide dismissed
Dismissed Show dismissed Hide dismissed
uint64 minExposureYieldAsset,
uint96 _maxSlippage
) {
ITransmuter transmuter = RebalancerFlashloan(_rebalancer).TRANSMUTER();
TRANSMUTER = transmuter;
rebalancer = RebalancerFlashloan(_rebalancer);
accessControlManager = IAccessControlManager(transmuter.accessControlManager());
_setCollateralData(vault, targetExposure, minExposureYieldAsset, maxExposureYieldAsset, overrideExposures);
_setMaxSlippage(_maxSlippage);
}

/*//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
HARVEST
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/

/// @notice Invests or divests from the yield asset associated to `collateral` based on the current exposure to this
/// collateral
/// @dev This transaction either reduces the exposure to `collateral` in the Transmuter or frees up some collateral
/// that can then be used for people looking to burn stablecoins
/// @dev Due to potential transaction fees within the Transmuter, this function doesn't exactly bring `collateral`
/// to the target exposure
/// @dev The `harvest` possibility shouldn't be implemented for assets with a manipulable price (like ERC4626)
/// contracts on which the `previewRedeem` values can be easily moved by creating a loss or a profit
function harvest(address collateral) external {
sogipec marked this conversation as resolved.
Show resolved Hide resolved
sogipec marked this conversation as resolved.
Show resolved Hide resolved
(uint256 stablecoinsFromCollateral, uint256 stablecoinsIssued) = TRANSMUTER.getIssuedByCollateral(collateral);
CollatParams memory collatInfo = collateralData[collateral];
(uint256 stablecoinsFromVault, ) = TRANSMUTER.getIssuedByCollateral(collatInfo.vault);
uint8 increase;
Dismissed Show dismissed Hide dismissed
uint256 amount;
uint256 targetExposureScaled = collatInfo.targetExposure * stablecoinsIssued;
if (stablecoinsFromCollateral * 1e9 > targetExposureScaled) {
// Need to increase exposure to yield bearing asset
increase = 1;
amount = stablecoinsFromCollateral - targetExposureScaled / 1e9;
uint256 maxValueScaled = collatInfo.maxExposureYieldAsset * stablecoinsIssued;
// These checks assume that there are no transaction fees on the stablecoin->collateral conversion and so
// it's still possible that exposure goes above the max exposure in some rare cases
if (stablecoinsFromVault * 1e9 > maxValueScaled) amount = 0;
else if ((stablecoinsFromVault + amount) * 1e9 > maxValueScaled)
amount = maxValueScaled / 1e9 - stablecoinsFromVault;
} else {
// In this case, exposure after the operation might remain slightly below the targetExposure as less
// collateral may be obtained by burning stablecoins for the yield asset and unwrapping it
amount = targetExposureScaled / 1e9 - stablecoinsFromCollateral;
uint256 minValueScaled = collatInfo.minExposureYieldAsset * stablecoinsIssued;
if (stablecoinsFromVault * 1e9 < minValueScaled) amount = 0;
else if (stablecoinsFromVault * 1e9 < minValueScaled + amount * 1e9)
amount = stablecoinsFromVault - minValueScaled / 1e9;
}
if (amount > 0) {
try TRANSMUTER.updateOracle(collatInfo.vault) {} catch {}

rebalancer.adjustYieldExposure(
amount,
increase,
collateral,
collatInfo.vault,
(amount * (1e9 - maxSlippage)) / 1e9
sogipec marked this conversation as resolved.
Show resolved Hide resolved
);
}
}
Dismissed Show dismissed Hide dismissed

/*//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
SETTERS
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/

function setRebalancer(address _newRebalancer) external onlyGuardian {
if (_newRebalancer == address(0)) revert ZeroAddress();
rebalancer = RebalancerFlashloan(_newRebalancer);
}

/// @dev This function shouldn't be called for a vault (e.g an ERC4626 token) which price can be easily moved
/// by creating a loss or a profit, at the risk of depleting the reserves available in the Rebalancer
function setCollateralData(
address vault,
uint64 targetExposure,
uint64 minExposureYieldAsset,
uint64 maxExposureYieldAsset,
Dismissed Show dismissed Hide dismissed
Dismissed Show dismissed Hide dismissed
Dismissed Show dismissed Hide dismissed
uint64 overrideExposures
) external onlyGuardian {
_setCollateralData(vault, targetExposure, minExposureYieldAsset, maxExposureYieldAsset, overrideExposures);
}

function setMaxSlippage(uint96 _maxSlippage) external onlyGuardian {
_setMaxSlippage(_maxSlippage);
}

function updateLimitExposuresYieldAsset(address collateral) external {
CollatParams storage collatInfo = collateralData[collateral];
if (collatInfo.overrideExposures == 2) _updateLimitExposuresYieldAsset(collatInfo);
}

function _setMaxSlippage(uint96 _maxSlippage) internal {
if (_maxSlippage > 1e9) revert InvalidParam();
maxSlippage = _maxSlippage;
}

function _setCollateralData(
address vault,
uint64 targetExposure,
uint64 minExposureYieldAsset,
uint64 maxExposureYieldAsset,
Dismissed Show dismissed Hide dismissed
Dismissed Show dismissed Hide dismissed
Dismissed Show dismissed Hide dismissed
uint64 overrideExposures
) internal {
address collateral = address(IERC4626(vault).asset());
CollatParams storage collatInfo = collateralData[collateral];
collatInfo.vault = vault;
if (targetExposure >= 1e9) revert InvalidParam();
collatInfo.targetExposure = targetExposure;
collatInfo.overrideExposures = overrideExposures;
if (overrideExposures == 1) {
if (maxExposureYieldAsset >= 1e9 || minExposureYieldAsset >= maxExposureYieldAsset) revert InvalidParam();
collatInfo.maxExposureYieldAsset = maxExposureYieldAsset;
collatInfo.minExposureYieldAsset = minExposureYieldAsset;
} else {
collatInfo.overrideExposures = 2;
_updateLimitExposuresYieldAsset(collatInfo);
}
}

function _updateLimitExposuresYieldAsset(CollatParams storage collatInfo) internal {
uint64[] memory xFeeMint;
(xFeeMint, ) = TRANSMUTER.getCollateralMintFees(collatInfo.vault);
uint256 length = xFeeMint.length;
if (length <= 1) collatInfo.maxExposureYieldAsset = 1e9;
else collatInfo.maxExposureYieldAsset = xFeeMint[length - 2];

uint64[] memory xFeeBurn;
(xFeeBurn, ) = TRANSMUTER.getCollateralBurnFees(collatInfo.vault);
length = xFeeBurn.length;
if (length <= 1) collatInfo.minExposureYieldAsset = 0;
else collatInfo.minExposureYieldAsset = xFeeBurn[length - 2];
}
Dismissed Show dismissed Hide dismissed
Dismissed Show dismissed Hide dismissed
}
29 changes: 25 additions & 4 deletions contracts/savings/Savings.sol
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@
/// to a level inferior to the current rate
uint256 public maxRate;

uint256[49] private __gap;
/// @notice Checks whether the address is trusted to set the rate
mapping(address => uint256) public isTrustedUpdater;

uint256[48] private __gap;

/*//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
EVENTS
Expand All @@ -38,6 +41,7 @@
event Accrued(uint256 interest);
event MaxRateUpdated(uint256 newMaxRate);
event ToggledPause(uint128 pauseStatus);
event ToggledTrusted(address indexed trustedAddress, uint256 trustedStatus);
event RateUpdated(uint256 newRate);

/*//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
Expand All @@ -64,6 +68,7 @@
if (address(_accessControlManager) == address(0)) revert ZeroAddress();
__ERC4626_init(asset_);
__ERC20_init(name_, symbol_);
_setNameAndSymbol(name_, symbol_);
accessControlManager = _accessControlManager;
_deposit(msg.sender, address(this), 10 ** (asset_.decimals()) / divizer, BASE_18 / divizer);
}
Expand All @@ -78,6 +83,13 @@
_;
}

/// @notice Checks whether the sender is allowed to update the rate
modifier onlyTrustedOrGuardian() {
if (isTrustedUpdater[msg.sender] == 0 && !accessControlManager.isGovernorOrGuardian(msg.sender))
revert NotTrusted();
_;
}

/*//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
CONTRACT LOGIC
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/
Expand Down Expand Up @@ -203,6 +215,8 @@
: shares.mulDiv(newTotalAssets, supply, rounding);
}

function _setNameAndSymbol(string memory newName, string memory newSymbol) internal virtual {}
Dismissed Show dismissed Hide dismissed

/*//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
HELPERS
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/
Expand All @@ -229,15 +243,22 @@
emit ToggledPause(pauseStatus);
}

/// @notice Toggles an address
function toggleTrusted(address trustedAddress) external onlyGuardian {
uint256 trustedStatus = 1 - isTrustedUpdater[trustedAddress];
isTrustedUpdater[trustedAddress] = trustedStatus;
emit ToggledTrusted(trustedAddress, trustedStatus);
}

/// @notice Updates the inflation rate for depositing `asset` in this contract
/// @dev Any `rate` can be set by the guardian provided that it is inferior to the `maxRate` settable
/// by a governor address
function setRate(uint208 newRate) external onlyGuardian {
/// @dev Any `rate` can be set by the guardian or by a trusted address provided that it is inferior to
///the `maxRate` settable by a governor address
function setRate(uint208 newRate) external onlyTrustedOrGuardian {
if (newRate > maxRate) revert InvalidRate();
_accrue();
rate = newRate;
emit RateUpdated(newRate);
}

Check warning

Code scanning / Slither

Reentrancy vulnerabilities Medium

Reentrancy in Savings.setRate(uint208):
External calls:
- _accrue()
- IAgToken(asset()).mint(address(this),earned)
State variables written after the call(s):
- rate = newRate
Savings.rate can be used in cross function reentrancies:
- Savings._computeUpdatedAssets(uint256,uint256)
- Savings.rate
- Savings.setRate(uint208)

Check notice

Code scanning / Slither

Reentrancy vulnerabilities Low

Reentrancy in Savings.setRate(uint208):
External calls:
- _accrue()
- IAgToken(asset()).mint(address(this),earned)
Event emitted after the call(s):
- RateUpdated(newRate)

/// @notice Updates the maximum rate settable
function setMaxRate(uint256 newMaxRate) external onlyGovernor {
Expand Down
35 changes: 35 additions & 0 deletions contracts/savings/nameable/SavingsNameable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: BUSL-1.1

pragma solidity ^0.8.19;

import "../Savings.sol";

/// @title SavingsNameable
/// @author Angle Labs, Inc.
contract SavingsNameable is Savings {
string internal __name;

string internal __symbol;

uint256[48] private __gapNameable;
Dismissed Show dismissed Hide dismissed

/// @inheritdoc ERC20Upgradeable
function name() public view override(ERC20Upgradeable, IERC20MetadataUpgradeable) returns (string memory) {
return __name;
}

/// @inheritdoc ERC20Upgradeable
function symbol() public view override(ERC20Upgradeable, IERC20MetadataUpgradeable) returns (string memory) {
return __symbol;
}

/// @notice Updates the name and symbol of the token
function setNameAndSymbol(string memory newName, string memory newSymbol) external onlyGovernor {
_setNameAndSymbol(newName, newSymbol);
}

function _setNameAndSymbol(string memory newName, string memory newSymbol) internal override {
__name = newName;
__symbol = newSymbol;
}
}
2 changes: 1 addition & 1 deletion foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ via_ir = true
sizes = true
optimizer = true
optimizer_runs=1000
solc_version = '0.8.22'
solc_version = '0.8.23'
ffi = true
fs_permissions = [
{ access = "read-write", path = "./scripts/selectors.json"},
Expand Down
1 change: 0 additions & 1 deletion lib/borrow-contracts
Submodule borrow-contracts deleted from 2d9882
Loading
Loading