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

xERC4626 support #241

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from 9 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
123 changes: 123 additions & 0 deletions core/contracts/lib/xERC4626.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: MIT
// Rewards logic inspired by xERC20 (https://github.com/ZeframLou/playpen/blob/main/src/xERC20.sol)
// Source: https://github.com/ERC4626-Alliance/ERC4626-Contracts
// Differences:
// - replaced import from Solmate's ERC4626 with OpenZeppelin ERC4626
// - replaced import from Solmate's SafeCastLib with OpenZeppelin SafeCast
// - removed super.beforeWithdraw and super.afterDeposit calls
// - removed overrides from beforeWithdraw and afterDeposit. OZ does not use these functions.
// - replaced `asset.balanceOf(address(this))` with `IERC20(asset()).balanceOf(address(this))`
// - removed unused `shares` param from `beforeWithdraw` and `afterDeposit`
// - minor formatting changes and solhint additions

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import "@openzeppelin/contracts/utils/math/SafeCast.sol";

/**
@title An xERC4626 Single Staking Contract
@notice This contract allows users to autocompound rewards denominated in an underlying reward token.
It is fully compatible with [ERC4626](https://eips.ethereum.org/EIPS/eip-4626) allowing for DeFi composability.
It maintains balances using internal accounting to prevent instantaneous changes in the exchange rate.
NOTE: an exception is at contract creation, when a reward cycle begins before the first deposit. After the first deposit, exchange rate updates smoothly.

Operates on "cycles" which distribute the rewards surplus over the internal balance to users linearly over the remainder of the cycle window.
*/
abstract contract xERC4626 is ERC4626 {
nkuba marked this conversation as resolved.
Show resolved Hide resolved
using SafeCast for *;

/// @notice the maximum length of a rewards cycle
uint32 public immutable rewardsCycleLength;

/// @notice the effective start of the current cycle
uint32 public lastSync;

/// @notice the end of the current cycle. Will always be evenly divisible by `rewardsCycleLength`.
uint32 public rewardsCycleEnd;

/// @notice the amount of rewards distributed in a the most recent cycle.
uint192 public lastRewardAmount;

uint256 internal storedTotalAssets;

/// @dev emit every time a new rewards cycle starts
event NewRewardsCycle(uint32 indexed cycleEnd, uint256 rewardAmount);

/// @dev thrown when syncing before cycle ends.
error SyncError();

constructor(uint32 _rewardsCycleLength) {
rewardsCycleLength = _rewardsCycleLength;
// seed initial rewardsCycleEnd
/* solhint-disable not-rely-on-time */
// slither-disable-next-line divide-before-multiply
rewardsCycleEnd =
(block.timestamp.toUint32() / rewardsCycleLength) *
rewardsCycleLength;
}

/// @notice Distributes rewards to xERC4626 holders.
/// All surplus `asset` balance of the contract over the internal
/// balance becomes queued for the next cycle.
function syncRewards() public virtual {
uint192 lastRewardAmount_ = lastRewardAmount;
/* solhint-disable-next-line not-rely-on-time */
uint32 timestamp = block.timestamp.toUint32();

if (timestamp < rewardsCycleEnd) revert SyncError();

uint256 storedTotalAssets_ = storedTotalAssets;
uint256 nextRewards = IERC20(asset()).balanceOf(address(this)) -
storedTotalAssets_ -
lastRewardAmount_;

storedTotalAssets = storedTotalAssets_ + lastRewardAmount_; // SSTORE

// slither-disable-next-line divide-before-multiply
uint32 end = ((timestamp + rewardsCycleLength) / rewardsCycleLength) *
rewardsCycleLength;

// Combined single SSTORE
lastRewardAmount = nextRewards.toUint192();
lastSync = timestamp;
rewardsCycleEnd = end;

emit NewRewardsCycle(end, nextRewards);
}

/// @notice Compute the amount of tokens available to share holders.
/// Increases linearly during a reward distribution period from the
/// sync call, not the cycle start.
function totalAssets() public view override returns (uint256) {
// cache global vars
uint256 storedTotalAssets_ = storedTotalAssets;
uint192 lastRewardAmount_ = lastRewardAmount;
uint32 rewardsCycleEnd_ = rewardsCycleEnd;
uint32 lastSync_ = lastSync;

/* solhint-disable-next-line not-rely-on-time */
if (block.timestamp >= rewardsCycleEnd_) {
// no rewards or rewards fully unlocked
// entire reward amount is available
return storedTotalAssets_ + lastRewardAmount_;
}

// rewards not fully unlocked
// add unlocked rewards to stored total
/* solhint-disable not-rely-on-time */
uint256 unlockedRewards = (lastRewardAmount_ *
(block.timestamp - lastSync_)) / (rewardsCycleEnd_ - lastSync_);
return storedTotalAssets_ + unlockedRewards;
}

// Update storedTotalAssets on withdraw/redeem
function beforeWithdraw(uint256 amount) internal virtual {
storedTotalAssets -= amount;
}

// Update storedTotalAssets on deposit/mint
function afterDeposit(uint256 amount) internal virtual {
storedTotalAssets += amount;
}
}
63 changes: 57 additions & 6 deletions core/contracts/stBTC.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "./Dispatcher.sol";
import "./lib/xERC4626.sol";
nkuba marked this conversation as resolved.
Show resolved Hide resolved

/// @title stBTC
/// @notice This contract implements the ERC-4626 tokenized vault standard. By
Expand All @@ -17,7 +18,7 @@ import "./Dispatcher.sol";
/// of yield-bearing vaults. This contract facilitates the minting and
/// burning of shares (stBTC), which are represented as standard ERC20
/// tokens, providing a seamless exchange with tBTC tokens.
contract stBTC is ERC4626, Ownable {
contract stBTC is xERC4626, Ownable {
using SafeERC20 for IERC20;

/// Dispatcher contract that routes tBTC from stBTC to a given vault and back.
Expand Down Expand Up @@ -61,8 +62,14 @@ contract stBTC is ERC4626, Ownable {

constructor(
IERC20 _tbtc,
address _treasury
) ERC4626(_tbtc) ERC20("Acre Staked Bitcoin", "stBTC") Ownable(msg.sender) {
address _treasury,
uint32 _rewardsCycleLength
)
ERC4626(_tbtc)
ERC20("Acre Staked Bitcoin", "stBTC")
Ownable(msg.sender)
xERC4626(_rewardsCycleLength) // TODO: revisit initialization
{
if (address(_treasury) == address(0)) {
revert ZeroAddress();
}
Expand Down Expand Up @@ -145,16 +152,17 @@ contract stBTC is ERC4626, Ownable {
/// contract.
/// @param assets Approved amount of tBTC tokens to deposit.
/// @param receiver The address to which the shares will be minted.
/// @return Minted shares.
/// @return shares Minted shares.
function deposit(
nkuba marked this conversation as resolved.
Show resolved Hide resolved
uint256 assets,
address receiver
) public override returns (uint256) {
) public override returns (uint256 shares) {
if (assets < minimumDepositAmount) {
revert LessThanMinDeposit(assets, minimumDepositAmount);
}

return super.deposit(assets, receiver);
shares = super.deposit(assets, receiver);
afterDeposit(assets);
}

/// @notice Mints shares to receiver by depositing tBTC tokens.
Expand All @@ -174,6 +182,49 @@ contract stBTC is ERC4626, Ownable {
if ((assets = super.mint(shares, receiver)) < minimumDepositAmount) {
revert LessThanMinDeposit(assets, minimumDepositAmount);
}
afterDeposit(assets);
}

/// @notice Burns shares from owner and sends exactly assets of underlying
/// tokens to receiver.
/// @param assets Amount of underlying tokens to withdraw.
/// @param receiver The address to which the assets will be sent.
/// @param owner The address from which the shares will be burned.
function withdraw(
uint256 assets,
address receiver,
address owner
) public override returns (uint256 shares) {
uint256 maxAssets = maxWithdraw(owner);
if (assets > maxAssets) {
revert ERC4626ExceededMaxWithdraw(owner, assets, maxAssets);
}

shares = previewWithdraw(assets);
beforeWithdraw(assets);
_withdraw(_msgSender(), receiver, owner, assets, shares);
}

/// @notice Burns shares from owner and sends exactly assets of underlying
/// tokens to receiver.
/// @param shares Amount of shares to redeem.
/// @param receiver The address to which the assets will be sent.
/// @param owner The address from which the shares will be burned.
function redeem(
uint256 shares,
address receiver,
address owner
) public override returns (uint256) {
uint256 maxShares = maxRedeem(owner);
if (shares > maxShares) {
revert ERC4626ExceededMaxRedeem(owner, shares, maxShares);
}

uint256 assets = previewRedeem(shares);
beforeWithdraw(assets);
_withdraw(_msgSender(), receiver, owner, assets, shares);

return assets;
}

/// @notice Returns value of assets that would be exchanged for the amount of
Expand Down
3 changes: 2 additions & 1 deletion core/deploy/01_deploy_stbtc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => {
const { deployer, treasury } = await getNamedAccounts()

const tbtc = await deployments.get("TBTC")
const rewardsCycleLength = 7 * 24 * 60 * 60 // 7 days

const stbtc = await deployments.deploy("stBTC", {
from: deployer,
args: [tbtc.address, treasury],
args: [tbtc.address, treasury, rewardsCycleLength],
log: true,
waitConfirmations: waitConfirmationsNumber(hre),
})
Expand Down
Loading
Loading