diff --git a/src/adapters/Adapter.sol b/src/adapters/Adapter.sol index 132804a..17f1928 100644 --- a/src/adapters/Adapter.sol +++ b/src/adapters/Adapter.sol @@ -13,6 +13,8 @@ import { IERC165 } from "core/interfaces/IERC165.sol"; pragma solidity >=0.8.19; +error WithdrawPending(); + interface Adapter is IERC165 { function previewDeposit(address validator, uint256 assets) external view returns (uint256); @@ -24,7 +26,7 @@ interface Adapter is IERC165 { function currentTime() external view returns (uint256); - function stake(address validator, uint256 amount) external returns (uint256 staked); + function stake(address validator, uint256 amount) external payable returns (uint256 staked); function unstake(address validator, uint256 amount) external returns (uint256 unlockID); diff --git a/src/adapters/GraphAdapter.sol b/src/adapters/GraphAdapter.sol index 5f5aeea..7ac9567 100644 --- a/src/adapters/GraphAdapter.sol +++ b/src/adapters/GraphAdapter.sol @@ -15,7 +15,7 @@ uint256 constant VERSION = 1; import { ERC20 } from "solmate/tokens/ERC20.sol"; import { SafeTransferLib } from "solmate/utils/SafeTransferLib.sol"; -import { Adapter } from "core/adapters/Adapter.sol"; +import { Adapter, WithdrawPending } from "core/adapters/Adapter.sol"; import { IGraphStaking, IGraphEpochManager } from "core/adapters/interfaces/IGraph.sol"; import { IERC165 } from "core/interfaces/IERC165.sol"; @@ -29,8 +29,6 @@ contract GraphAdapter is Adapter { uint256 private constant STORAGE = uint256(keccak256("xyz.tenderize.graph.adapter.storage.location")) - 1; - error WithdrawPending(); - struct Unlock { uint256 shares; uint256 epoch; @@ -109,7 +107,7 @@ contract GraphAdapter is Adapter { return GRAPH_STAKING.hasStake(validator); } - function stake(address validator, uint256 amount) external override returns (uint256) { + function stake(address validator, uint256 amount) external payable override returns (uint256) { GRT.safeApprove(address(GRAPH_STAKING), amount); uint256 delShares = GRAPH_STAKING.delegate(validator, amount); IGraphStaking.DelegationPool memory delPool = GRAPH_STAKING.delegationPools(validator); diff --git a/src/adapters/LivepeerAdapter.sol b/src/adapters/LivepeerAdapter.sol index e18d7fa..3ff37ae 100644 --- a/src/adapters/LivepeerAdapter.sol +++ b/src/adapters/LivepeerAdapter.sol @@ -89,7 +89,7 @@ contract LivepeerAdapter is Adapter { return block.number; } - function stake(address validator, uint256 amount) public returns (uint256) { + function stake(address validator, uint256 amount) public payable returns (uint256) { LPT.safeApprove(address(LIVEPEER_BONDING), amount); LIVEPEER_BONDING.bond(amount, validator); return amount; diff --git a/src/adapters/PolygonAdapter.sol b/src/adapters/PolygonAdapter.sol index 98cea73..c0cf783 100644 --- a/src/adapters/PolygonAdapter.sol +++ b/src/adapters/PolygonAdapter.sol @@ -124,7 +124,7 @@ contract PolygonAdapter is Adapter { return POLYGON_STAKEMANAGER.epoch(); } - function stake(address validator, uint256 amount) external override returns (uint256) { + function stake(address validator, uint256 amount) external payable override returns (uint256) { // approve tokens POL.safeApprove(address(POLYGON_STAKEMANAGER), amount); diff --git a/src/adapters/SeiAdapter.sol b/src/adapters/SeiAdapter.sol new file mode 100644 index 0000000..29f7971 --- /dev/null +++ b/src/adapters/SeiAdapter.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.19; + +uint256 constant VERSION = 1; + +import { ERC20 } from "solmate/tokens/ERC20.sol"; +import { SafeTransferLib } from "solmate/utils/SafeTransferLib.sol"; +import { Adapter, WithdrawPending as WithdrawPendingError } from "core/adapters/Adapter.sol"; +import { ISei, StakingPool, UnbondingDelegation, BondStatus, UNSTAKE_TIME } from "core/adapters/interfaces/ISei.sol"; +import { IERC165 } from "core/interfaces/IERC165.sol"; +import { FixedPointMathLib } from "solmate/utils/FixedPointMathLib.sol"; + +address constant SEI_STAKING_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000001005; +ISei constant SEI_STAKING_CONTRACT = ISei(SEI_STAKING_PRECOMPILE_ADDRESS); + +contract SeiAdapter is Adapter { + using SafeTransferLib for ERC20; + using FixedPointMathLib for uint256; + + uint256 private constant STORAGE = uint256(keccak256("xyz.tenderize.sei.adapter.storage.location")) - 1; + + struct Storage { + address validator; + } + + function _loadStorage() internal pure returns (Storage storage $) { + uint256 slot = STORAGE; + + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := slot + } + } + + function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { + return interfaceId == type(Adapter).interfaceId || interfaceId == type(IERC165).interfaceId; + } + + function previewDeposit(address validator, uint256 assets) external view override returns (uint256) { + return _previewDeposit(validator, assets); + } + + function previewWithdraw(uint256 unlockID) external view override returns (uint256) { + UnbondingDelegation memory unbond = SEI_STAKING_CONTRACT.getUnbondingDelegation(_loadStorage().validator, unlockID); + return unbond.amount; + } + + function unlockMaturity(uint256 unlockID) external view override returns (uint256) { + UnbondingDelegation memory unbond = SEI_STAKING_CONTRACT.getUnbondingDelegation(_loadStorage().validator, unlockID); + return unbond.completionTime; + } + + function unlockTime() external view override returns (uint256) { + return UNSTAKE_TIME; + } + + function currentTime() external view override returns (uint256) { + return block.timestamp; + } + + function isValidator(address validator) public view override returns (bool) { + return SEI_STAKING_CONTRACT.getStakingPool(validator).status == BondStatus.Bonded; + } + + function stake(address validator, uint256 amount) external payable override returns (uint256 out) { + out = _previewDeposit(validator, amount); + SEI_STAKING_CONTRACT.delegate(validator, amount); + } + + function unstake(address validator, uint256 amount) external override returns (uint256 unlockID) { + unlockID = SEI_STAKING_CONTRACT.undelegate(validator, amount); + } + + function withdraw(address validator, uint256 unlockID) external override returns (uint256 amount) { + UnbondingDelegation memory unbond = SEI_STAKING_CONTRACT.getUnbondingDelegation(validator, unlockID); + // TODO: check unbonding time denomination + if (unbond.completionTime > block.timestamp) { + revert WithdrawPendingError(); + } + amount = unbond.amount; + } + + function rebase(address validator, uint256 /*currentStake*/ ) external override returns (uint256 newStake) { + Storage storage $ = _loadStorage(); + if ($.validator == address(0)) $.validator = validator; + + uint256 shares = SEI_STAKING_CONTRACT.getDelegation(address(this), validator); + StakingPool memory stakingPool = SEI_STAKING_CONTRACT.getStakingPool(validator); + newStake = shares.mulDivDown(stakingPool.totalTokens, stakingPool.totalShares); + } + + function _previewDeposit(address validator, uint256 assets) internal view returns (uint256 out) { + StakingPool memory stakingPool = SEI_STAKING_CONTRACT.getStakingPool(validator); + uint256 shares = assets.mulDivDown(stakingPool.totalShares, stakingPool.totalTokens); + return shares.mulDivDown(stakingPool.totalTokens + assets, stakingPool.totalShares + shares); + } +} diff --git a/src/adapters/interfaces/ISei.sol b/src/adapters/interfaces/ISei.sol new file mode 100644 index 0000000..5f8261f --- /dev/null +++ b/src/adapters/interfaces/ISei.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.19; + +uint256 constant UNSTAKE_TIME = 21 days; + +enum BondStatus { + Unbonded, + Unbonding, + Bonded +} + +struct UnbondingDelegation { + uint256 initialAmount; + uint256 amount; + uint256 creationHeight; + uint256 completionTime; +} + +struct StakingPool { + uint256 totalShares; + uint256 totalTokens; + BondStatus status; + bool jailed; +} + +interface ISei { + // Transactions + function delegate(address validator, uint256 amount) external returns (bool success); + + function redelegate(address src, address dst, uint256 amount) external returns (bool success); + + function undelegate(address validator, uint256 amount) external returns (uint256 unbondingID); + + function getDelegation(address delegator, address validator) external view returns (uint256 shares); + + function getStakingPool(address validator) external view returns (StakingPool memory); + + function getUnbondingDelegation(address validator, uint256 unbondingID) external view returns (UnbondingDelegation memory); +} diff --git a/src/tenderizer/TenderizerETH.sol b/src/tenderizer/TenderizerETH.sol new file mode 100644 index 0000000..349ba67 --- /dev/null +++ b/src/tenderizer/TenderizerETH.sol @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.19; + +import { ERC20 } from "solmate/tokens/ERC20.sol"; +import { FixedPointMathLib } from "solmate/utils/FixedPointMathLib.sol"; +import { SafeTransferLib } from "solmate/utils/SafeTransferLib.sol"; + +import { Adapter, AdapterDelegateCall } from "core/adapters/Adapter.sol"; +import { Registry } from "core/registry/Registry.sol"; +import { TenderizerImmutableArgs, TenderizerEvents } from "core/tenderizer/TenderizerBase.sol"; +import { TToken } from "core/tendertoken/TToken.sol"; +import { Multicall } from "core/utils/Multicall.sol"; +import { SelfPermit } from "core/utils/SelfPermit.sol"; +import { _staticcall } from "core/utils/StaticCall.sol"; +import { addressToString } from "core/utils/Utils.sol"; + +/** + * @title Tenderizer + * @author Tenderize Labs Ltd + * @notice Liquid staking vault for native liquid staking + * @dev Uses full type safety and unstructured storage + */ + +contract Tenderizer is TenderizerImmutableArgs, TenderizerEvents, TToken, Multicall, SelfPermit { + error InsufficientAssets(); + + using AdapterDelegateCall for Adapter; + using FixedPointMathLib for uint256; + using SafeTransferLib for ERC20; + + uint256 private constant MAX_FEE = 0.005e6; // 0.5% + uint256 private constant FEE_BASE = 1e6; + + // solhint-disable-next-line no-empty-blocks + constructor(address _registry, address _unlocks) TenderizerImmutableArgs(_registry, _unlocks) { } + receive() external payable { } + fallback() external payable { } + + // @inheritdoc TToken + function name() external view override returns (string memory) { + return string.concat("tender ", _baseSymbol()); + } + + // @inheritdoc TToken + function symbol() external view override returns (string memory) { + return string.concat("t", _baseSymbol()); + } + + // @inheritdoc TToken + function transfer(address to, uint256 amount) public override returns (bool) { + _rebase(); + return TToken.transfer(to, amount); + } + + // @inheritdoc TToken + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + _rebase(); + return TToken.transferFrom(from, to, amount); + } + + /** + * @notice Deposit assets to mint tTokens + * @param receiver address to mint tTokens to + * @param assets amount of assets to deposit + */ + function deposit(address receiver, uint256 assets) external payable returns (uint256) { + _rebase(); + + // check if value is sent with message + if (msg.value < assets) revert(); + + // stake assets + uint256 staked = _stake(validator(), assets); + + // mint tokens to receiver + uint256 shares; + if ((shares = _mint(receiver, staked)) == 0) revert InsufficientAssets(); + + uint256 tTokenOut = convertToAssets(shares); + emit Deposit(msg.sender, receiver, assets, tTokenOut); + + return tTokenOut; + } + + /** + * @notice Unlock tTokens to withdraw assets at maturity + * @param assets amount of assets to unlock + * @return unlockID of the unlock + */ + function unlock(uint256 assets) external returns (uint256 unlockID) { + _rebase(); + + // burn tTokens before creating an `unlock` + _burn(msg.sender, assets); + + // unlock assets and get unlockID + unlockID = _unstake(validator(), assets); + + // create unlock of unlockID + _unlocks().createUnlock(msg.sender, unlockID); + + // emit Unlock event + emit Unlock(msg.sender, assets, unlockID); + } + + /** + * @notice Redeem an unlock to withdraw assets after maturity + * @param receiver address to withdraw assets to + * @param unlockID ID of the unlock to redeem + * @return amount of assets withdrawn + */ + function withdraw(address payable receiver, uint256 unlockID) external returns (uint256 amount) { + // Redeem unlock if mature + _unlocks().useUnlock(msg.sender, unlockID); + + // withdraw assets to send to `receiver` + amount = _withdraw(validator(), unlockID); + + // transfer assets to `receiver` + receiver.transfer(amount); + + // emit Withdraw event + emit Withdraw(receiver, amount, unlockID); + } + + /** + * @notice Rebase tToken supply + * @dev Rebase can be called by anyone, is also forced to be called before any action or transfer + */ + function rebase() external { + _rebase(); + } + + function _rebase() internal { + uint256 currentStake = totalSupply(); + uint256 newStake = _rebase(validator(), currentStake); + + if (newStake > currentStake) { + unchecked { + uint256 rewards = newStake - currentStake; + uint256 fees = _calculateFees(rewards); + _setTotalSupply(newStake - fees); + // mint fees + if (fees > 0) { + _mint(_registry().treasury(), fees); + } + } + } else { + _setTotalSupply(newStake); + } + + // emit rebase event + emit Rebase(currentStake, newStake); + } + + function _calculateFees(uint256 rewards) internal view returns (uint256 fees) { + uint256 fee = _registry().fee(asset()); + fee = fee > MAX_FEE ? MAX_FEE : fee; + fees = rewards * fee / FEE_BASE; + } + + function _baseSymbol() internal view returns (string memory) { + return string.concat(ERC20(asset()).symbol(), "-", addressToString(validator())); + } + + function previewDeposit(uint256 assets) external view returns (uint256) { + uint256 out = abi.decode(_staticcall(address(this), abi.encodeCall(this._previewDeposit, (assets))), (uint256)); + Storage storage $ = _loadStorage(); + uint256 _totalShares = $._totalShares; // Saves an extra SLOAD if slot is non-zero + uint256 shares = convertToShares(out); + return _totalShares == 0 ? out : shares * $._totalSupply / _totalShares; + } + + function previewWithdraw(uint256 unlockID) external view returns (uint256) { + return abi.decode(_staticcall(address(this), abi.encodeCall(this._previewWithdraw, (unlockID))), (uint256)); + } + + function unlockMaturity(uint256 unlockID) external view returns (uint256) { + return abi.decode(_staticcall(address(this), abi.encodeCall(this._unlockMaturity, (unlockID))), (uint256)); + } + + // =============================================================================================================== + // NOTE: These functions are marked `public` but considered `internal` (hence the `_` prefix). + // This is because the compiler doesn't know whether there is a state change because of `delegatecall`` + // So for the external API (e.g. used by Unlocks.sol) we wrap these functions in `external` functions + // using a `staticcall` to `this`. + // This is a hacky workaround while better solidity features are being developed. + function _previewDeposit(uint256 assets) public returns (uint256) { + return abi.decode(adapter()._delegatecall(abi.encodeCall(adapter().previewDeposit, (validator(), assets))), (uint256)); + } + + function _previewWithdraw(uint256 unlockID) public returns (uint256) { + return abi.decode(adapter()._delegatecall(abi.encodeCall(adapter().previewWithdraw, (unlockID))), (uint256)); + } + + function _unlockMaturity(uint256 unlockID) public returns (uint256) { + return abi.decode(adapter()._delegatecall(abi.encodeCall(adapter().unlockMaturity, (unlockID))), (uint256)); + } + // =============================================================================================================== + + function _rebase(address validator, uint256 currentStake) internal returns (uint256 newStake) { + newStake = abi.decode(adapter()._delegatecall(abi.encodeCall(adapter().rebase, (validator, currentStake))), (uint256)); + } + + function _stake(address validator, uint256 amount) internal returns (uint256 staked) { + staked = abi.decode(adapter()._delegatecall(abi.encodeCall(adapter().stake, (validator, amount))), (uint256)); + } + + function _unstake(address validator, uint256 amount) internal returns (uint256 unlockID) { + unlockID = abi.decode(adapter()._delegatecall(abi.encodeCall(adapter().unstake, (validator, amount))), (uint256)); + } + + function _withdraw(address validator, uint256 unlockID) internal returns (uint256 withdrawAmount) { + withdrawAmount = abi.decode(adapter()._delegatecall(abi.encodeCall(adapter().withdraw, (validator, unlockID))), (uint256)); + } +}