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

WOETH - Fixed yield rate each day #2421

Open
wants to merge 39 commits into
base: sparrowDom/woeth_hack_proof
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
7db7545
Initial concept
DanielVF Feb 28, 2025
e7b5daa
Remove reverts from startYield
DanielVF Feb 28, 2025
c400f5f
Remove unneeded import
DanielVF Feb 28, 2025
0054c26
Add yield event, deduplicate startYield()
DanielVF Feb 28, 2025
1606ac6
Tighten startYield()
DanielVF Feb 28, 2025
690e81c
higher daily cap. Still way under safety reasons
DanielVF Feb 28, 2025
5ffec31
There should be no yield on the block that changes happen
DanielVF Feb 28, 2025
441beb6
There's something to compiling
DanielVF Feb 28, 2025
2661656
Allow for negative hardAssets
DanielVF Feb 28, 2025
9edd73b
Formatting cleanup
DanielVF Feb 28, 2025
897acc4
Remove rate
DanielVF Feb 28, 2025
79ab2d7
enhanced yield testing
DanielVF Feb 28, 2025
6c0b17e
add safecasts
sparrowDom Feb 28, 2025
d362ea8
remove comment
sparrowDom Feb 28, 2025
bea7f4f
go to uint128
DanielVF Feb 28, 2025
779e8e5
nicer ordering
DanielVF Feb 28, 2025
8eebe1a
remove parenthesis
sparrowDom Feb 28, 2025
9ae5a3e
make vars local
sparrowDom Feb 28, 2025
49be3dd
nicer for prettier
DanielVF Feb 28, 2025
72f382b
Cleaner elapsed calc
DanielVF Feb 28, 2025
f01c0b2
Cleaner to user IERC20 when not a special OETH need
DanielVF Feb 28, 2025
554570c
Better error message means no override needed
DanielVF Feb 28, 2025
dc5473e
Remove unneeded import
DanielVF Feb 28, 2025
6b95b0c
Remove unneeded using
DanielVF Feb 28, 2025
8790f72
Recalc in transfers
DanielVF Feb 28, 2025
3a953ea
Merge remote-tracking branch 'origin/sparrowDom/woeth_hack_proof' int…
sparrowDom Mar 3, 2025
b2ea11c
correct bad merge mistakes
sparrowDom Mar 3, 2025
793e37a
add tests for yield start events on deposit mint redeem withdraw
sparrowDom Mar 4, 2025
62fceca
Tighten pass
DanielVF Mar 4, 2025
c2434c2
fix compile error
sparrowDom Mar 4, 2025
ab81400
fix issue when a yield period should start
sparrowDom Mar 4, 2025
404f407
Correct behavior, lock in no yield for usual period
DanielVF Mar 4, 2025
cacb9db
Better name, scheduleYield
DanielVF Mar 4, 2025
a15851d
Better name, userAssets
DanielVF Mar 4, 2025
e422a5e
Update comments
DanielVF Mar 4, 2025
add45c7
_min to pure
DanielVF Mar 5, 2025
e9801e4
Begin readme on wOETH
DanielVF Mar 5, 2025
78050ef
The Rappie Simplification
DanielVF Mar 5, 2025
a53a8ce
readme update
DanielVF Mar 5, 2025
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
65 changes: 65 additions & 0 deletions contracts/contracts/token/README-woeth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# WOETH token

An ERC4626 contract that wraps a rebasing token and allows it to be treated as a non-rebasing value accrual token.

This contract distributes yield slowly over 23 hour periods. This prevents donation attacks against borrowing on lending platforms.

It is designed to work only with up-only rebasing tokens. The asset token must not make reenterable external calls on transfers.

We plan on minting at least 1e14 wOETH tokens on a new deploy and sending them to a dead address before any user funds are deposited.

## Invariants

### Yield timing

Yield is distributed evenly per second from the start of a yield period at (`scheduleYield()` + 1) to the end of the period, inclusive.

An example of a three second yield period.

[ ~ ][Yield][Yield][Yield][ ][ ]
[schedule][ ][ ][ end ][ ][ ]

Yield only can happen at the start of a block, and at most once per block. As a result:

> transfers do not change `totalAssets()`

> scheduleYield() does not change `totalAssets()`

> all other actions do not change `totalAssets()`, beyond the OETH transferred to or from the user

Donations are slowed, and have no effect on the current block. Given that this contract only works off of balances, there is no difference from wOETH's point of view between an OETH donation, and an OETH rebase.

> sending OETH to the contract, or positive rebasing from OETH, will not change `totalAssets()`.

Yield is evenly spread.

> yield given in a second, when yield is active, will be (yieldAssets / YIELD_TIME) either rounded down or rounded up.

> yield given in a second, when the block timestamp is past the end date, will be 0

Because we operate on blockchains with many different block times, the per block yields may vary depending on how many active yield seconds elapse in each.

### Solvency

The protocol rounds against the user, in favor of the protocol

> Any series of actions by a single user in a single block, will not result in an increase in their (OETH + previewRedeem(balance))[^1]

> At any time, all users of the system can redeem all their wOETH

> The actual redeem amounts will match previewRedeem()[^1]

> `previewRedeem()` will never go down, outside of an external loss of OETH balance

> The `trackedAssets` will never exceed wOETH's balance of OETH, outside of an external loss of OETH balance

> The sum of `totalAssets()` will never exceed wOETH's balance of OETH, outside of an external loss of OETH balance

> `trackedAssets - yieldAssets` will never be negative

### 4626-ness

This is an ERC4626, as such it should follow correct behaviors for an ERC4626.


[^1] When OETH has sufficient transfer resolution.
176 changes: 94 additions & 82 deletions contracts/contracts/token/WOETH.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,41 @@ import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol";

import { StableMath } from "../utils/StableMath.sol";
import { Governable } from "../governance/Governable.sol";
import { Initializable } from "../utils/Initializable.sol";
import { OETH } from "./OETH.sol";

/**
* @title OETH Token Contract
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OETH -> WOETH

* @author Origin Protocol Inc
* @author Origin Protocol
*
* @dev An important capability of this contract is that it isn't susceptible to changes of the
* exchange rate of WOETH/OETH if/when someone sends the underlying asset (OETH) to the contract.
* If OETH weren't rebasing this could be achieved by solely tracking the ERC20 transfers of the OETH
* token on mint, deposit, redeem, withdraw. The issue is that OETH is rebasing and OETH balances
* will change when the token rebases. For that reason we are tracking the WOETH contract credits and
* credits per token in those 4 actions. That way WOETH can keep an accurate track of the OETH balance
* ignoring any unexpected transfers of OETH to this contract.
* @dev An ERC4626 contract that wraps a rebasing token
* and allows it to be treated as a non-rebasing value accrual token.
* This contract distributes yield slowly over 23 hours which prevents
* donation attacks against lending platforms.
* It is designed to work only with up-only rebasing tokens.
* The asset token must not make reenterable external calls on transfers.
*/

contract WOETH is ERC4626, Governable, Initializable {
using SafeERC20 for IERC20;
using StableMath for uint256;
uint256 public oethCreditsHighres;
bool private _oethCreditsInitialized;
uint256[48] private __gap;
using SafeCast for uint256;

uint256 public trackedAssets;
uint128 public yieldAssets;
uint64 public yieldEnd;
bool private _initialized2;
uint256[47] private __gap;

uint256 public constant YIELD_TIME = 1 days - 1 hours;

event YiedPeriodStarted(
uint256 trackedAssets,
uint256 yieldAssets,
uint256 yieldEnd
);

// no need to set ERC20 name and symbol since they are overridden in WOETH & WOETHBase
constructor(ERC20 underlying_)
Expand All @@ -49,23 +59,14 @@ contract WOETH is ERC4626, Governable, Initializable {
}

/**
* @notice secondary initializer that newly deployed contracts will execute as part
* of primary initialize function and the existing contracts will have it called
* as a governance operation.
* @notice Upgrade contract to support yield periods.
* Called automatically on new contracts via initialize()
*/
function initialize2() public onlyGovernor {
require(!_oethCreditsInitialized, "Initialize2 already called");

_oethCreditsInitialized = true;
/*
* This contract is using creditsBalanceOfHighres rather than creditsBalanceOf since this
* ensures better accuracy when rounding. Also creditsBalanceOf can be a little
* finicky since it reports Highres version of credits and creditsPerToken
* when the account is a fresh one. That doesn't have an effect on mainnet since
* WOETH has already seen transactions. But it is rather annoying in unit test
* environment.
*/
oethCreditsHighres = _getOETHCredits();
require(!_initialized2, "Initialize2 already called");
_initialized2 = true;

trackedAssets = IERC20(asset()).balanceOf(address(this));
}

function name()
Expand All @@ -90,103 +91,114 @@ contract WOETH is ERC4626, Governable, Initializable {

/**
* @notice Transfer token to governor. Intended for recovering tokens stuck in
* contract, i.e. mistaken sends. Cannot transfer OETH
* contract, i.e. mistaken sends. Cannot transfer the core asset
* @param asset_ Address for the asset
* @param amount_ Amount of the asset to transfer
*/
function transferToken(address asset_, uint256 amount_)
external
onlyGovernor
{
//@dev TODO: we could implement a feature where if anyone sends OETH directly to
// the contract, that we can let the governor transfer the excess of the token.
require(asset_ != address(asset()), "Cannot collect OETH");
require(asset_ != address(asset()), "Cannot collect core asset");
IERC20(asset_).safeTransfer(governor(), amount_);
}

/** @dev See {IERC4262-totalAssets} */
function totalAssets() public view override returns (uint256) {
uint256 creditsPerTokenHighres = OETH(asset())
.rebasingCreditsPerTokenHighres();

return (oethCreditsHighres).divPrecisely(creditsPerTokenHighres);
/* @notice Start the next yield period, if one is not active.
* New yield will not start until time has moved forward.
*/
function scheduleYield() public {
// If we are currently in a yield period, keep everything the same
if (block.timestamp < yieldEnd) {
return;
}

// Read current assets
uint256 _computedAssets = totalAssets();
uint256 _actualAssets = IERC20(asset()).balanceOf(address(this));

// Compute next yield period values
if (_actualAssets <= _computedAssets) {
yieldAssets = 0;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Should ensure we have a test for a loss of balance case.

trackedAssets = _actualAssets;
} else if (_actualAssets > _computedAssets) {
uint256 _newYield = _actualAssets - _computedAssets;
uint256 _maxYield = (_computedAssets * 5) / 100; // Maximum of 5% increase in assets per day
_newYield = _min(_min(_newYield, _maxYield), type(uint128).max);
yieldAssets = _newYield.toUint128();
trackedAssets = _computedAssets + yieldAssets;
}
// raw cast is deliberate, since this will not perma revert
yieldEnd = uint64(block.timestamp + YIELD_TIME);
emit YiedPeriodStarted(trackedAssets, yieldAssets, yieldEnd);
}

function _getOETHCredits()
internal
view
returns (uint256 oethCreditsHighres)
{
(oethCreditsHighres, , ) = OETH(asset()).creditsBalanceOfHighres(
address(this)
);
/**
* @notice Returns the assets currently backing the total supply.
* Does not include future yield held that will stream per block.
* @return totalAssets()
*/
function totalAssets() public view override returns (uint256) {
uint256 _end = yieldEnd;
if (block.timestamp >= _end) {
return trackedAssets;
} else if (block.timestamp <= _end - YIELD_TIME) {
return trackedAssets - yieldAssets;
}
uint256 elapsed = (block.timestamp + YIELD_TIME) - _end;
uint256 _unlockedYield = (yieldAssets * elapsed) / YIELD_TIME;
return trackedAssets + _unlockedYield - yieldAssets;
}

/** @dev See {IERC4262-deposit} */
function deposit(uint256 oethAmount, address receiver)
public
override
returns (uint256 woethAmount)
{
if (oethAmount == 0) return 0;

/**
* Initially we attempted to do the credits calculation within this contract and try
* to mimic OUSD's implementation. This way 1 external call less would be required. Due
* to a different way OUSD is calculating credits:
* - always rounds credits up
* - operates on final user balances before converting to credits
* - doesn't perform additive / subtractive calculation with credits once they are converted
* from balances
*
* We've decided that it is safer to read the credits diff directly from the OUSD contract
* and not face the risk of a compounding error in oethCreditsHighres that could result in
* inaccurate `convertToShares` & `convertToAssets` which consequently would result in faulty
* `previewMint` & `previewRedeem`. High enough error can result in different conversion rates
* which a flash loan entering via `deposit` and exiting via `redeem` (or entering via `mint`
* and exiting via `withdraw`) could take advantage of.
*/
uint256 creditsBefore = _getOETHCredits();
woethAmount = super.deposit(oethAmount, receiver);
oethCreditsHighres += _getOETHCredits() - creditsBefore;
trackedAssets += oethAmount;
scheduleYield();
}

/** @dev See {IERC4262-mint} */
function mint(uint256 woethAmount, address receiver)
public
override
returns (uint256 oethAmount)
{
if (woethAmount == 0) return 0;

uint256 creditsBefore = _getOETHCredits();
oethAmount = super.mint(woethAmount, receiver);
oethCreditsHighres += _getOETHCredits() - creditsBefore;
trackedAssets += oethAmount;
scheduleYield();
}

/** @dev See {IERC4262-withdraw} */
function withdraw(
uint256 oethAmount,
address receiver,
address owner
) public override returns (uint256 woethAmount) {
if (oethAmount == 0) return 0;

uint256 creditsBefore = _getOETHCredits();
woethAmount = super.withdraw(oethAmount, receiver, owner);
oethCreditsHighres -= creditsBefore - _getOETHCredits();
trackedAssets -= oethAmount;
scheduleYield();
}

/** @dev See {IERC4262-redeem} */
function redeem(
uint256 woethAmount,
address receiver,
address owner
) public override returns (uint256 oethAmount) {
if (woethAmount == 0) return 0;

uint256 creditsBefore = _getOETHCredits();
oethAmount = super.redeem(woethAmount, receiver, owner);
oethCreditsHighres -= creditsBefore - _getOETHCredits();
trackedAssets -= oethAmount;
scheduleYield();
}

function _transfer(
address sender,
address recipient,
uint256 amount
) internal override {
super._transfer(sender, recipient, amount);
scheduleYield();
}

function _min(uint256 a, uint256 b) internal pure returns (uint256) {
return a < b ? a : b;
}
}
Loading
Loading