diff --git a/contracts/BackedAutoFeeTokenImplementation.sol b/contracts/BackedAutoFeeTokenImplementation.sol index 96c53ac..3a29c20 100644 --- a/contracts/BackedAutoFeeTokenImplementation.sol +++ b/contracts/BackedAutoFeeTokenImplementation.sol @@ -38,6 +38,7 @@ pragma solidity 0.8.9; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "./BackedTokenImplementation.sol"; +import "./interfaces/IBackedAutoFeeToken.sol"; /** * @dev @@ -51,7 +52,7 @@ import "./BackedTokenImplementation.sol"; * */ -contract BackedAutoFeeTokenImplementation is BackedTokenImplementation { +contract BackedAutoFeeTokenImplementation is BackedTokenImplementation, IBackedAutoFeeToken { // Calculating the Delegated Transfer Shares typehash: bytes32 constant public DELEGATED_TRANSFER_SHARES_TYPEHASH = keccak256( @@ -88,35 +89,28 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation { uint256 public newMultiplier; uint256 public newMultiplierActivationTime; + /** + * @dev Append-only log of *explicit* multiplier updates submitted via + * `updateMultiplierValue` / `updateMultiplierWithNonce`. + * + * Index 0 is a genesis sentinel `{1e18, 1e18, 0}` so that + * `_storeScheduledMultiplierUpdate` can safely read `length - 1`. + * + * Pending future-dated entries are overridden in place: when a new + * explicit update is submitted while the previous one is still + * scheduled (activationTime > block.timestamp), the previous entry is + * popped before the new one is appended. The corresponding + * `MultiplierScheduled` event from the popped entry remains on chain; + * the array does not retain it. + */ + MultiplierUpdate[] public multiplierUpdates; + function multiplierNonce() external view returns (uint256) { if(block.timestamp >= newMultiplierActivationTime) { return newMultiplierNonce; } return lastMultiplierNonce; } - // Events: - - /** - * @dev Emitted when multiplier updater is changed - */ - event NewMultiplierUpdater(address indexed newMultiplierUpdater); - - /** - * @dev Emitted when `value` token shares are moved from one account (`from`) to - * another (`to`). - * - * Note that `value` may be zero. - */ - event TransferShares( - address indexed from, - address indexed to, - uint256 value - ); - - /** - * @dev Emitted when multiplier value is updated - */ - event MultiplierUpdated(uint256 value); // Modifiers: @@ -164,7 +158,7 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation { function initialize( string memory name_, string memory symbol_ - ) public virtual override { + ) public virtual override(BackedTokenImplementation, IBackedToken) { super.initialize(name_, symbol_); _initialize_auto_fee(24 * 3600, block.timestamp, 0); } @@ -197,6 +191,25 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation { newMultiplierActivationTime = 0; } + function initialize_v4( + MultiplierUpdate [] calldata _pastMultipliersUpdates + ) external virtual { + require(multiplierUpdates.length == 0, "BackedAutoFeeTokenImplementation v4 already initialized"); + multiplierUpdates.push(MultiplierUpdate({ + previousMultiplier: 1e18, + newMultiplier: 1e18, + activationTime: 0 + })); + for (uint i = 0; i < _pastMultipliersUpdates.length; i++) { + require(_pastMultipliersUpdates[i].previousMultiplier > 0, "BackedAutoFeeTokenImplementation: previousMultiplier cannot be zero"); + multiplierUpdates.push(MultiplierUpdate({ + previousMultiplier: _pastMultipliersUpdates[i].previousMultiplier, + newMultiplier: _pastMultipliersUpdates[i].newMultiplier, + activationTime: _pastMultipliersUpdates[i].activationTime + })); + } + } + function _initialize_auto_fee( uint256 _periodLength, uint256 _lastTimeFeeApplied, @@ -213,6 +226,18 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation { periodLength = _periodLength; lastTimeFeeApplied = _lastTimeFeeApplied; feePerPeriod = _feePerPeriod; + multiplierUpdates.push(MultiplierUpdate({ + previousMultiplier: 1e18, + newMultiplier: 1e18, + activationTime: 0 + })); + } + + /** + * @inheritdoc IERC20MetadataUpgradeable + */ + function decimals() public view virtual override(BackedTokenImplementation, IBackedToken) returns (uint8) { + return ERC20Upgradeable.decimals(); } /** @@ -284,6 +309,13 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation { return _getUnderlyingAmountByShares(_sharesAmount, currentMultiplier); } + /** + * @return Length of scheduled multipliers updates array + */ + function multiplierUpdatesLength() external view returns (uint256) { + return multiplierUpdates.length; + } + /** * @dev Delegated Transfer Shares, transfer shares via a sign message, using erc712. */ @@ -416,6 +448,7 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation { uint256 pendingNewMultiplierActivationTime ) public onlyMultiplierUpdater updateMultiplier onlyUpdatedMultiplier(oldMultiplier) { _updateMultiplier(pendingNewMultiplier, lastMultiplierNonce + 1, pendingNewMultiplierActivationTime); + _storeScheduledMultiplierUpdate(oldMultiplier, pendingNewMultiplier, pendingNewMultiplierActivationTime); } /** @@ -433,6 +466,46 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation { uint256 pendingNewMultiplierActivationTime ) external onlyMultiplierUpdater updateMultiplier onlyUpdatedMultiplier(oldMultiplier) onlyNewerMultiplierNonce(newMultiplierNonce){ _updateMultiplier(newMultiplier, newMultiplierNonce, pendingNewMultiplierActivationTime); + _storeScheduledMultiplierUpdate(oldMultiplier, newMultiplier, pendingNewMultiplierActivationTime); + } + + /** + * @dev Stores an explicit multiplier update in `multiplierUpdates`. + * + * If the previously stored entry is still pending (activationTime in the + * future), it is overwritten in place — only one scheduled update can be + * pending at a time — and a `MultiplierScheduleOverridden` event is + * emitted so off-chain consumers can reconcile the now-stale + * `MultiplierScheduled` event for the discarded entry. + * + * Tolerates an empty array (the not-yet-migrated state between a v4 + * implementation upgrade and an `initialize_v4` call) by falling through + * to the append path. + */ + function _storeScheduledMultiplierUpdate( + uint256 _previousMultiplierValue, + uint256 _multiplierValue, + uint256 _multiplierActivationTime + ) internal { + MultiplierUpdate memory entry = MultiplierUpdate({ + previousMultiplier: _previousMultiplierValue, + newMultiplier: _multiplierValue, + activationTime: _multiplierActivationTime > block.timestamp ? _multiplierActivationTime : block.timestamp + }); + + uint256 len = multiplierUpdates.length; + if (len > 0 && multiplierUpdates[len - 1].activationTime > block.timestamp) { + MultiplierUpdate memory overridden = multiplierUpdates[len - 1]; + multiplierUpdates[len - 1] = entry; + emit MultiplierScheduleOverridden( + overridden.newMultiplier, + overridden.activationTime, + entry.newMultiplier, + entry.activationTime + ); + } else { + multiplierUpdates.push(entry); + } } /** @@ -616,6 +689,7 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation { if(pendingNewMultiplierActivationTime > block.timestamp) { newMultiplierActivationTime = pendingNewMultiplierActivationTime; + emit MultiplierScheduled(pendingNewMultiplier, pendingNewMultiplierActivationTime); // We don't need to update lastMultiplier and lastMultiplierNonce here, as they will be updated in updateMultiplier modifier when calling updateMultiplier method } else { newMultiplierActivationTime = 0; diff --git a/contracts/BackedTokenImplementation.sol b/contracts/BackedTokenImplementation.sol index c397420..31065cc 100644 --- a/contracts/BackedTokenImplementation.sol +++ b/contracts/BackedTokenImplementation.sol @@ -39,6 +39,7 @@ pragma solidity 0.8.9; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "./ERC20PermitDelegateTransfer.sol"; import "./SanctionsList.sol"; +import "./interfaces/IBackedToken.sol"; /** * @dev @@ -55,7 +56,7 @@ import "./SanctionsList.sol"; * */ -contract BackedTokenImplementation is OwnableUpgradeable, ERC20PermitDelegateTransfer { +contract BackedTokenImplementation is OwnableUpgradeable, ERC20PermitDelegateTransfer, IBackedToken { string constant public VERSION = "1.1.0"; // Roles: @@ -76,16 +77,6 @@ contract BackedTokenImplementation is OwnableUpgradeable, ERC20PermitDelegateTra // Terms: string public terms; - // Events: - event NewMinter(address indexed newMinter); - event NewBurner(address indexed newBurner); - event NewPauser(address indexed newPauser); - event NewSanctionsList(address indexed newSanctionsList); - event DelegateWhitelistChange(address indexed whitelistAddress, bool status); - event DelegateModeChange(bool delegateMode); - event PauseModeChange(bool pauseMode); - event NewTerms(string newTerms); - modifier allowedDelegate { require(delegateMode || delegateWhitelist[_msgSender()], "BackedToken: Unauthorized delegate"); _; @@ -153,6 +144,10 @@ contract BackedTokenImplementation is OwnableUpgradeable, ERC20PermitDelegateTra super.delegatedTransfer(owner, to, value, deadline, v, r, s); } + function decimals() public view virtual override(ERC20Upgradeable, IBackedToken) returns (uint8) { + return super.decimals(); + } + /** * @dev Function to mint tokens. Allowed only for minter * diff --git a/contracts/WrappedBackedTokenFactory.sol b/contracts/WrappedBackedTokenFactory.sol index 0d9ae81..2bb2bad 100644 --- a/contracts/WrappedBackedTokenFactory.sol +++ b/contracts/WrappedBackedTokenFactory.sol @@ -66,7 +66,6 @@ contract WrappedBackedTokenFactory is Ownable { "Factory: address should not be 0" ); - wrappedTokenImplementation = new WrappedBackedTokenImplementation(); proxyAdmin = new ProxyAdmin(); proxyAdmin.transferOwnership(proxyAdminOwner); } @@ -77,7 +76,6 @@ contract WrappedBackedTokenFactory is Ownable { address underlying; // The address of the wrapped token underlying address tokenOwner; // The address of the account to which the owner role will be assigned address pauser; // The address of the account to which the pauser role will be assigned - address sanctionsList; // The address of sanctions list contract } /** @@ -98,12 +96,16 @@ contract WrappedBackedTokenFactory is Ownable { ); bytes32 salt = keccak256( - abi.encodePacked(configuration.name, configuration.symbol) + abi.encodePacked(configuration.name, configuration.symbol, configuration.underlying) ); WrappedBackedTokenProxy newProxy = new WrappedBackedTokenProxy{salt: salt}( + address(this), //Using this as implementation to not make address dependent on implementation address + address(this), + "" + ); + newProxy.upgradeToAndCall( address(wrappedTokenImplementation), - address(proxyAdmin), abi.encodeWithSelector( bytes4( keccak256( @@ -115,13 +117,13 @@ contract WrappedBackedTokenFactory is Ownable { configuration.underlying ) ); + newProxy.changeAdmin(address(proxyAdmin)); WrappedBackedTokenImplementation newToken = WrappedBackedTokenImplementation( address(newProxy) ); newToken.setPauser(configuration.pauser); - newToken.setSanctionsList(configuration.sanctionsList); newToken.transferOwnership(configuration.tokenOwner); emit NewToken( diff --git a/contracts/WrappedBackedTokenImplementation.sol b/contracts/WrappedBackedTokenImplementation.sol index a0fd240..2ccc8e7 100644 --- a/contracts/WrappedBackedTokenImplementation.sol +++ b/contracts/WrappedBackedTokenImplementation.sol @@ -36,25 +36,31 @@ pragma solidity 0.8.9; +import "./interfaces/IBackedAutoFeeToken.sol"; +import "./interfaces/IBackedToken.sol"; import "@openzeppelin/contracts-upgradeable-new/token/ERC20/extensions/ERC4626Upgradeable.sol"; import "@openzeppelin/contracts-upgradeable-new/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable-new/access/OwnableUpgradeable.sol"; -import "./SanctionsList.sol"; +import "@openzeppelin/contracts-upgradeable-new/utils/math/MathUpgradeable.sol"; /** * @dev * * This token contract is following the ERC20 standard. * It inherits ERC4626Upgradeable, which extends the basic ERC20 to be a representation of changing underlying token. + * The underlying token is expected to be an IBackedAutoFeeToken, that is a token with an auto fee mechanism, + * and a multiplier that changes over time to reflect the fees and corporate actions applied. + * It translates the shares of the underlying IBackedAutoFeeToken to an amount of assets, using the current multiplier of the underlying token. + * The shares of the underlying token are kept in the contract, and the corresponding amount of this token is minted to the user. * Enforces Sanctions List via the Chainalysis standard interface. * The contract contains one role: * - A pauser, that can pause or restore all transfers in the contract. - * - An owner, that can set the above, and also the sanctionsList pointer. - * The owner can also set who can use the EIP-712 functionality, either specific accounts via a whitelist, or everyone. * */ contract WrappedBackedTokenImplementation is OwnableUpgradeable, ERC4626Upgradeable, ERC20PermitUpgradeable { + using MathUpgradeable for uint256; + string constant public VERSION = "1.0.0"; // Calculating the Delegated Transfer typehash: @@ -71,29 +77,18 @@ contract WrappedBackedTokenImplementation is OwnableUpgradeable, ERC4626Upgradea // Pause: bool public isPaused; - // SanctionsList: - SanctionsList public sanctionsList; - // Terms: string public terms; // Events: event NewPauser(address indexed newPauser); - event NewSanctionsList(address indexed newSanctionsList); - event DelegateWhitelistChange(address indexed whitelistAddress, bool status); - event DelegateModeChange(bool delegateMode); event PauseModeChange(bool pauseMode); event NewTerms(string newTerms); - modifier allowedDelegate { - require(delegateMode || delegateWhitelist[_msgSender()], "WrappedBackedToken: Unauthorized delegate"); - _; - } - // constructor, call initializer to lock the implementation instance. constructor () { - initialize("Wrapped Backed Token Implementation", "wBTI", address(0x0000000000000000000000000000000000000000)); + _disableInitializers(); } function initialize(string memory name_, string memory symbol_, address underlying_) public initializer { @@ -111,21 +106,6 @@ contract WrappedBackedTokenImplementation is OwnableUpgradeable, ERC4626Upgradea return ERC4626Upgradeable.decimals(); } - /** - * @inheritdoc IERC20PermitUpgradeable - */ - function permit( - address owner, - address spender, - uint256 value, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) public virtual override allowedDelegate { - super.permit(owner, spender, value, deadline, v, r, s); - } - /** * @dev Delegated Transfer, transfer via a sign message, using erc712. */ @@ -137,7 +117,7 @@ contract WrappedBackedTokenImplementation is OwnableUpgradeable, ERC4626Upgradea uint8 v, bytes32 r, bytes32 s - ) external virtual allowedDelegate { + ) external virtual { require(block.timestamp <= deadline, "ERC20Permit: expired deadline"); bytes32 structHash = keccak256(abi.encode(DELEGATED_TRANSFER_TYPEHASH, owner, to, value, _useNonce(owner), deadline)); @@ -177,50 +157,6 @@ contract WrappedBackedTokenImplementation is OwnableUpgradeable, ERC4626Upgradea emit NewPauser(newPauser); } - /** - * @dev Function to change the contract Senctions List. Allowed only for owner - * - * Emits a { NewSanctionsList } event - * - * @param newSanctionsList The address of the new Senctions List following the Chainalysis standard - */ - function setSanctionsList(address newSanctionsList) external onlyOwner { - // Check the proposed sanctions list contract has the right interface: - require(!SanctionsList(newSanctionsList).isSanctioned(address(this)), "WrappedBackedToken: Wrong List interface"); - - sanctionsList = SanctionsList(newSanctionsList); - emit NewSanctionsList(newSanctionsList); - } - - - /** - * @dev EIP-712 Function to change the delegate status of account. - * Allowed only for owner - * - * Emits a { DelegateWhitelistChange } event - * - * @param whitelistAddress The address for which to change the delegate status - * @param status The new delegate status - */ - function setDelegateWhitelist(address whitelistAddress, bool status) external onlyOwner { - delegateWhitelist[whitelistAddress] = status; - emit DelegateWhitelistChange(whitelistAddress, status); - } - - /** - * @dev EIP-712 Function to change the contract delegate mode. Allowed - * only for owner - * - * Emits a { DelegateModeChange } event - * - * @param _delegateMode The new delegate mode for the contract - */ - function setDelegateMode(bool _delegateMode) external onlyOwner { - delegateMode = _delegateMode; - - emit DelegateModeChange(_delegateMode); - } - /** * @dev Function to change the contract terms. Allowed only for owner * @@ -248,10 +184,10 @@ contract WrappedBackedTokenImplementation is OwnableUpgradeable, ERC4626Upgradea require(!isPaused, "WrappedBackedToken: token transfer while paused"); if (from != address(0)) { - require(!sanctionsList.isSanctioned(from), "WrappedBackedToken: sender is sanctioned"); + require(!IBackedToken(asset()).sanctionsList().isSanctioned(from), "WrappedBackedToken: sender is sanctioned"); } if (to != address(0)) { - require(!sanctionsList.isSanctioned(to), "WrappedBackedToken: receiver is sanctioned"); + require(!IBackedToken(asset()).sanctionsList().isSanctioned(to), "WrappedBackedToken: receiver is sanctioned"); } super._beforeTokenTransfer(from, to, amount); @@ -263,9 +199,85 @@ contract WrappedBackedTokenImplementation is OwnableUpgradeable, ERC4626Upgradea address spender, uint256 amount ) internal virtual override { - require(!sanctionsList.isSanctioned(spender), "WrappedBackedToken: spender is sanctioned"); + require(!IBackedToken(asset()).sanctionsList().isSanctioned(spender), "WrappedBackedToken: spender is sanctioned"); super._spendAllowance(owner, spender, amount); } + /** @dev See {IERC4626-previewMint}. + * + * Amounts are rounded down, in order to accomodate multiplier math done on underlying token + */ + function previewMint(uint256 shares) public view virtual override returns (uint256) { + return _convertToAssets(shares, MathUpgradeable.Rounding.Down); + } + + /** @dev See {IERC4626-previewWithdraw}. + * + * Amounts are rounded down, in order to accomodate multiplier math done on underlying token + */ + function previewWithdraw(uint256 assets) public view virtual override returns (uint256) { + return _convertToShares(assets, MathUpgradeable.Rounding.Down); + } + + /** @dev See {IERC4626-totalAssets}. + * + * Instead of checking actual balances, we assume that we maintain 1:1 ratio between wrapper token and the shares of the underlying token + * , so total assets is calculated as the balance of shares of the underlying token kept by the contract, multiplied by the current multiplier of the underlying token. + */ + function totalAssets() public view virtual override returns (uint256) { + return _convertToAssets(totalSupply(), MathUpgradeable.Rounding.Down); + } + + /** + * @dev Internal conversion function (from assets to shares) with support for rounding direction. + */ + function _convertToShares(uint256 assets, MathUpgradeable.Rounding rounding) internal view virtual override returns (uint256) { + (uint256 currentMultiplier, ,) = IBackedAutoFeeToken(asset()).getCurrentMultiplier(); + return assets.mulDiv(1e18, currentMultiplier, rounding); + } + + /** + * @dev Internal conversion function (from shares to assets) with support for rounding direction. + */ + function _convertToAssets(uint256 shares, MathUpgradeable.Rounding rounding) internal view virtual override returns (uint256) { + (uint256 currentMultiplier, ,) = IBackedAutoFeeToken(asset()).getCurrentMultiplier(); + return shares.mulDiv(currentMultiplier, 1e18, rounding); + } + + /** + * @dev Deposit/mint common workflow, adjusted to the fact, that wrapper token is keeping the shares of the underlying token and not the underlying tokens themselves, + * so it needs to transfer the shares from the user, and not do erc20 transfers as in a normal ERC4626 implementation. + */ + function _deposit(address caller, address receiver, uint256 assetsRequested, uint256 shares) internal virtual override { + IBackedAutoFeeToken assetToken = IBackedAutoFeeToken(asset()); + assetToken.transferSharesFrom(caller, address(this), shares); + _mint(receiver, shares); + + uint256 assets = convertToAssets(shares); + emit Deposit(caller, receiver, assets, shares); + } + + /** + * @dev Withdraw/redeem common workflow, adjusted to the fact, that wrapper token is keeping the shares of the underlying token and not the underlying tokens themselves, + * so it needs to transfer the shares to the user, and not do erc20 transfers as in a normal ERC4626 implementation. + */ + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assetsRequested, + uint256 shares + ) internal virtual override { + if (caller != owner) { + _spendAllowance(owner, caller, shares); + } + + _burn(owner, shares); + IBackedAutoFeeToken assetToken = IBackedAutoFeeToken(asset()); + assetToken.transferShares(receiver, shares); + + uint256 assets = convertToAssets(shares); + emit Withdraw(caller, receiver, owner, assets, shares); + } } diff --git a/contracts/interfaces/IBackedAutoFeeToken.sol b/contracts/interfaces/IBackedAutoFeeToken.sol new file mode 100644 index 0000000..0dac24b --- /dev/null +++ b/contracts/interfaces/IBackedAutoFeeToken.sol @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../SanctionsList.sol"; +import "./IBackedToken.sol"; + +/** + * @title IBackedAutoFeeToken + * @dev Interface for the BackedAutoFeeToken, a rebasing ERC20 token with automatic fee accrual + * + * This token implements a share-based rebasing mechanism where: + * - Users hold shares that represent their portion of the total supply + * - A multiplier converts shares to underlying token amounts + * - Fees are automatically applied by decreasing the multiplier over time + * - The multiplier can be updated by an authorized multiplierUpdater address + */ +interface IBackedAutoFeeToken is IBackedToken { + /** + * @dev Struct representing multiplier update + * @param previousMultiplier The multiplier value before this update + * @param newMultiplier The multiplier value after this update + * @param activationTime The Unix timestamp when this update was/will be activated + */ + struct MultiplierUpdate { + uint256 previousMultiplier; + uint256 newMultiplier; + uint256 activationTime; + } + + // Events + + /** + * @dev Emitted when the multiplier updater address is changed + * @param newMultiplierUpdater The address of the new multiplier updater + */ + event NewMultiplierUpdater(address indexed newMultiplierUpdater); + + /** + * @dev Emitted when shares are transferred between addresses + * @param from The address shares are transferred from + * @param to The address shares are transferred to + * @param value The amount of shares transferred + */ + event TransferShares(address indexed from, address indexed to, uint256 value); + + /** + * @dev Emitted when the multiplier value is updated and activated + * @param value The new multiplier value (in 1e18 precision) + */ + event MultiplierUpdated(uint256 value); + + /** + * @dev Emitted when a multiplier is scheduled for future activation + * @param newMultiplier The new multiplier value that will be activated (in 1e18 precision) + * @param activationTime The Unix timestamp when the multiplier will become active + */ + event MultiplierScheduled(uint256 newMultiplier, uint256 activationTime); + + /** + * @dev Emitted when a previously scheduled multiplier update is replaced + * before its activationTime is reached. The overridden entry is removed + * from `multiplierUpdates` in place and replaced by the new one. + * + * Off-chain consumers reconstructing state from events should drop any + * earlier `MultiplierScheduled(overriddenMultiplier, overriddenActivationTime)` + * upon seeing this event. + * + * @param overriddenMultiplier The newMultiplier of the pending entry that + * was discarded (the one previously announced via MultiplierScheduled) + * @param overriddenActivationTime The activationTime of the discarded entry + * @param newMultiplier The newMultiplier that replaces it (already announced + * in this same transaction via MultiplierScheduled or MultiplierUpdated) + * @param newActivationTime The activationTime stored for the replacement + */ + event MultiplierScheduleOverridden( + uint256 overriddenMultiplier, + uint256 overriddenActivationTime, + uint256 newMultiplier, + uint256 newActivationTime + ); + + // View functions - EIP-712 and Roles + + /** + * @dev Returns the address authorized to update the multiplier + * @return The multiplier updater address + */ + function multiplierUpdater() external view returns (address); + + // View functions - Fee Configuration + + /** + * @dev Returns the timestamp when the fee was last applied + * @return The Unix timestamp of the last fee application + */ + function lastTimeFeeApplied() external view returns (uint256); + + /** + * @dev Returns the fee rate applied per period + * @return The fee per period in 1e18 precision (e.g., 1e15 = 0.1% fee) + */ + function feePerPeriod() external view returns (uint256); + + /** + * @dev Returns the length of each fee accrual period in seconds + * @return The period length in seconds (e.g., 86400 for daily fees) + */ + function periodLength() external view returns (uint256); + + // View functions - Multiplier State + + /** + * @dev Returns the last activated multiplier value + * @return The last multiplier value in 1e18 precision + */ + function lastMultiplier() external view returns (uint256); + + /** + * @dev Returns the current active multiplier, considering pending activations + * @return The currently active multiplier value in 1e18 precision + */ + function multiplier() external view returns (uint256); + + /** + * @dev Returns the nonce of the last activated multiplier + * @return The last multiplier nonce + */ + function lastMultiplierNonce() external view returns (uint256); + + /** + * @dev Returns the nonce of the pending/new multiplier + * @return The new multiplier nonce + */ + function newMultiplierNonce() external view returns (uint256); + + /** + * @dev Returns the value of the pending/new multiplier + * @return The new multiplier value in 1e18 precision + */ + function newMultiplier() external view returns (uint256); + + /** + * @dev Returns the timestamp when the new multiplier becomes active + * @return The Unix timestamp of activation (0 if no pending activation) + */ + function newMultiplierActivationTime() external view returns (uint256); + + /** + * @dev Returns the current multiplier nonce, considering pending activations + * @return The current active multiplier nonce + */ + function multiplierNonce() external view returns (uint256); + + /** + * @dev Calculates and returns the current multiplier with fees applied + * @return currentMultiplier The multiplier value with all accrued fees applied + * @return periodsPassed The number of fee periods that have passed + * @return currentMultiplierNonce The nonce including periods passed + */ + function getCurrentMultiplier() external view returns (uint256 currentMultiplier, uint256 periodsPassed, uint256 currentMultiplierNonce); + + // View functions - Token Shares + + /** + * @dev Returns the share balance of an account + * @param account The address to query + * @return The number of shares owned by the account + */ + function sharesOf(address account) external view returns (uint256); + + /** + * @dev Converts an underlying token amount to shares + * @param _underlyingAmount The amount of tokens to convert + * @return The equivalent amount of shares + */ + function getSharesByUnderlyingAmount(uint256 _underlyingAmount) external view returns (uint256); + + /** + * @dev Converts shares to underlying token amount + * @param _sharesAmount The amount of shares to convert + * @return The equivalent amount of tokens + */ + function getUnderlyingAmountByShares(uint256 _sharesAmount) external view returns (uint256); + + /** + * @dev Returns the length of the multiplierUpdates array. + * + * The array records *explicit* multiplier updates only (those submitted + * via `updateMultiplierValue` / `updateMultiplierWithNonce`). Automatic + * per-period fee decay is NOT appended. See `multiplierUpdates` for the + * full semantics. + * + * @return The number of explicit multiplier updates stored + * (including the genesis sentinel at index 0). + */ + function multiplierUpdatesLength() external view returns (uint256); + + /** + * @dev Returns a specific explicit multiplier update by index. + * + * This array is an append-only log of *explicit* multiplier updates only; + * automatic per-period fee decay is applied lazily to `lastMultiplier` + * without appending here. As a result `previousMultiplier` at index `i` + * is the fee-decayed value at the time of the i-th explicit update and + * is typically less than `newMultiplier` at index `i-1` — the gap is the + * accrual that happened in between. + * + * Index 0 is a genesis sentinel `{1e18, 1e18, 0}`. A future-dated entry + * that is overridden before activation is popped from this array; the + * `MultiplierScheduled` event for the popped entry remains on chain. + * + * @param index The index in the multiplierUpdates array + * @return previousMultiplier The (possibly fee-decayed) multiplier value + * immediately before this explicit update was applied + * @return newMultiplier The multiplier value after this update + * @return activationTime The Unix timestamp when this update was/will be + * activated; equals `block.timestamp` at recording time for + * immediate updates, or the requested future timestamp for + * scheduled ones + */ + function multiplierUpdates(uint256 index) external view returns (uint256 previousMultiplier, uint256 newMultiplier, uint256 activationTime); + + // State-changing functions - Share Transfers + + /** + * @dev Executes a delegated share transfer using EIP-712 signature + * @param owner The address that owns the shares + * @param to The address to transfer shares to + * @param value The amount of shares to transfer + * @param deadline The deadline timestamp for the signature + * @param v The recovery byte of the signature + * @param r Half of the ECDSA signature pair + * @param s Half of the ECDSA signature pair + */ + function delegatedTransferShares(address owner, address to, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external; + + /** + * @dev Transfers shares from the caller to another address + * @param to The address to transfer shares to + * @param sharesAmount The amount of shares to transfer + * @return success True if the transfer succeeded + */ + function transferShares(address to, uint256 sharesAmount) external returns (bool); + + /** + * @dev Transfers shares from one address to another using allowance + * @param from The address to transfer shares from + * @param to The address to transfer shares to + * @param sharesAmount The amount of shares to transfer + * @return success True if the transfer succeeded + */ + function transferSharesFrom(address from, address to, uint256 sharesAmount) external returns (bool); + + // State-changing functions - Fee Configuration (Owner only) + + /** + * @dev Updates the fee rate per period + * Can only be called by the owner + * Cannot be called when a multiplier activation is pending + * @param newFeePerPeriod The new fee rate in 1e18 precision + */ + function updateFeePerPeriod(uint256 newFeePerPeriod) external; + + /** + * @dev Updates the multiplier updater address + * Can only be called by the owner + * @param newMultiplierUpdater The address of the new multiplier updater + */ + function setMultiplierUpdater(address newMultiplierUpdater) external; + + /** + * @dev Updates the timestamp of last fee application + * Can only be called by the owner + * Cannot be called when a multiplier activation is pending + * @param newLastTimeFeeApplied The new timestamp (must be non-zero) + */ + function setLastTimeFeeApplied(uint256 newLastTimeFeeApplied) external; + + /** + * @dev Updates the length of each fee period + * Can only be called by the owner + * Cannot be called when a multiplier activation is pending + * @param newPeriodLength The new period length in seconds + */ + function setPeriodLength(uint256 newPeriodLength) external; + + // State-changing functions - Multiplier Updates (Multiplier Updater only) + + /** + * @dev Updates the multiplier value with automatic nonce increment + * Can only be called by the multiplier updater + * Validates that the oldMultiplier matches the current value + * @param pendingNewMultiplier The new multiplier value in 1e18 precision + * @param oldMultiplier The expected current multiplier for validation + * @param pendingNewMultiplierActivationTime When to activate (0 for immediate, future timestamp for delayed) + */ + function updateMultiplierValue(uint256 pendingNewMultiplier, uint256 oldMultiplier, uint256 pendingNewMultiplierActivationTime) external; + + /** + * @dev Updates the multiplier value with explicit nonce + * Can only be called by the multiplier updater + * Validates that the oldMultiplier matches and nonce is newer + * @param newMultiplier The new multiplier value in 1e18 precision + * @param oldMultiplier The expected current multiplier for validation + * @param newMultiplierNonce The explicit nonce for this update + * @param pendingNewMultiplierActivationTime When to activate (0 for immediate, future timestamp for delayed) + */ + function updateMultiplierWithNonce(uint256 newMultiplier, uint256 oldMultiplier, uint256 newMultiplierNonce, uint256 pendingNewMultiplierActivationTime) external; +} \ No newline at end of file diff --git a/contracts/interfaces/IBackedToken.sol b/contracts/interfaces/IBackedToken.sol new file mode 100644 index 0000000..76dd5e0 --- /dev/null +++ b/contracts/interfaces/IBackedToken.sol @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../SanctionsList.sol"; + +/** + * @title IBackedToken + * @dev Interface for BackedTokenImplementation, an ERC20 token with EIP-712 + * permit and delegated-transfer support, role-based mint/burn/pause + * controls, a Chainalysis-compatible sanctions list, and a settable + * terms-of-service link. + * + * The contract exposes four roles: + * - minter: can mint new tokens. + * - burner: can burn its own tokens or tokens held by the contract itself. + * - pauser: can pause or unpause all transfers. + * - owner: can configure the three roles above, the sanctions list, the + * delegate-mode flags, and the terms string. + */ +interface IBackedToken { + /** + * @dev Returns the decimals places of the token. + */ + function decimals() external view returns (uint8); + + // View functions - Roles + + /** + * @dev Returns the address authorized to mint tokens. + */ + function minter() external view returns (address); + + /** + * @dev Returns the address authorized to burn tokens. + */ + function burner() external view returns (address); + + /** + * @dev Returns the address authorized to pause/unpause transfers. + */ + function pauser() external view returns (address); + + // View functions - Delegate Mode + + /** + * @dev Returns whether anyone is allowed to relay `permit` and + * `delegatedTransfer` calls. When false, only addresses present in + * `delegateWhitelist` are allowed. + */ + function delegateMode() external view returns (bool); + + /** + * @dev Returns whether `account` is whitelisted to relay `permit` and + * `delegatedTransfer` calls. + * @param account The address to query. + */ + function delegateWhitelist(address account) external view returns (bool); + + // View functions - Pause + + /** + * @dev Returns whether all token transfers are currently paused. + */ + function isPaused() external view returns (bool); + + // View functions - Sanctions List and Terms + + /** + * @dev Returns the sanctions list contract used to gate transfers and + * allowance spends. Follows the Chainalysis interface. + */ + function sanctionsList() external view returns (SanctionsList); + + /** + * @dev Returns the current terms-of-service string (typically a web or + * IPFS link). + */ + function terms() external view returns (string memory); + + // State-changing functions - Initialization + + /** + * @dev Initializes the token. Can only be called once per proxy. + * @param name_ The ERC20 token name. + * @param symbol_ The ERC20 token symbol. + */ + function initialize(string memory name_, string memory symbol_) external; + + // State-changing functions - Mint and Burn + + /** + * @dev Mint new tokens. Callable only by `minter`. + * @param account Recipient of the minted tokens. + * @param amount Amount to mint. + */ + function mint(address account, uint256 amount) external; + + /** + * @dev Burn tokens. Callable only by `burner`. The burned tokens must + * come from the burner itself or from this contract. + * @param account Account from which the tokens will be burned. + * @param amount Amount to burn. + */ + function burn(address account, uint256 amount) external; + + // State-changing functions - Pause + + /** + * @dev Pause or unpause all token transfers. Callable only by `pauser`. + * @param newPauseMode True to pause, false to resume. + */ + function setPause(bool newPauseMode) external; + + // State-changing functions - Owner Configuration + + /** + * @dev Set the address authorized to mint tokens. Owner only. + * @param newMinter The new minter address. + */ + function setMinter(address newMinter) external; + + /** + * @dev Set the address authorized to burn tokens. Owner only. + * @param newBurner The new burner address. + */ + function setBurner(address newBurner) external; + + /** + * @dev Set the address authorized to pause transfers. Owner only. + * @param newPauser The new pauser address. + */ + function setPauser(address newPauser) external; + + /** + * @dev Point the contract at a new sanctions-list contract. Owner only. + * The new contract must implement the Chainalysis interface; the + * call probes `isSanctioned(address(this))` to verify. + * @param newSanctionsList The new sanctions list address. + */ + function setSanctionsList(address newSanctionsList) external; + + /** + * @dev Toggle the delegate-relay whitelist status of an address. Owner only. + * @param whitelistAddress The address whose status is changing. + * @param status True to whitelist, false to remove. + */ + function setDelegateWhitelist(address whitelistAddress, bool status) external; + + /** + * @dev Toggle global delegate mode. When true, anyone may relay `permit` + * and `delegatedTransfer` calls. Owner only. + * @param _delegateMode The new delegate-mode flag. + */ + function setDelegateMode(bool _delegateMode) external; + + /** + * @dev Set the terms-of-service string. Owner only. + * @param newTerms The new terms (typically a web or IPFS link). + */ + function setTerms(string memory newTerms) external; + + // Events + + event NewMinter(address indexed newMinter); + event NewBurner(address indexed newBurner); + event NewPauser(address indexed newPauser); + event NewSanctionsList(address indexed newSanctionsList); + event DelegateWhitelistChange(address indexed whitelistAddress, bool status); + event DelegateModeChange(bool delegateMode); + event PauseModeChange(bool pauseMode); + event NewTerms(string newTerms); +} diff --git a/scripts/helpers/upgradeToV4.ts b/scripts/helpers/upgradeToV4.ts new file mode 100644 index 0000000..955316c --- /dev/null +++ b/scripts/helpers/upgradeToV4.ts @@ -0,0 +1,27 @@ +import { ethers } from "hardhat"; +import fs from "fs"; + +async function main() { + const PROXY_ADMIN = process.env.PROXY_ADMIN!; + const TOKEN_PROXY = process.env.TOKEN_PROXY!; + + type RawUpdate = { previousMultiplier: string; newMultiplier: string; activationTime: number }; + const pastUpdates: RawUpdate[] = JSON.parse(fs.readFileSync(process.env.PAST_UPDATES_FILE!, "utf8")); + + const v4Impl = await ( + await ethers.getContractFactory("BackedAutoFeeTokenImplementation") + ).deploy(); + await v4Impl.deployed(); + + const proxyAdmin = await ethers.getContractAt("ProxyAdmin", PROXY_ADMIN); + + const tx = await proxyAdmin.upgradeAndCall( + TOKEN_PROXY, + v4Impl.address, + v4Impl.interface.encodeFunctionData("initialize_v4", [pastUpdates]) + ); + const receipt = await tx.wait(); + console.log(`Upgrade + initialize_v4 mined in block ${receipt.blockNumber} (tx: ${tx.hash})`); +} + +main().catch((e) => { console.error(e); process.exit(1); }); \ No newline at end of file diff --git a/test/BackedAutoFeeTokenImplementation.ts b/test/BackedAutoFeeTokenImplementation.ts index f96a025..9192490 100644 --- a/test/BackedAutoFeeTokenImplementation.ts +++ b/test/BackedAutoFeeTokenImplementation.ts @@ -227,11 +227,41 @@ describe("BackedAutoFeeTokenImplementation", function () { expect(await tokenV2Upgraded.newMultiplierActivationTime()).to.be.equal(0); }); }); + + describe('When called on a proxy without v3 fields populated', () => { + // Simulate a real v2 -> v3 upgrade by upgrading a v1 proxy directly to the v3 + // implementation, leaving the new* storage slots at their zero default. + let tokenFreshV3: BackedAutoFeeTokenImplementation; + + cacheBeforeEach(async () => { + const v1Implementation = await new BackedTokenImplementation__factory(owner.signer).deploy(); + const tokenProxy = await new BackedTokenProxy__factory(owner.signer).deploy( + v1Implementation.address, + proxyAdmin.address, + v1Implementation.interface.encodeFunctionData('initialize', [tokenName, tokenSymbol]) + ); + + const v3Implementation = await new BackedAutoFeeTokenImplementation__factory(owner.signer).deploy(); + await proxyAdmin.upgrade(tokenProxy.address, v3Implementation.address); + + tokenFreshV3 = BackedAutoFeeTokenImplementation__factory.connect(tokenProxy.address, owner.signer); + }); + + it("Should populate newMultiplier and newMultiplierNonce from last* values", async function () { + expect(await tokenFreshV3.newMultiplier()).to.be.equal(0); + + await tokenFreshV3.initialize_v3(); + + expect(await tokenFreshV3.newMultiplier()).to.be.equal(await tokenFreshV3.lastMultiplier()); + expect(await tokenFreshV3.newMultiplierNonce()).to.be.equal(await tokenFreshV3.lastMultiplierNonce()); + expect(await tokenFreshV3.newMultiplierActivationTime()).to.be.equal(0); + }); + }); }) describe('#getCurrentMultiplier', () => { - describe('when time moved by 365 days forward', () => { - const periodsPassed = 365; + describe('when time moved forward', () => { + const periodsPassed = 7; let preMultiplier: BigNumber; let preMultiplierNonce: BigNumber; describe('and fee is set to non-zero value', () => { @@ -387,8 +417,8 @@ describe("BackedAutoFeeTokenImplementation", function () { }) }) describe('#updateMultiplier', () => { - describe('when time moved by 365 days forward', () => { - const periodsPassed = 365; + describe('when time moved forward', () => { + const periodsPassed = 7; const baseMintedAmount = ethers.BigNumber.from(10).pow(18); let mintedShares: BigNumber; cacheBeforeEach(async () => { @@ -505,8 +535,14 @@ describe("BackedAutoFeeTokenImplementation", function () { }); describe('#balanceOf', () => { - it('Should decrease balance of the user by fee accrued in 365 days', async () => { - expect((await token.balanceOf(owner.address)).sub(baseMintedAmount.mul(annualFee * 100).div(100)).abs()).to.lte( + it('Should decrease balance of the user by fee accrued', async () => { + const feePerPeriod = await token.feePerPeriod(); + let cumMult = ethers.BigNumber.from(10).pow(18); + for (let i = 0; i < periodsPassed; i++) { + cumMult = cumMult.mul(ethers.BigNumber.from(10).pow(18).sub(feePerPeriod)).div(ethers.BigNumber.from(10).pow(18)); + } + const expectedBalance = baseMintedAmount.mul(cumMult).div(ethers.BigNumber.from(10).pow(18)); + expect((await token.balanceOf(owner.address)).sub(expectedBalance).abs()).to.lte( BigNumber.from(10).pow(3) ) }) @@ -514,14 +550,22 @@ describe("BackedAutoFeeTokenImplementation", function () { describe('#getSharesByUnderlyingAmount', () => { it('Should increase amount of shares neeeded for given underlying amount', async () => { - const amount = 1000; - expect((await token.getSharesByUnderlyingAmount(amount))).to.eq(ethers.BigNumber.from(amount / annualFee)) + const amount = ethers.BigNumber.from(10).pow(15); + const { currentMultiplier } = await token.getCurrentMultiplier(); + const expectedShares = amount.mul(ethers.BigNumber.from(10).pow(18)).div(currentMultiplier); + expect((await token.getSharesByUnderlyingAmount(amount))).to.eq(expectedShares) }) }); describe('#totalSupply', () => { - it('Should decrease total supply of the token by the fee accrued in 365 days', async () => { - expect((await token.totalSupply()).sub(baseMintedAmount.mul(annualFee * 100).div(100)).abs()).to.lte( + it('Should decrease total supply of the token by the fee accrued', async () => { + const feePerPeriod = await token.feePerPeriod(); + let cumMult = ethers.BigNumber.from(10).pow(18); + for (let i = 0; i < periodsPassed; i++) { + cumMult = cumMult.mul(ethers.BigNumber.from(10).pow(18).sub(feePerPeriod)).div(ethers.BigNumber.from(10).pow(18)); + } + const expectedSupply = baseMintedAmount.mul(cumMult).div(ethers.BigNumber.from(10).pow(18)); + expect((await token.totalSupply()).sub(expectedSupply).abs()).to.lte( BigNumber.from(10).pow(3) ) }) @@ -594,7 +638,10 @@ describe("BackedAutoFeeTokenImplementation", function () { cacheBeforeEach(async () => { userBalance = await token.getUnderlyingAmountByShares(sharesToTransfer); - deadline = baseTime * 2; + // Far enough ahead that the "time moved forward" tests stay within it, + // but close enough that crossing it only elapses a handful of fee + // periods (the updateMultiplier modifier loops once per period). + deadline = baseTime + 8 * accrualPeriodLength; nonce = await token.nonces(owner.address); const domain = { name: await token.name(), @@ -655,8 +702,8 @@ describe("BackedAutoFeeTokenImplementation", function () { await expect(subject()).to.be.reverted; }) - describe('when time moved by 365 days forward', () => { - const periodsPassed = 365; + describe('when time moved forward', () => { + const periodsPassed = 7; cacheBeforeEach(async () => { await helpers.time.setNextBlockTimestamp(baseTime + periodsPassed * accrualPeriodLength); await helpers.mine() @@ -1820,6 +1867,524 @@ describe("BackedAutoFeeTokenImplementation", function () { }); }); + describe('#updateMultiplierWithNonce - outdated nonce', () => { + it('Should revert when called by multiplierUpdater with non-increasing nonce', async () => { + const { currentMultiplier, currentMultiplierNonce } = await token.getCurrentMultiplier(); + await expect( + token.updateMultiplierWithNonce(currentMultiplier, currentMultiplier, currentMultiplierNonce, 0) + ).to.be.revertedWith("BackedToken: Multiplier nonce is outdated."); + }); + }); + + describe('#delegatedTransferShares - expired deadline (multiplier still valid)', () => { + const baseMintedAmount = ethers.BigNumber.from(10).pow(18); + const sharesToTransfer = ethers.BigNumber.from(10).pow(18); + let signature: string; + let deadline: number; + + cacheBeforeEach(async () => { + await token.connect(minter.signer).mint(owner.address, baseMintedAmount); + await token.setDelegateWhitelist(actor.address, true); + + // Use a deadline only slightly in the future so we can pass it without + // letting many fee periods elapse (which would cause the multiplier to + // decay and revert before the deadline check is reached). + deadline = baseTime + 300; + const nonce = await token.nonces(owner.address); + const domain = { + name: await token.name(), + version: "1", + chainId: await owner.signer.getChainId(), + verifyingContract: token.address + }; + const types = { + DELEGATED_TRANSFER_SHARES: [ + { type: 'address', name: 'owner' }, + { type: 'address', name: 'to' }, + { type: 'uint256', name: 'value' }, + { type: 'uint256', name: 'nonce' }, + { type: 'uint256', name: 'deadline' } + ] + }; + const msg = { + owner: owner.address, + to: actor.address, + value: sharesToTransfer, + nonce: nonce, + deadline: deadline + }; + const signer = await ethers.getSigner(owner.address); + signature = await signer._signTypedData(domain, types, msg); + }); + + it('Should revert with expired deadline message', async () => { + await helpers.time.setNextBlockTimestamp(deadline + 1); + await helpers.mine(); + const sig = ethers.utils.splitSignature(signature); + await expect( + token.connect(actor.signer).delegatedTransferShares( + owner.address, actor.address, sharesToTransfer, deadline, sig.v, sig.r, sig.s + ) + ).to.be.revertedWith("ERC20Permit: expired deadline"); + }); + }); + + describe('#delegatedTransferShares - updateMultiplier branch', () => { + // Exercises both sides of the `if (lastMultiplier != currentMultiplier)` + // branch inside the updateMultiplier modifier as reached via + // delegatedTransferShares. + const baseMintedAmount = ethers.BigNumber.from(10).pow(18); + const sharesToTransfer = ethers.BigNumber.from(10).pow(18).div(4); + let signature: string; + let deadline: number; + + const buildSignature = async () => { + deadline = baseTime + 100 * accrualPeriodLength; + const nonce = await token.nonces(owner.address); + const domain = { + name: await token.name(), + version: "1", + chainId: await owner.signer.getChainId(), + verifyingContract: token.address + }; + const types = { + DELEGATED_TRANSFER_SHARES: [ + { type: 'address', name: 'owner' }, + { type: 'address', name: 'to' }, + { type: 'uint256', name: 'value' }, + { type: 'uint256', name: 'nonce' }, + { type: 'uint256', name: 'deadline' } + ] + }; + const msg = { + owner: owner.address, + to: actor.address, + value: sharesToTransfer, + nonce: nonce, + deadline: deadline + }; + const signer = await ethers.getSigner(owner.address); + signature = await signer._signTypedData(domain, types, msg); + }; + + cacheBeforeEach(async () => { + await token.connect(minter.signer).mint(owner.address, baseMintedAmount); + await token.setDelegateWhitelist(actor.address, true); + }); + + it('Should not touch the multiplier when no fee period has elapsed', async () => { + // No full period elapsed since lastTimeFeeApplied: getCurrentMultiplier + // returns the stored value, so the modifier skips _updateMultiplier. + await buildSignature(); + const sig = ethers.utils.splitSignature(signature); + await token.connect(actor.signer).delegatedTransferShares( + owner.address, actor.address, sharesToTransfer, deadline, sig.v, sig.r, sig.s + ); + expect(await token.lastMultiplier()).to.be.equal(await token.multiplier()); + }); + + it('Should update the multiplier when fee periods have elapsed', async () => { + await buildSignature(); + const multiplierBefore = await token.lastMultiplier(); + await helpers.time.setNextBlockTimestamp(baseTime + 10 * accrualPeriodLength); + const sig = ethers.utils.splitSignature(signature); + await token.connect(actor.signer).delegatedTransferShares( + owner.address, actor.address, sharesToTransfer, deadline, sig.v, sig.r, sig.s + ); + expect(await token.lastMultiplier()).to.be.lt(multiplierBefore); + }); + }); + + describe('#updateMultiplier modifier - multiplier decays to zero', () => { + // After raising the fee to (1e18 - 1) and letting a few periods pass, + // the multiplier rounds down to zero in the modifier's update loop. + // The next call through the modifier then reverts inside _updateMultiplier, + // which exercises the failure path of the updateMultiplier modifier + // for each function that uses it. + const maxFee = ethers.BigNumber.from(10).pow(18).sub(1); + const periodsToCollapse = 5; + + cacheBeforeEach(async () => { + await token.updateFeePerPeriod(maxFee); + await token.connect(minter.signer).mint(owner.address, ethers.BigNumber.from(10).pow(18)); + await token.approve(actor.address, ethers.BigNumber.from(10).pow(18)); + await helpers.time.setNextBlockTimestamp(baseTime + periodsToCollapse * accrualPeriodLength); + await helpers.mine(); + }); + + it('transferShares should revert', async () => { + await expect( + token.transferShares(actor.address, 1) + ).to.be.revertedWith("BackedToken: Multiplier cannot be zero"); + }); + + it('transferSharesFrom should revert', async () => { + await expect( + token.connect(actor.signer).transferSharesFrom(owner.address, tmpAccount.address, 1) + ).to.be.revertedWith("BackedToken: Multiplier cannot be zero"); + }); + + it('setLastTimeFeeApplied should revert', async () => { + await expect( + token.setLastTimeFeeApplied(baseTime + 1) + ).to.be.revertedWith("BackedToken: Multiplier cannot be zero"); + }); + + it('setPeriodLength should revert', async () => { + await expect( + token.setPeriodLength(accrualPeriodLength * 2) + ).to.be.revertedWith("BackedToken: Multiplier cannot be zero"); + }); + + it('updateMultiplierValue should revert', async () => { + await expect( + token.updateMultiplierValue(1, 0, 0) + ).to.be.revertedWith("BackedToken: Multiplier cannot be zero"); + }); + + it('updateMultiplierWithNonce should revert', async () => { + await expect( + token.updateMultiplierWithNonce(1, 0, 1, 0) + ).to.be.revertedWith("BackedToken: Multiplier cannot be zero"); + }); + + it('mint (via _beforeTokenTransfer) should revert', async () => { + // _mint calls _beforeTokenTransfer (which carries the updateMultiplier + // modifier) before any shares math, so this exercises the modifier's + // failure path at the _beforeTokenTransfer invocation site. + await expect( + token.connect(minter.signer).mint(owner.address, 1) + ).to.be.revertedWith("BackedToken: Multiplier cannot be zero"); + }); + + it('delegatedTransferShares should revert', async () => { + // The updateMultiplier modifier runs (and reverts) before the function + // body, so the signature is never reached and can be a dummy value. + await token.setDelegateWhitelist(actor.address, true); + const dummy = ethers.utils.hexZeroPad("0x01", 32); + await expect( + token.connect(actor.signer).delegatedTransferShares( + owner.address, tmpAccount.address, 1, baseTime * 2, 27, dummy, dummy + ) + ).to.be.revertedWith("BackedToken: Multiplier cannot be zero"); + }); + }); + + describe('#decimals', () => { + it('Should return 18 by default', async () => { + expect(await token.decimals()).to.be.equal(18); + }); + }); + + describe('#_updateMultiplier - invalid activation time', () => { + it('updateMultiplierValue should revert when activation time is at/after next period boundary', async () => { + const { currentMultiplier } = await token.getCurrentMultiplier(); + const newMult = currentMultiplier.mul(110).div(100); + const lastTimeFeeApplied = await token.lastTimeFeeApplied(); + const periodLength = await token.periodLength(); + const tooLate = lastTimeFeeApplied.add(periodLength); // == lastTimeFeeApplied + periodLength + await expect( + token.updateMultiplierValue(newMult, currentMultiplier, tooLate) + ).to.be.revertedWith("BackedToken: Activation time needs to be before next period"); + }); + + it('updateMultiplierWithNonce should revert when activation time is at/after next period boundary', async () => { + const { currentMultiplier, currentMultiplierNonce } = await token.getCurrentMultiplier(); + const newMult = currentMultiplier.mul(110).div(100); + const lastTimeFeeApplied = await token.lastTimeFeeApplied(); + const periodLength = await token.periodLength(); + const tooLate = lastTimeFeeApplied.add(periodLength).add(1); + await expect( + token.updateMultiplierWithNonce(newMult, currentMultiplier, currentMultiplierNonce.add(1), tooLate) + ).to.be.revertedWith("BackedToken: Activation time needs to be before next period"); + }); + }); + + describe('#multiplierUpdates', () => { + describe('Initial state', () => { + it('Should have one initial entry after initialization', async () => { + expect(await token.multiplierUpdatesLength()).to.be.equal(1); + }); + + it('Should have correct initial values', async () => { + const initialUpdate = await token.multiplierUpdates(0); + expect(initialUpdate.previousMultiplier).to.be.equal(ethers.BigNumber.from(10).pow(18)); + expect(initialUpdate.newMultiplier).to.be.equal(ethers.BigNumber.from(10).pow(18)); + expect(initialUpdate.activationTime).to.be.equal(0); + }); + }); + + describe('When updating multiplier immediately', () => { + cacheBeforeEach(async () => { + const currentMult = await token.multiplier(); + const newMult = currentMult.mul(95).div(100); // 5% decrease + await token.updateMultiplierValue(newMult, currentMult, 0); + }); + + it('Should add new entry to multiplierUpdates', async () => { + expect(await token.multiplierUpdatesLength()).to.be.equal(2); + }); + + it('Should store correct values in new entry', async () => { + const update = await token.multiplierUpdates(1); + const currentTime = await helpers.time.latest(); + expect(update.previousMultiplier).to.be.equal(ethers.BigNumber.from(10).pow(18)); + expect(update.newMultiplier).to.be.equal(ethers.BigNumber.from(10).pow(18).mul(95).div(100)); + expect(update.activationTime).to.be.equal(currentTime); + }); + }); + + describe('When updating multiplier with future activation', () => { + const futureTime = baseTime + accrualPeriodLength / 2; + + cacheBeforeEach(async () => { + const currentMult = await token.multiplier(); + const newMult = currentMult.mul(110).div(100); + await token.updateMultiplierValue(newMult, currentMult, futureTime); + }); + + it('Should add new entry with future activation time', async () => { + expect(await token.multiplierUpdatesLength()).to.be.equal(2); + const update = await token.multiplierUpdates(1); + expect(update.activationTime).to.be.equal(futureTime); + }); + + it('Should emit MultiplierScheduled event', async () => { + // This test is within a nested context where futureTime is already set + // and multiplier update was already done in the cacheBeforeEach + // Let's verify the event was already emitted in that transaction + const update = await token.multiplierUpdates(1); + expect(update.activationTime).to.be.equal(futureTime); + + // The MultiplierScheduled event was emitted in the cacheBeforeEach + // We can verify by checking that a scheduled update exists + expect(await token.newMultiplierActivationTime()).to.be.equal(futureTime); + }); + + describe('And then updating again before activation', () => { + it('Should overwrite previous pending update', async () => { + const currentMult = await token.multiplier(); + const newerMult = currentMult.mul(120).div(100); + const newerTime = baseTime + accrualPeriodLength / 4; + + await token.updateMultiplierValue(newerMult, currentMult, newerTime); + + // Should still have 2 entries (initial + current), previous pending was overwritten + expect(await token.multiplierUpdatesLength()).to.be.equal(2); + + const lastUpdate = await token.multiplierUpdates(1); + expect(lastUpdate.newMultiplier).to.be.equal(newerMult); + expect(lastUpdate.activationTime).to.be.equal(newerTime); + }); + }); + + describe('And then updating after activation', () => { + it('Should add new entry after previous one activates', async () => { + // Move time to activation + await helpers.time.setNextBlockTimestamp(futureTime); + await token.transfer(actor.address, 1); + + const currentMult = await token.multiplier(); + const anotherNewMult = currentMult.mul(105).div(100); + await token.updateMultiplierValue(anotherNewMult, currentMult, 0); + + // Should have 3 entries now + expect(await token.multiplierUpdatesLength()).to.be.equal(3); + }); + }); + }); + + describe('Multiple sequential updates', () => { + it('Should track history of all activated multiplier updates', async () => { + // Mint some tokens first to have balance for transfers + await token.connect(minter.signer).mint(owner.address, ethers.BigNumber.from(10).pow(20)); + + let currentMult = await token.multiplier(); + + // First update + const newMult1 = currentMult.mul(95).div(100); + await token.updateMultiplierValue(newMult1, currentMult, 0); + + // Move time forward + await helpers.time.setNextBlockTimestamp(baseTime + accrualPeriodLength); + await token.transfer(actor.address, ethers.BigNumber.from(10).pow(18)); + + // Second update + currentMult = await token.multiplier(); + const newMult2 = currentMult.mul(98).div(100); + await token.updateMultiplierValue(newMult2, currentMult, 0); + + // Should have 3 entries total + expect(await token.multiplierUpdatesLength()).to.be.equal(3); + + // Verify entries + const update1 = await token.multiplierUpdates(1); + const update2 = await token.multiplierUpdates(2); + + expect(update1.previousMultiplier).to.be.equal(ethers.BigNumber.from(10).pow(18)); + expect(update2.previousMultiplier).to.not.equal(ethers.BigNumber.from(10).pow(18)); + }); + }); + }); + + describe('#initialize_v4', () => { + describe('When called on already initialized v4 contract', () => { + it('Should revert', async () => { + // Current token already has multiplierUpdates initialized + await expect( + token.initialize_v4([]) + ).to.be.revertedWith("BackedAutoFeeTokenImplementation v4 already initialized"); + }); + }); + + describe('Initialize_v4 behavior validation', () => { + it('Should only allow initialization when array is empty', async () => { + // The initialize_v4 check: multiplierUpdates.length == 0 + // This means it's designed for contracts that were deployed before v4 + const currentLength = await token.multiplierUpdatesLength(); + expect(currentLength).to.be.equal(1); // Already initialized with one entry + + // Attempting to call it when array is not empty should revert + await expect(token.initialize_v4([])).to.be.revertedWith( + "BackedAutoFeeTokenImplementation v4 already initialized" + ); + }); + }); + + describe('When called on a proxy without multiplierUpdates populated', () => { + // Simulate a real pre-v4 -> v4 upgrade by upgrading a v1 proxy directly to + // the v4 implementation, leaving the multiplierUpdates array empty. + let tokenFreshV4: BackedAutoFeeTokenImplementation; + const oneE18 = ethers.BigNumber.from(10).pow(18); + + cacheBeforeEach(async () => { + const v1Implementation = await new BackedTokenImplementation__factory(owner.signer).deploy(); + const tokenProxy = await new BackedTokenProxy__factory(owner.signer).deploy( + v1Implementation.address, + proxyAdmin.address, + v1Implementation.interface.encodeFunctionData('initialize', [tokenName, tokenSymbol]) + ); + + const v4Implementation = await new BackedAutoFeeTokenImplementation__factory(owner.signer).deploy(); + await proxyAdmin.upgrade(tokenProxy.address, v4Implementation.address); + + tokenFreshV4 = BackedAutoFeeTokenImplementation__factory.connect(tokenProxy.address, owner.signer); + }); + + it('Should store only the genesis sentinel when no past updates are provided', async () => { + expect(await tokenFreshV4.multiplierUpdatesLength()).to.be.equal(0); + + await tokenFreshV4.initialize_v4([]); + + expect(await tokenFreshV4.multiplierUpdatesLength()).to.be.equal(1); + const genesis = await tokenFreshV4.multiplierUpdates(0); + expect(genesis.previousMultiplier).to.be.equal(oneE18); + expect(genesis.newMultiplier).to.be.equal(oneE18); + expect(genesis.activationTime).to.be.equal(0); + }); + + it('Should backfill provided past multiplier updates after the genesis sentinel', async () => { + const pastUpdates = [ + { + previousMultiplier: oneE18, + newMultiplier: ethers.BigNumber.from('990000000000000000'), + activationTime: 1_000, + }, + { + previousMultiplier: ethers.BigNumber.from('990000000000000000'), + newMultiplier: ethers.BigNumber.from('980000000000000000'), + activationTime: 2_000, + }, + ]; + + await tokenFreshV4.initialize_v4(pastUpdates); + + expect(await tokenFreshV4.multiplierUpdatesLength()).to.be.equal(3); // genesis + 2 + + for (let i = 0; i < pastUpdates.length; i++) { + const stored = await tokenFreshV4.multiplierUpdates(i + 1); + expect(stored.previousMultiplier).to.be.equal(pastUpdates[i].previousMultiplier); + expect(stored.newMultiplier).to.be.equal(pastUpdates[i].newMultiplier); + expect(stored.activationTime).to.be.equal(pastUpdates[i].activationTime); + } + }); + + it('Should revert when a past update has a zero previousMultiplier', async () => { + const pastUpdates = [ + { + previousMultiplier: 0, + newMultiplier: ethers.BigNumber.from('990000000000000000'), + activationTime: 1_000, + }, + ]; + + await expect(tokenFreshV4.initialize_v4(pastUpdates)).to.be.revertedWith( + "BackedAutoFeeTokenImplementation: previousMultiplier cannot be zero" + ); + }); + }); + }); + + describe('#multiplierUpdatesLength', () => { + it('Should return correct length', async () => { + const length = await token.multiplierUpdatesLength(); + expect(length).to.be.equal(1); + }); + + it('Should increment after each multiplier update', async () => { + const initialLength = await token.multiplierUpdatesLength(); + + const currentMult = await token.multiplier(); + const newMult = currentMult.mul(98).div(100); + await token.updateMultiplierValue(newMult, currentMult, 0); + + const newLength = await token.multiplierUpdatesLength(); + expect(newLength).to.be.equal(initialLength.add(1)); + }); + }); + + describe('#MultiplierScheduled event', () => { + it('Should emit MultiplierScheduled when setting future activation', async () => { + const currentMult = await token.multiplier(); + const newMult = currentMult.mul(105).div(100); + const futureTime = baseTime + accrualPeriodLength / 2; + + const tx = token.updateMultiplierValue(newMult, currentMult, futureTime); + + await expect(tx) + .to.emit(token, "MultiplierScheduled") + .withArgs(newMult, futureTime); + }); + + it('Should emit MultiplierUpdated (not MultiplierScheduled) when activating immediately', async () => { + const currentMult = await token.multiplier(); + const newMult = currentMult.mul(105).div(100); + + const tx = token.updateMultiplierValue(newMult, currentMult, 0); + + await expect(tx) + .to.emit(token, "MultiplierUpdated") + .withArgs(newMult); + + await expect(tx) + .to.not.emit(token, "MultiplierScheduled"); + }); + + it('Should emit MultiplierScheduled when using updateMultiplierWithNonce', async () => { + const currentMult = await token.multiplier(); + const currentNonce = await token.multiplierNonce(); + const newMult = currentMult.mul(105).div(100); + const newNonce = currentNonce.add(10); + const futureTime = baseTime + accrualPeriodLength / 2; + + const tx = token.updateMultiplierWithNonce(newMult, currentMult, newNonce, futureTime); + + await expect(tx) + .to.emit(token, "MultiplierScheduled") + .withArgs(newMult, futureTime); + }); + }); + }); function nthRoot(annualFee: number, n: number) { return Decimal.pow(1 - annualFee, new Decimal(1).div(n)); diff --git a/test/BackedTokenImplementation.ts b/test/BackedTokenImplementation.ts index 2a22666..4495c94 100644 --- a/test/BackedTokenImplementation.ts +++ b/test/BackedTokenImplementation.ts @@ -698,4 +698,8 @@ describe("BackedToken", function () { token.connect(tmpAccount.signer).setTerms("Random Terms") ).to.be.revertedWith("Ownable: caller is not the owner"); }); + + it("Returns ERC20 default decimals", async function () { + expect(await token.decimals()).to.equal(18); + }); }); diff --git a/test/Upgradability.ts b/test/Upgradability.ts index 94ac90b..1d523df 100644 --- a/test/Upgradability.ts +++ b/test/Upgradability.ts @@ -280,3 +280,113 @@ describe("Upgrade from v1.1.0 to auto fee", () => { expect(await tokenV2.balanceOf(tmpAccount.address)).to.equal(150); }); }); + +describe("Upgrade to v4 (multiplier history migration)", () => { + let v4Impl: BackedAutoFeeTokenImplementation; + let token: BackedAutoFeeTokenImplementation; + let tokenV1: BackedTokenImplementation; + let v1Factory: BackedFactory; + + let owner: SignerWithAddress; + let minter: SignerWithAddress; + let burner: SignerWithAddress; + let pauser: SignerWithAddress; + let blacklister: SignerWithAddress; + let attacker: SignerWithAddress; + let sanctionsList: SanctionsListMock; + + const tokenName = "Wrapped Apple"; + const tokenSymbol = "WAAPL"; + + const past = [ + { previousMultiplier: "1000000000000000000", newMultiplier: "999000000000000000", activationTime: 1700000000 }, + { previousMultiplier: "999000000000000000", newMultiplier: "998000000000000000", activationTime: 1700086400 }, + ]; + + beforeEach(async () => { + owner = await getSigner(0); + minter = await getSigner(1); + burner = await getSigner(2); + pauser = await getSigner(3); + blacklister = await getSigner(4); + attacker = await getSigner(5); + + // Deploy a v1.1.0 token proxy (its multiplierUpdates array is empty, + // which is the precondition initialize_v4 requires). + v1Factory = await ( + await ethers.getContractFactory("BackedFactory") + ).deploy(owner.address); + + sanctionsList = await ( + await ethers.getContractFactory("SanctionsListMock", blacklister.signer) + ).deploy(); + + const receipt = await ( + await v1Factory.deployToken( + tokenName, + tokenSymbol, + owner.address, + minter.address, + burner.address, + pauser.address, + sanctionsList.address + ) + ).wait(); + + const deployedTokenAddress = receipt.events?.find( + (event: any) => event.event === "NewToken" + )?.args?.newToken; + + tokenV1 = await ethers.getContractAt( + "BackedTokenImplementation", + deployedTokenAddress + ); + + v4Impl = await ( + await ethers.getContractFactory("BackedAutoFeeTokenImplementation") + ).deploy(); + await v4Impl.deployed(); + }); + + it("atomic upgradeAndCall backfills history and is one-shot", async () => { + const proxyAdmin = await ethers.getContractAt( + "ProxyAdmin", + await v1Factory.proxyAdmin() + ); + + // Atomic: flip implementation AND run initialize_v4 in a single tx. + await proxyAdmin.upgradeAndCall( + tokenV1.address, + v4Impl.address, + v4Impl.interface.encodeFunctionData('initialize_v4', [past]) + ); + + token = await ethers.getContractAt( + "BackedAutoFeeTokenImplementation", + tokenV1.address + ); + + // Genesis sentinel at index 0 + the two backfilled entries. + expect(await token.multiplierUpdatesLength()).to.equal(3); + + const sentinel = await token.multiplierUpdates(0); + expect(sentinel.previousMultiplier).to.equal("1000000000000000000"); + expect(sentinel.newMultiplier).to.equal("1000000000000000000"); + expect(sentinel.activationTime).to.equal(0); + + const first = await token.multiplierUpdates(1); + expect(first.previousMultiplier).to.equal(past[0].previousMultiplier); + expect(first.newMultiplier).to.equal(past[0].newMultiplier); + expect(first.activationTime).to.equal(past[0].activationTime); + + const second = await token.multiplierUpdates(2); + expect(second.previousMultiplier).to.equal(past[1].previousMultiplier); + expect(second.newMultiplier).to.equal(past[1].newMultiplier); + expect(second.activationTime).to.equal(past[1].activationTime); + + // One-shot: any later call (any caller) reverts. + await expect(token.initialize_v4([])).to.be.revertedWith( + "BackedAutoFeeTokenImplementation v4 already initialized" + ); + }); +}); \ No newline at end of file diff --git a/test/WrappedBackedTokenFactory.ts b/test/WrappedBackedTokenFactory.ts new file mode 100644 index 0000000..9c25078 --- /dev/null +++ b/test/WrappedBackedTokenFactory.ts @@ -0,0 +1,399 @@ +/* eslint-disable camelcase */ +/* eslint-disable prettier/prettier */ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { Signer } from "ethers"; +import * as helpers from "@nomicfoundation/hardhat-network-helpers"; +import Decimal from "decimal.js"; + +import { + BackedAutoFeeTokenImplementation, + BackedAutoFeeTokenImplementation__factory, + BackedTokenProxy__factory, + ProxyAdmin, + ProxyAdmin__factory, + SanctionsListMock, + SanctionsListMock__factory, + WrappedBackedTokenFactory, + WrappedBackedTokenFactory__factory, + WrappedBackedTokenImplementation, + WrappedBackedTokenImplementation__factory, +} from "../typechain"; + +type SignerWithAddress = { + signer: Signer; + address: string; +}; + +describe("WrappedBackedTokenFactory", function () { + const annualFee = 0.5; + const multiplierAdjustmentPerPeriod = nthRoot(annualFee, 365).mul( + Decimal.pow(10, 18) + ); + const baseFeePerPeriod = Decimal.pow(10, 18) + .minus(multiplierAdjustmentPerPeriod) + .toFixed(0); + const baseTime = 2_200_000_000; + + const tokenName = "Backed Apple"; + const tokenSymbol = "bAAPL"; + const wrappedTokenName = `Wrapped ${tokenName}`; + const wrappedTokenSymbol = `w${tokenSymbol}`; + + let factory: WrappedBackedTokenFactory; + let wrappedImplementation: WrappedBackedTokenImplementation; + let underlying: BackedAutoFeeTokenImplementation; + let underlyingProxyAdmin: ProxyAdmin; + let sanctionsList: SanctionsListMock; + + let owner: SignerWithAddress; + let proxyAdminOwner: SignerWithAddress; + let tokenOwner: SignerWithAddress; + let pauser: SignerWithAddress; + let other: SignerWithAddress; + + beforeEach(async () => { + const accounts = await ethers.getSigners(); + const getSigner = async (i: number): Promise => ({ + signer: accounts[i], + address: await accounts[i].getAddress(), + }); + + owner = await getSigner(0); + proxyAdminOwner = await getSigner(1); + tokenOwner = await getSigner(2); + pauser = await getSigner(3); + other = await getSigner(4); + + await helpers.time.setNextBlockTimestamp(baseTime); + + // Deploy underlying auto-fee token (used as the wrapped asset). + const underlyingImpl = await new BackedAutoFeeTokenImplementation__factory( + owner.signer + ).deploy(); + underlyingProxyAdmin = await new ProxyAdmin__factory(owner.signer).deploy(); + const underlyingProxy = await new BackedTokenProxy__factory( + owner.signer + ).deploy( + underlyingImpl.address, + underlyingProxyAdmin.address, + underlyingImpl.interface.encodeFunctionData( + "initialize(string,string,uint256,uint256,uint256)", + [tokenName, tokenSymbol, 24 * 3600, baseTime, baseFeePerPeriod] + ) + ); + underlying = BackedAutoFeeTokenImplementation__factory.connect( + underlyingProxy.address, + owner.signer + ); + + sanctionsList = await new SanctionsListMock__factory(owner.signer).deploy(); + + wrappedImplementation = await new WrappedBackedTokenImplementation__factory( + owner.signer + ).deploy(); + + factory = await new WrappedBackedTokenFactory__factory(owner.signer).deploy( + proxyAdminOwner.address + ); + }); + + afterEach(async () => { + await helpers.reset(); + }); + + const buildConfig = (overrides: Partial<{ + name: string; + symbol: string; + underlying: string; + tokenOwner: string; + pauser: string; + }> = {}) => ({ + name: wrappedTokenName, + symbol: wrappedTokenSymbol, + underlying: underlying.address, + tokenOwner: tokenOwner.address, + pauser: pauser.address, + ...overrides, + }); + + describe("#constructor", () => { + it("should set deployer as factory owner", async () => { + expect(await factory.owner()).to.equal(owner.address); + }); + + it("should deploy a ProxyAdmin owned by proxyAdminOwner", async () => { + const proxyAdmin = await ethers.getContractAt( + "ProxyAdmin", + await factory.proxyAdmin() + ); + expect(await proxyAdmin.owner()).to.equal(proxyAdminOwner.address); + }); + + it("should expose the deployed ProxyAdmin via proxyAdmin()", async () => { + const proxyAdminAddress = await factory.proxyAdmin(); + expect(proxyAdminAddress).to.match(/^0x[a-fA-F\d]{40}$/); + expect(proxyAdminAddress).to.not.equal(ethers.constants.AddressZero); + }); + + it("should leave wrappedTokenImplementation unset by default", async () => { + expect(await factory.wrappedTokenImplementation()).to.equal( + ethers.constants.AddressZero + ); + }); + + it("should revert when proxyAdminOwner is zero address", async () => { + await expect( + new WrappedBackedTokenFactory__factory(owner.signer).deploy( + ethers.constants.AddressZero + ) + ).to.be.revertedWith("Factory: address should not be 0"); + }); + }); + + describe("#updateImplementation", () => { + it("should set the implementation and emit NewImplementation", async () => { + await expect( + factory.updateImplementation(wrappedImplementation.address) + ) + .to.emit(factory, "NewImplementation") + .withArgs(wrappedImplementation.address); + + expect(await factory.wrappedTokenImplementation()).to.equal( + wrappedImplementation.address + ); + }); + + it("should allow swapping the implementation", async () => { + await factory.updateImplementation(wrappedImplementation.address); + + const newImpl = await new WrappedBackedTokenImplementation__factory( + owner.signer + ).deploy(); + + await expect(factory.updateImplementation(newImpl.address)) + .to.emit(factory, "NewImplementation") + .withArgs(newImpl.address); + + expect(await factory.wrappedTokenImplementation()).to.equal( + newImpl.address + ); + }); + + it("should revert when implementation is zero address", async () => { + await expect( + factory.updateImplementation(ethers.constants.AddressZero) + ).to.be.revertedWith("Factory: address should not be 0"); + }); + + it("should revert when called by non-owner", async () => { + await expect( + factory + .connect(other.signer) + .updateImplementation(wrappedImplementation.address) + ).to.be.revertedWith("Ownable: caller is not the owner"); + }); + }); + + describe("#deployToken", () => { + beforeEach(async () => { + await factory.updateImplementation(wrappedImplementation.address); + }); + + const deploy = async (overrides = {}) => { + const tx = await factory.deployToken(buildConfig(overrides)); + const receipt = await tx.wait(); + const event = receipt.events?.find((e) => e.event === "NewToken"); + return { + receipt, + event, + address: event?.args?.newToken as string, + }; + }; + + it("should emit NewToken with the deployed proxy address, name and symbol", async () => { + const { event, address } = await deploy(); + + expect(event).to.not.be.undefined; + expect(address).to.match(/^0x[a-fA-F\d]{40}$/); + expect(event?.args?.name).to.equal(wrappedTokenName); + expect(event?.args?.symbol).to.equal(wrappedTokenSymbol); + }); + + it("should initialize the wrapped token state", async () => { + const { address } = await deploy(); + const wrapped = WrappedBackedTokenImplementation__factory.connect( + address, + owner.signer + ); + + expect(await wrapped.name()).to.equal(wrappedTokenName); + expect(await wrapped.symbol()).to.equal(wrappedTokenSymbol); + expect(await wrapped.asset()).to.equal(underlying.address); + expect(await wrapped.decimals()).to.equal(await underlying.decimals()); + }); + + it("should configure roles and ownership", async () => { + const { address } = await deploy(); + const wrapped = WrappedBackedTokenImplementation__factory.connect( + address, + owner.signer + ); + + expect(await wrapped.owner()).to.equal(tokenOwner.address); + expect(await wrapped.pauser()).to.equal(pauser.address); + }); + + it("should hand the proxy admin role to the factory's ProxyAdmin", async () => { + const { address } = await deploy(); + const proxyAdminAddress = await factory.proxyAdmin(); + const proxyAdmin = await ethers.getContractAt( + "ProxyAdmin", + proxyAdminAddress + ); + + expect(await proxyAdmin.getProxyAdmin(address)).to.equal( + proxyAdminAddress + ); + expect(await proxyAdmin.getProxyImplementation(address)).to.equal( + wrappedImplementation.address + ); + }); + + it("should let the ProxyAdmin owner upgrade the deployed token", async () => { + const { address } = await deploy(); + const proxyAdmin = await ethers.getContractAt( + "ProxyAdmin", + await factory.proxyAdmin() + ); + + const newImpl = await new WrappedBackedTokenImplementation__factory( + owner.signer + ).deploy(); + + await proxyAdmin + .connect(proxyAdminOwner.signer) + .upgrade(address, newImpl.address); + + expect(await proxyAdmin.getProxyImplementation(address)).to.equal( + newImpl.address + ); + + // Storage must survive the upgrade. + const wrapped = WrappedBackedTokenImplementation__factory.connect( + address, + owner.signer + ); + expect(await wrapped.name()).to.equal(wrappedTokenName); + expect(await wrapped.owner()).to.equal(tokenOwner.address); + }); + + it("should revert when redeploying with the same name/symbol/underlying (CREATE2 salt collision)", async () => { + const config = buildConfig(); + await factory.deployToken(config); + await expect(factory.deployToken(config)).to.be.reverted; + }); + + it("should allow deploying multiple tokens with different inputs", async () => { + const { address: a1 } = await deploy({ symbol: "wbAAPL1" }); + const { address: a2 } = await deploy({ symbol: "wbAAPL2" }); + expect(a1).to.not.equal(a2); + }); + + it("should let the configured pauser pause the freshly-deployed token", async () => { + const { address } = await deploy(); + const wrapped = WrappedBackedTokenImplementation__factory.connect( + address, + owner.signer + ); + + await expect(wrapped.connect(pauser.signer).setPause(true)) + .to.emit(wrapped, "PauseModeChange") + .withArgs(true); + expect(await wrapped.isPaused()).to.equal(true); + }); + + it("should leave the factory unable to call owner-only functions on the new token", async () => { + const { address } = await deploy(); + const wrapped = WrappedBackedTokenImplementation__factory.connect( + address, + owner.signer + ); + + // Ownership was transferred to tokenOwner; the deployer (and even the + // factory itself) cannot reconfigure the token. + await expect( + wrapped.connect(owner.signer).setPauser(other.address) + ).to.be.revertedWith("Ownable: caller is not the owner"); + }); + + it("should revert when tokenOwner is zero address", async () => { + await expect( + factory.deployToken( + buildConfig({ tokenOwner: ethers.constants.AddressZero }) + ) + ).to.be.revertedWith("Factory: address should not be 0"); + }); + + it("should revert when underlying is zero address", async () => { + await expect( + factory.deployToken( + buildConfig({ underlying: ethers.constants.AddressZero }) + ) + ).to.be.revertedWith("Factory: address should not be 0"); + }); + + it("should revert when pauser is zero address", async () => { + await expect( + factory.deployToken( + buildConfig({ pauser: ethers.constants.AddressZero }) + ) + ).to.be.revertedWith("Factory: address should not be 0"); + }); + + it("should revert when called by non-owner", async () => { + await expect( + factory.connect(other.signer).deployToken(buildConfig()) + ).to.be.revertedWith("Ownable: caller is not the owner"); + }); + + it("should revert when implementation has not been set", async () => { + // Use a fresh factory without an implementation configured. + const fresh = await new WrappedBackedTokenFactory__factory( + owner.signer + ).deploy(proxyAdminOwner.address); + + await expect(fresh.deployToken(buildConfig())).to.be.reverted; + }); + }); + + describe("end-to-end flow", () => { + it("supports a full deposit roundtrip on a factory-deployed token", async () => { + await factory.updateImplementation(wrappedImplementation.address); + + const tx = await factory.deployToken(buildConfig()); + const receipt = await tx.wait(); + const tokenAddr = receipt.events?.find((e) => e.event === "NewToken") + ?.args?.newToken as string; + + const wrapped = WrappedBackedTokenImplementation__factory.connect( + tokenAddr, + owner.signer + ); + + // Wire up the underlying so the deposit path can run. + await underlying.setMinter(owner.address); + await underlying.setSanctionsList(sanctionsList.address); + const amount = ethers.BigNumber.from(10).pow(18).mul(100); + await underlying.mint(owner.address, amount); + await underlying.approve(wrapped.address, amount); + + await wrapped.deposit(amount, owner.address); + expect(await wrapped.balanceOf(owner.address)).to.be.gt(0); + }); + }); +}); + +function nthRoot(annualFee: number, n: number) { + return Decimal.pow(1 - annualFee, new Decimal(1).div(n)); +} diff --git a/test/WrappedBackedTokenImplementation.ts b/test/WrappedBackedTokenImplementation.ts index 2b1c6a4..392b7ff 100644 --- a/test/WrappedBackedTokenImplementation.ts +++ b/test/WrappedBackedTokenImplementation.ts @@ -112,7 +112,6 @@ describe("WrappedBackedTokenImplementation", function () { ] ) )).address, owner.signer) - await wrapped.setSanctionsList(sanctionsList.address); // Chain Id @@ -144,12 +143,104 @@ describe("WrappedBackedTokenImplementation", function () { }) }); + describe('#version', () => { + it("should return correct version", async () => { + expect(await wrapped.VERSION()).to.equal("1.0.0"); + }); + }); + + describe('ERC4626 functionality', () => { + describe('#asset', () => { + it("should return underlying token address", async () => { + expect(await wrapped.asset()).to.equal(token.address); + }); + }); + + describe('#totalAssets', () => { + it("should return total underlying tokens", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + await wrapped.deposit(depositAmount, owner.address); + + const totalAssets = await wrapped.totalAssets(); + const expectedShares = await token.sharesOf(wrapped.address); + const expectedAssets = await token.getUnderlyingAmountByShares(expectedShares); + + expect(totalAssets).to.equal(expectedAssets); + }); + }); + + describe('#convertToShares', () => { + it("should convert assets to shares correctly", async () => { + const assets = BigNumber.from(1000); + const shares = await wrapped.convertToShares(assets); + + const currentMultiplier = (await token.getCurrentMultiplier())[0]; + const expectedShares = assets.mul(BigNumber.from(10).pow(18)).div(currentMultiplier); + + expect(shares).to.equal(expectedShares); + }); + }); + + describe('#convertToAssets', () => { + it("should convert shares to assets correctly", async () => { + const shares = BigNumber.from(1000); + const assets = await wrapped.convertToAssets(shares); + + const currentMultiplier = (await token.getCurrentMultiplier())[0]; + const expectedAssets = shares.mul(currentMultiplier).div(BigNumber.from(10).pow(18)); + + expect(assets).to.equal(expectedAssets); + }); + }); + + describe('#maxDeposit', () => { + it("should return max uint256", async () => { + expect(await wrapped.maxDeposit(owner.address)).to.equal(ethers.constants.MaxUint256); + }); + }); + + describe('#maxMint', () => { + it("should return max uint256", async () => { + expect(await wrapped.maxMint(owner.address)).to.equal(ethers.constants.MaxUint256); + }); + }); + + describe('#maxWithdraw', () => { + it("should return owner's asset balance", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + await wrapped.deposit(depositAmount, owner.address); + + const maxWithdraw = await wrapped.maxWithdraw(owner.address); + const balance = await wrapped.balanceOf(owner.address); + const assets = await wrapped.convertToAssets(balance); + + expect(maxWithdraw).to.equal(assets); + }); + }); + + describe('#maxRedeem', () => { + it("should return owner's share balance", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + await wrapped.deposit(depositAmount, owner.address); + + const maxRedeem = await wrapped.maxRedeem(owner.address); + const balance = await wrapped.balanceOf(owner.address); + + expect(maxRedeem).to.equal(balance); + }); + }); + }); + describe('When wrapping rebasing token', () => { const initialBalance = BigNumber.from(1000); cacheBeforeEach(async () => { await token.approve(wrapped.address, initialBalance); await wrapped.mint(1000, owner.address); }) + describe('When rebasing token increases multiplier by 10%', () => { const multiplierIncreasePercentage = 10; @@ -167,11 +258,13 @@ describe("WrappedBackedTokenImplementation", function () { expect(balance).to.eq(initialBalance); }) + it("should increase user underlying balance by 10%", async () => { const assetsBalance = await wrapped.convertToAssets(await wrapped.balanceOf(owner.address)); expect(assetsBalance.toNumber()).to.be.approximately(initialBalance.mul(100 + multiplierIncreasePercentage).div(100).toNumber(), 1); }) + describe('When minting new tokens', () => { it("should require 10% more tokens to mint same amount of wrapper", async () => { await token.transfer(actor.address, 1100); @@ -185,6 +278,7 @@ describe("WrappedBackedTokenImplementation", function () { expect(balance.toNumber()).to.be.eq(initialBalance); }) }); + describe('When burning tokens', () => { it("should return 10% more tokens than the ones used for mint", async () => { await wrapped.redeem(initialBalance, actor.address, owner.address); @@ -195,11 +289,162 @@ describe("WrappedBackedTokenImplementation", function () { }) }); }); + + describe('When rebasing token decreases multiplier (fee accrual)', () => { + const multiplierDecreasePercentage = 5; + + cacheBeforeEach(async () => { + const previousMultiplier = await token.multiplier(); + await token.updateMultiplierValue( + previousMultiplier.mul(100 - multiplierDecreasePercentage).div(100), + previousMultiplier, + 0 + ) + }) + + it("should keep user wrapper balance unchanged", async () => { + const balance = await wrapped.balanceOf(owner.address); + expect(balance).to.eq(initialBalance); + }) + + it("should decrease user underlying balance by 5%", async () => { + const assetsBalance = await wrapped.convertToAssets(await wrapped.balanceOf(owner.address)); + expect(assetsBalance.toNumber()).to.be.approximately(initialBalance.mul(100 - multiplierDecreasePercentage).div(100).toNumber(), 1); + }) + }); + }); + + describe('#deposit', () => { + it("should deposit underlying tokens and mint wrapped tokens", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + + const tx = await wrapped.deposit(depositAmount, owner.address); + const receipt = await tx.wait(); + + const depositEvent = receipt.events?.find(e => e.event === 'Deposit'); + expect(depositEvent).to.not.be.undefined; + if (depositEvent && depositEvent.args) { + // ERC4626 Deposit event: event Deposit(address indexed caller, address indexed owner, uint256 assets, uint256 shares) + expect(depositEvent.args[0]).to.equal(owner.address); // caller + expect(depositEvent.args[1]).to.equal(owner.address); // receiver/owner + } + + const balance = await wrapped.balanceOf(owner.address); + expect(balance).to.be.gt(0); + }); + + it("should emit correct Deposit event", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + + await expect(wrapped.deposit(depositAmount, owner.address)) + .to.emit(wrapped, 'Deposit'); + }); }); + describe('#mint', () => { + it("should mint exact amount of wrapped tokens", async () => { + const mintAmount = BigNumber.from(1000); + await token.approve(wrapped.address, BigNumber.from(10).pow(18)); + + await wrapped.mint(mintAmount, owner.address); + + const balance = await wrapped.balanceOf(owner.address); + expect(balance).to.equal(mintAmount); + }); + }); + + describe('#withdraw', () => { + it("should withdraw underlying tokens and burn wrapped tokens", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + await wrapped.deposit(depositAmount, owner.address); + + const beforeBalance = await token.balanceOf(actor.address); + const withdrawAmount = BigNumber.from(500); + await wrapped.withdraw(withdrawAmount, actor.address, owner.address); + + const afterBalance = await token.balanceOf(actor.address); + const diff = afterBalance.sub(beforeBalance).toNumber(); + expect(diff).to.be.approximately(withdrawAmount.toNumber(), 2); + }); + + it("should emit correct Withdraw event", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + await wrapped.deposit(depositAmount, owner.address); + + const withdrawAmount = BigNumber.from(500); + + await expect(wrapped.withdraw(withdrawAmount, actor.address, owner.address)) + .to.emit(wrapped, 'Withdraw'); + }); + }); - // Tests copied from base BackedTokenImplementation tests: + describe('#redeem', () => { + it("should redeem exact amount of wrapped tokens", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + await wrapped.mint(depositAmount, owner.address); + + const redeemAmount = BigNumber.from(500); + + await wrapped.redeem(redeemAmount, actor.address, owner.address); + + const balance = await wrapped.balanceOf(owner.address); + expect(balance).to.equal(depositAmount.sub(redeemAmount)); + }); + }); + + describe('#previewDeposit', () => { + it("should preview shares for asset amount", async () => { + const assets = BigNumber.from(1000); + const shares = await wrapped.previewDeposit(assets); + + const convertedShares = await wrapped.convertToShares(assets); + expect(shares).to.equal(convertedShares); + }); + }); + + describe('#previewMint', () => { + it("should preview assets for share amount with correct rounding", async () => { + const shares = BigNumber.from(1000); + const assets = await wrapped.previewMint(shares); + + // previewMint should round down + const currentMultiplier = (await token.getCurrentMultiplier())[0]; + const expectedAssets = shares.mul(currentMultiplier).div(BigNumber.from(10).pow(18)); + + expect(assets).to.equal(expectedAssets); + }); + }); + + describe('#previewWithdraw', () => { + it("should preview shares for withdraw amount with correct rounding", async () => { + const assets = BigNumber.from(1000); + const shares = await wrapped.previewWithdraw(assets); + + // previewWithdraw should round down + const currentMultiplier = (await token.getCurrentMultiplier())[0]; + const expectedShares = assets.mul(BigNumber.from(10).pow(18)).div(currentMultiplier); + + expect(shares).to.equal(expectedShares); + }); + }); + + describe('#previewRedeem', () => { + it("should preview assets for redeem amount", async () => { + const shares = BigNumber.from(1000); + const assets = await wrapped.previewRedeem(shares); + + const convertedAssets = await wrapped.convertToAssets(shares); + expect(assets).to.equal(convertedAssets); + }); + }); + + // Tests copied from base WrappedBackedTokenImplementation tests: it("Basic information check", async function () { expect(await wrapped.name()).to.equal(wrappedTokenName); @@ -211,7 +456,6 @@ describe("WrappedBackedTokenImplementation", function () { expect(await wrapped.VERSION()).to.equal("1.0.0"); }); - it("Define Pauser and transfer Pauser", async function () { // Set Pauser let receipt = await (await wrapped.setPauser(pauser.address)).wait(); @@ -279,7 +523,6 @@ describe("WrappedBackedTokenImplementation", function () { ] ) ); - // ToDo: expect(await wrapped.DOMAIN_SEPARATOR()).to.equal(domainSeparator); }); @@ -326,22 +569,7 @@ describe("WrappedBackedTokenImplementation", function () { const sig = await signer._signTypedData(domain, types, msg); const splitSig = ethers.utils.splitSignature(sig); - // Try to send it when delegation mode is off: - await expect( - wrapped.permit( - tmpAccount.address, - minter.address, - 100, - ethers.constants.MaxUint256, - splitSig.v, - splitSig.r, - splitSig.s - ) - ).to.revertedWith("BackedToken: Unauthorized delegate"); - - // Whitelist an address and relay signature: - await wrapped.setDelegateWhitelist(owner.address, true); - + // V2 doesn't have delegate authorization - permit works directly await expect( wrapped.permit( tmpAccount.address, @@ -374,8 +602,7 @@ describe("WrappedBackedTokenImplementation", function () { 100 ); - // Set delegation mode to true and try again: - await wrapped.setDelegateMode(true); + // Try with another signature msg.nonce = 1; msg.value = 150; const sig2 = await signer._signTypedData(domain, types, msg); @@ -452,22 +679,7 @@ describe("WrappedBackedTokenImplementation", function () { const sig = await signer._signTypedData(domain, types, msg); const splitSig = ethers.utils.splitSignature(sig); - // Try to send it when delegation mode is off: - await expect( - wrapped.delegatedTransfer( - tmpAccount.address, - minter.address, - 100, - ethers.constants.MaxUint256, - splitSig.v, - splitSig.r, - splitSig.s - ) - ).to.revertedWith("WrappedBackedToken: Unauthorized delegate"); - - // Whitelist an address and relay signature: - await wrapped.setDelegateWhitelist(owner.address, true); - + // V2 doesn't have delegate authorization - test expired deadline await expect( wrapped.delegatedTransfer( tmpAccount.address, @@ -499,8 +711,7 @@ describe("WrappedBackedTokenImplementation", function () { expect(await wrapped.balanceOf(tmpAccount.address)).to.equal(400); expect(await wrapped.balanceOf(minter.address)).to.equal(100); - // Set delegation mode to true and try again: - await wrapped.setDelegateMode(true); + // Try again with different nonce msg.nonce = 1; msg.value = 200; const sig2 = await signer._signTypedData(domain, types, msg); @@ -542,51 +753,17 @@ describe("WrappedBackedTokenImplementation", function () { }); it("Try to set delegate from wrong address", async function () { - // Delegate mode: + // V2 doesn't have setDelegateMode or setDelegateWhitelist + // Test that only owner can call owner-only functions await expect( - wrapped.connect(tmpAccount.signer).setDelegateMode(true) - ).to.be.revertedWith("Ownable: caller is not the owner"); - - // Delegate address: - await expect( - wrapped - .connect(tmpAccount.signer) - .setDelegateWhitelist(tmpAccount.address, true) + wrapped.connect(tmpAccount.signer).setPauser(tmpAccount.address) ).to.be.revertedWith("Ownable: caller is not the owner"); }); - it("Set SanctionsList", async function () { - // Deploy a new Sanctions List: - const sanctionsList2: SanctionsListMock = await ( - await ethers.getContractFactory("SanctionsListMock", blacklister.signer) - ).deploy(); - await sanctionsList2.deployed(); - - // Test current Sanctions List: - expect(await wrapped.sanctionsList()).to.equal(sanctionsList.address); - - // Change SanctionsList - const receipt = await ( - await wrapped.setSanctionsList(sanctionsList2.address) - ).wait(); - expect(receipt.events?.[0].event).to.equal("NewSanctionsList"); - expect(receipt.events?.[0].args?.[0]).to.equal(sanctionsList2.address); - expect(await wrapped.sanctionsList()).to.equal(sanctionsList2.address); - }); - - it("Try to set SanctionsList from wrong address", async function () { - await expect( - wrapped.connect(tmpAccount.signer).setSanctionsList(tmpAccount.address) - ).to.be.revertedWith("Ownable: caller is not the owner"); - }); - - it("Try to set SanctionsList to a contract not following the interface", async function () { - await expect( - wrapped.connect(owner.signer).setSanctionsList(wrapped.address) - ).to.be.revertedWith( - "Transaction reverted: function selector was not recognized and there's no fallback function" - ); - }); + // Sanctions list management has moved to the underlying token; the wrapped + // contract reads from `IBackedToken(asset()).sanctionsList()`. So the + // wrapper-side setters/getters no longer exist and the legacy + // "Set SanctionsList" tests have been removed. it("Check blocking of address in the Sanctions List", async function () { await token.approve(wrapped.address, 200); @@ -609,15 +786,15 @@ describe("WrappedBackedTokenImplementation", function () { // Try to send from the sanctioned address: await expect( wrapped.connect(tmpAccount.signer).transfer(owner.address, 100) - ).to.be.revertedWith("BackedToken: sender is sanctioned"); + ).to.be.revertedWith("WrappedBackedToken: sender is sanctioned"); // Try to spend from the sanctioned address: wrapped.connect(owner.signer).approve(tmpAccount.address, 100); await expect( - token + wrapped .connect(tmpAccount.signer) .transferFrom(owner.address, minter.address, 50) - ).to.be.revertedWith("BackedToken: spender is sanctioned"); + ).to.be.revertedWith("WrappedBackedToken: spender is sanctioned"); // Remove from sanctions list: await ( @@ -638,14 +815,13 @@ describe("WrappedBackedTokenImplementation", function () { expect(await wrapped.balanceOf(owner.address)).to.equal(50); }); - it("SanctionsList stops minting and burning", async function () { + it("SanctionsList stops deposit and redeem", async function () { await token.connect(minter.signer).approve(wrapped.address, BigNumber.from(10).pow(18).mul(1_000_000)); await token.approve(wrapped.address, 300); await wrapped.deposit(100, owner.address); await wrapped.deposit(100, tmpAccount.address); await wrapped.deposit(100, burner.address); await wrapped.setPauser(pauser.address); - await wrapped.setSanctionsList(sanctionsList.address); // Sanction 0x0 address, and still mint: await sanctionsList.addToSanctionsList([ethers.constants.AddressZero]); @@ -662,10 +838,10 @@ describe("WrappedBackedTokenImplementation", function () { await sanctionsList .connect(blacklister.signer) .addToSanctionsList([burner.address]); - await expect(wrapped.connect(burner.signer).redeem(50, burner.address, burner.address)).to.be.revertedWith('BackedToken: sender is sanctioned'); + await expect(wrapped.connect(burner.signer).redeem(50, burner.address, burner.address)).to.be.revertedWith('WrappedBackedToken: sender is sanctioned'); }); - it("SanctionsList stops minting and burning", async function () { + it("SanctionsList stops spender in transferFrom", async function () { await token.approve(wrapped.address, 300); await wrapped.deposit(100, owner.address); await wrapped.approve(minter.address, 100); @@ -693,9 +869,835 @@ describe("WrappedBackedTokenImplementation", function () { ).to.be.revertedWith("Ownable: caller is not the owner"); }); + describe('ERC4626 edge cases and compliance', () => { + describe('#deposit with different receivers', () => { + it("should deposit to a different receiver", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + + await wrapped.deposit(depositAmount, actor.address); + + const balance = await wrapped.balanceOf(actor.address); + expect(balance).to.be.gt(0); + expect(await wrapped.balanceOf(owner.address)).to.equal(0); + }); + }); + + describe('#mint with different receivers', () => { + it("should mint to a different receiver", async () => { + const mintAmount = BigNumber.from(1000); + await token.approve(wrapped.address, BigNumber.from(10).pow(18)); + + await wrapped.mint(mintAmount, actor.address); + + const balance = await wrapped.balanceOf(actor.address); + expect(balance).to.equal(mintAmount); + }); + }); + + describe('#withdraw with allowance', () => { + it("should allow withdrawal with proper allowance", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + await wrapped.deposit(depositAmount, owner.address); + + // Approve actor to withdraw on behalf of owner + await wrapped.approve(actor.address, depositAmount); + + const beforeBalance = await token.balanceOf(tmpAccount.address); + await wrapped.connect(actor.signer).withdraw(500, tmpAccount.address, owner.address); + + const afterBalance = await token.balanceOf(tmpAccount.address); + expect(afterBalance.sub(beforeBalance).toNumber()).to.be.approximately(500, 2); + }); + it("should fail withdrawal without allowance", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + await wrapped.deposit(depositAmount, owner.address); + + await expect( + wrapped.connect(actor.signer).withdraw(500, tmpAccount.address, owner.address) + ).to.be.reverted; + }); + }); + + describe('#redeem with allowance', () => { + it("should allow redeem with proper allowance", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + await wrapped.mint(depositAmount, owner.address); + + // Approve actor to redeem on behalf of owner + await wrapped.approve(actor.address, depositAmount); + + await wrapped.connect(actor.signer).redeem(500, tmpAccount.address, owner.address); + + const balance = await wrapped.balanceOf(owner.address); + expect(balance).to.equal(500); + }); + + it("should fail redeem without allowance", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + await wrapped.mint(depositAmount, owner.address); + + await expect( + wrapped.connect(actor.signer).redeem(500, tmpAccount.address, owner.address) + ).to.be.reverted; + }); + }); + + describe('#deposit and #withdraw roundtrip', () => { + it("should allow full roundtrip deposit and withdraw", async () => { + const depositAmount = BigNumber.from(1000); + const initialBalance = await token.balanceOf(owner.address); + + await token.approve(wrapped.address, depositAmount); + await wrapped.deposit(depositAmount, owner.address); + + const assets = await wrapped.convertToAssets(await wrapped.balanceOf(owner.address)); + await wrapped.withdraw(assets, owner.address, owner.address); + + const finalBalance = await token.balanceOf(owner.address); + expect(finalBalance.sub(initialBalance).abs().toNumber()).to.be.approximately(0, 2); + }); + }); + + describe('#mint and #redeem roundtrip', () => { + it("should allow full roundtrip mint and redeem", async () => { + const mintAmount = BigNumber.from(1000); + const initialBalance = await token.balanceOf(owner.address); + + await token.approve(wrapped.address, BigNumber.from(10).pow(18)); + await wrapped.mint(mintAmount, owner.address); + + await wrapped.redeem(mintAmount, owner.address, owner.address); + + const finalBalance = await token.balanceOf(owner.address); + expect(finalBalance.sub(initialBalance).abs().toNumber()).to.be.approximately(0, 2); + }); + }); + }); + + describe('Rounding behavior tests', () => { + describe('When dealing with small amounts', () => { + it("should handle deposit of 1 wei correctly", async () => { + await token.approve(wrapped.address, 1); + const shares = await wrapped.previewDeposit(1); + + if (shares.gt(0)) { + await wrapped.deposit(1, owner.address); + expect(await wrapped.balanceOf(owner.address)).to.be.gte(shares); + } + }); + + it("should handle mint of 1 share correctly", async () => { + await token.approve(wrapped.address, BigNumber.from(10).pow(18)); + await wrapped.previewMint(1); + + await wrapped.mint(1, owner.address); + expect(await wrapped.balanceOf(owner.address)).to.equal(1); + }); + }); + + describe('Rounding direction tests', () => { + it("previewDeposit should round down", async () => { + const assets = BigNumber.from(999); + const shares1 = await wrapped.previewDeposit(assets); + const shares2 = await wrapped.convertToShares(assets); + + expect(shares1).to.equal(shares2); + }); + + it("previewMint should round down", async () => { + const shares = BigNumber.from(999); + const assets = await wrapped.previewMint(shares); + + // previewMint uses Rounding.Down + const currentMultiplier = (await token.getCurrentMultiplier())[0]; + const expected = shares.mul(currentMultiplier).div(BigNumber.from(10).pow(18)); + + expect(assets).to.equal(expected); + }); + + it("previewWithdraw should round down", async () => { + const assets = BigNumber.from(999); + const shares = await wrapped.previewWithdraw(assets); + + // previewWithdraw uses Rounding.Down + const currentMultiplier = (await token.getCurrentMultiplier())[0]; + const expected = assets.mul(BigNumber.from(10).pow(18)).div(currentMultiplier); + + expect(shares).to.equal(expected); + }); + + it("previewRedeem should round down", async () => { + const shares = BigNumber.from(999); + const assets1 = await wrapped.previewRedeem(shares); + const assets2 = await wrapped.convertToAssets(shares); + + expect(assets1).to.equal(assets2); + }); + }); + }); + + describe('Transfer restrictions and ERC20 compliance', () => { + describe('When paused', () => { + cacheBeforeEach(async () => { + await token.approve(wrapped.address, 1000); + await wrapped.deposit(500, owner.address); + await wrapped.setPauser(pauser.address); + await wrapped.connect(pauser.signer).setPause(true); + }); + + it("should block deposit when paused", async () => { + await expect( + wrapped.deposit(100, owner.address) + ).to.be.revertedWith("WrappedBackedToken: token transfer while paused"); + }); + + it("should block mint when paused", async () => { + await expect( + wrapped.mint(100, owner.address) + ).to.be.revertedWith("WrappedBackedToken: token transfer while paused"); + }); + + it("should block withdraw when paused", async () => { + await expect( + wrapped.withdraw(100, owner.address, owner.address) + ).to.be.revertedWith("WrappedBackedToken: token transfer while paused"); + }); + + it("should block redeem when paused", async () => { + await expect( + wrapped.redeem(100, owner.address, owner.address) + ).to.be.revertedWith("WrappedBackedToken: token transfer while paused"); + }); + }); + + describe('Zero amount operations', () => { + it("should handle deposit of 0 amount", async () => { + await token.approve(wrapped.address, 1000); + await wrapped.deposit(0, owner.address); + expect(await wrapped.balanceOf(owner.address)).to.equal(0); + }); + + it("should handle mint of 0 shares", async () => { + await token.approve(wrapped.address, 1000); + await wrapped.mint(0, owner.address); + expect(await wrapped.balanceOf(owner.address)).to.equal(0); + }); + + it("should handle withdraw of 0 amount", async () => { + await token.approve(wrapped.address, 1000); + await wrapped.deposit(500, owner.address); + + const beforeBalance = await wrapped.balanceOf(owner.address); + await wrapped.withdraw(0, owner.address, owner.address); + expect(await wrapped.balanceOf(owner.address)).to.equal(beforeBalance); + }); + + it("should handle redeem of 0 shares", async () => { + await token.approve(wrapped.address, 1000); + await wrapped.deposit(500, owner.address); + + const beforeBalance = await wrapped.balanceOf(owner.address); + await wrapped.redeem(0, owner.address, owner.address); + expect(await wrapped.balanceOf(owner.address)).to.equal(beforeBalance); + }); + }); + }); + + describe('Multiplier changes during operations', () => { + describe('When multiplier changes between preview and execution', () => { + it("should handle multiplier increase between previewDeposit and deposit", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + + const expectedShares = await wrapped.previewDeposit(depositAmount); + + // Increase multiplier by 10% + const previousMultiplier = await token.multiplier(); + await token.updateMultiplierValue( + previousMultiplier.mul(110).div(100), + previousMultiplier, + 0 + ); + + // Deposit should still work but give different shares + await wrapped.deposit(depositAmount, owner.address); + const actualShares = await wrapped.balanceOf(owner.address); + + // With higher multiplier, same assets give fewer shares + expect(actualShares).to.be.lt(expectedShares); + }); + + it("should handle multiplier decrease between previewWithdraw and withdraw", async () => { + const depositAmount = BigNumber.from(1000); + await token.approve(wrapped.address, depositAmount); + await wrapped.deposit(depositAmount, owner.address); + + const withdrawAmount = BigNumber.from(500); + await wrapped.previewWithdraw(withdrawAmount); + + // Decrease multiplier by 10% + const previousMultiplier = await token.multiplier(); + await token.updateMultiplierValue( + previousMultiplier.mul(90).div(100), + previousMultiplier, + 0 + ); + + // Withdraw should still work + await wrapped.withdraw(withdrawAmount, owner.address, owner.address); + }); + }); + }); + + describe('Rounding math at multiplier = 10x', () => { + // The wrapper overrides _convertToShares/_convertToAssets to use the + // underlying multiplier directly: + // shares = assets * 1e18 / multiplier (floor) + // assets = shares * multiplier / 1e18 (floor) + // and overrides previewMint/previewWithdraw to also round Down (see the + // contract's "Amounts are rounded down, in order to accomodate multiplier + // math done on underlying token" notes). At multiplier = 10e18: + // previewDeposit(a) = previewWithdraw(a) = floor(a / 10) + // previewMint(s) = previewRedeem(s) = s * 10 (always exact) + // + // The underlying BackedAutoFeeToken also floors `amount -> underlying-shares` + // on transfer, so a transferFrom of `a` actually moves + // floor(a / 10) * 10 = a - (a % 10) + // underlying in balanceOf terms (the `a % 10` remainder vanishes). + + const ONE = BigNumber.from(10).pow(18); + + cacheBeforeEach(async () => { + // Prime: 1e18 underlying -> 1e18 wrapped shares (1:1 since vault was empty). + await token.approve(wrapped.address, ONE.mul(20)); + await wrapped.deposit(ONE, owner.address); + + // Move underlying multiplier to exactly 10x. + const previousMultiplier = await token.multiplier(); + await token.updateMultiplierValue(ONE.mul(10), previousMultiplier, 0); + + // Sanity check: vault state is what the formulas below assume. + expect(await wrapped.totalSupply()).to.equal(ONE); + expect(await wrapped.totalAssets()).to.equal(ONE.mul(10)); + expect((await token.getCurrentMultiplier())[0]).to.equal(ONE.mul(10)); + }); + + describe("preview functions follow floor(a/10) and s*10 exactly", () => { + it("previewDeposit(20) == 2 (assets divisible by multiplier — exact)", async () => { + expect(await wrapped.previewDeposit(20)).to.equal(2); + expect(await wrapped.previewWithdraw(20)).to.equal(2); + }); + + it("previewDeposit(25) == 2 (not divisible — rounds down, 5 wei lost)", async () => { + expect(await wrapped.previewDeposit(25)).to.equal(2); + expect(await wrapped.previewWithdraw(25)).to.equal(2); + }); + + it("previewDeposit(9) == 0 (less than one share's worth of assets)", async () => { + expect(await wrapped.previewDeposit(9)).to.equal(0); + expect(await wrapped.previewWithdraw(9)).to.equal(0); + }); + + it("previewMint and previewRedeem are always exact at 10x", async () => { + for (const s of [0, 1, 2, 7, 100, 999]) { + expect(await wrapped.previewMint(s), `previewMint(${s})`).to.equal(s * 10); + expect(await wrapped.previewRedeem(s), `previewRedeem(${s})`).to.equal(s * 10); + } + }); + }); + + describe("deposit", () => { + it("exact: deposit(20) mints 2 shares and removes 20 underlying from owner", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.deposit(20, owner.address); + + expect((await wrapped.balanceOf(owner.address)).sub(sharesBefore)).to.equal(2); + expect(ownerAssetsBefore.sub(await token.balanceOf(owner.address))).to.equal(20); + }); + + it("inexact: deposit(25) mints 2 shares but only removes 20 underlying (5 wei lost in underlying rounding)", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.deposit(25, owner.address); + + // Wrapper mints floor(25/10) = 2 wrapped shares. + expect((await wrapped.balanceOf(owner.address)).sub(sharesBefore)).to.equal(2); + // But the underlying token's transferFrom floors amount->underlying-shares, + // so owner's balanceOf only drops by 20, not 25 — the 5-wei remainder simply + // never leaves the owner's account. + expect(ownerAssetsBefore.sub(await token.balanceOf(owner.address))).to.equal(20); + }); + }); + + describe("mint", () => { + it("mint(7) pulls exactly 70 underlying and credits 7 shares — always exact at 10x", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.mint(7, owner.address); + + expect((await wrapped.balanceOf(owner.address)).sub(sharesBefore)).to.equal(7); + expect(ownerAssetsBefore.sub(await token.balanceOf(owner.address))).to.equal(70); + }); + + it("mint(0) is a no-op on balances", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.mint(0, owner.address); + + expect(await wrapped.balanceOf(owner.address)).to.equal(sharesBefore); + expect(await token.balanceOf(owner.address)).to.equal(ownerAssetsBefore); + }); + }); + + describe("withdraw", () => { + it("exact: withdraw(40) burns 4 shares and pays out 40 underlying", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.withdraw(40, owner.address, owner.address); + + expect(sharesBefore.sub(await wrapped.balanceOf(owner.address))).to.equal(4); + expect((await token.balanceOf(owner.address)).sub(ownerAssetsBefore)).to.equal(40); + }); + + it("inexact: withdraw(45) burns floor(45/10)=4 shares and pays out 40 underlying (5 wei vanishes)", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.withdraw(45, owner.address, owner.address); + + expect(sharesBefore.sub(await wrapped.balanceOf(owner.address))).to.equal(4); + // Underlying token also floors the transfer -> only 40 actually moves. + expect((await token.balanceOf(owner.address)).sub(ownerAssetsBefore)).to.equal(40); + }); + }); + + describe("redeem", () => { + it("redeem(3) burns 3 shares and returns exactly 30 underlying", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.redeem(3, owner.address, owner.address); + + expect(sharesBefore.sub(await wrapped.balanceOf(owner.address))).to.equal(3); + expect((await token.balanceOf(owner.address)).sub(ownerAssetsBefore)).to.equal(30); + }); + }); + + describe("round trips", () => { + it("deposit(100) -> redeem returns exactly 100 — divides cleanly by multiplier", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.deposit(100, owner.address); + const sharesMinted = (await wrapped.balanceOf(owner.address)).sub(sharesBefore); + expect(sharesMinted).to.equal(10); + + await wrapped.redeem(sharesMinted, owner.address, owner.address); + + expect(await token.balanceOf(owner.address)).to.equal(ownerAssetsBefore); + }); + + it("deposit(107) -> redeem nets to zero — the 7-wei remainder never leaves owner's account", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.deposit(107, owner.address); + const sharesMinted = (await wrapped.balanceOf(owner.address)).sub(sharesBefore); + expect(sharesMinted).to.equal(10); // floor(107/10) + // The wrapper *requested* 107 from owner, but the underlying token only + // moved floor(107*1e18/10e18)=10 underlying-shares (=100 in balanceOf). + // The 7-wei remainder stays in owner's account. + expect(ownerAssetsBefore.sub(await token.balanceOf(owner.address))).to.equal(100); + + await wrapped.redeem(sharesMinted, owner.address, owner.address); + + // After redeeming the 10 shares the owner is exactly whole again. + expect(await token.balanceOf(owner.address)).to.equal(ownerAssetsBefore); + }); + + it("mint(5) -> redeem(5) is a perfect round trip — assets math is exact at 10x", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.mint(5, owner.address); + await wrapped.redeem(5, owner.address, owner.address); + + expect(await wrapped.balanceOf(owner.address)).to.equal(sharesBefore); + expect(await token.balanceOf(owner.address)).to.equal(ownerAssetsBefore); + }); + }); + }); + + describe('Rounding math at multiplier = 0.51x', () => { + // The wrapper overrides _convertToShares/_convertToAssets, previewWithdraw, + // and previewMint — so the active rounding directions are: + // previewDeposit -> _convertToShares Down (OZ default) + // previewMint -> _convertToAssets Down (wrapper override; non-standard) + // previewWithdraw -> _convertToShares Down (wrapper override; non-standard) + // previewRedeem -> _convertToAssets Down (OZ default) + // The wrapper's _convertToShares/_convertToAssets ignore totalSupply and use + // the underlying multiplier directly: + // shares = a * 1e18 / multiplier (rounding from caller) + // assets = s * multiplier / 1e18 (rounding from caller) + // At multiplier = 0.51e18 these reduce to: + // previewDeposit(a) = previewWithdraw(a) = floor(a * 100 / 51) + // previewMint(s) = previewRedeem(s) = floor(s * 51 / 100) + // + // Because the wrapper's _deposit/_withdraw use transferShares directly (not + // the assets value), the underlying balance delta observed by the owner is + // independent of the preview's rounding direction. The underlying token + // stores in shares and floors both amount->shares (on transfer) and + // shares->amount (on balanceOf), so the observed balanceOf delta is a + // non-trivial composition of the share count moved and the underlying's + // flooring. Expected values below were captured directly from the contract. + + const ONE = BigNumber.from(10).pow(18); + const MULTIPLIER = ONE.mul(51).div(100); // 0.51e18 + + cacheBeforeEach(async () => { + // Prime: 1e18 underlying -> 1e18 wrapped shares (1:1 since vault was empty). + await token.approve(wrapped.address, ONE.mul(20)); + await wrapped.deposit(ONE, owner.address); + + const previousMultiplier = await token.multiplier(); + await token.updateMultiplierValue(MULTIPLIER, previousMultiplier, 0); + + expect(await wrapped.totalSupply()).to.equal(ONE); + expect(await wrapped.totalAssets()).to.equal(MULTIPLIER); + expect((await token.getCurrentMultiplier())[0]).to.equal(MULTIPLIER); + }); + + describe("preview functions", () => { + it("previewDeposit/previewWithdraw match floor(a * 100 / 51)", async () => { + // Both round Down (wrapper override on previewWithdraw). + expect(await wrapped.previewDeposit(0)).to.equal(0); + expect(await wrapped.previewDeposit(1)).to.equal(1); // floor(100/51) + expect(await wrapped.previewDeposit(50)).to.equal(98); // floor(5000/51) + expect(await wrapped.previewDeposit(51)).to.equal(100); // exact + expect(await wrapped.previewDeposit(52)).to.equal(101); // floor(5200/51) + expect(await wrapped.previewDeposit(102)).to.equal(200); // exact + + for (const a of [0, 1, 50, 51, 52, 102]) { + expect(await wrapped.previewWithdraw(a), `previewWithdraw(${a})`).to.equal( + await wrapped.previewDeposit(a) + ); + } + }); + + it("previewMint rounds Down (floor) — wrapper override matches previewRedeem", async () => { + // previewMint = floor(s * 51 / 100), same as previewRedeem + expect(await wrapped.previewMint(0)).to.equal(0); + expect(await wrapped.previewMint(1)).to.equal(0); // floor(0.51) + expect(await wrapped.previewMint(50)).to.equal(25); // floor(25.5) + expect(await wrapped.previewMint(99)).to.equal(50); // floor(50.49) + expect(await wrapped.previewMint(100)).to.equal(51); // exact + expect(await wrapped.previewMint(101)).to.equal(51); // floor(51.51) + }); + + it("previewRedeem rounds Down (floor)", async () => { + expect(await wrapped.previewRedeem(0)).to.equal(0); + expect(await wrapped.previewRedeem(1)).to.equal(0); // floor(0.51) — sub-unit + expect(await wrapped.previewRedeem(50)).to.equal(25); // floor(25.5) + expect(await wrapped.previewRedeem(99)).to.equal(50); // floor(50.49) + expect(await wrapped.previewRedeem(100)).to.equal(51); // exact + expect(await wrapped.previewRedeem(101)).to.equal(51); // floor(51.51) + }); + + it("previewMint always equals previewRedeem (both round Down)", async () => { + for (const s of [0, 1, 50, 99, 100, 101]) { + expect(await wrapped.previewMint(s), `previewMint(${s})`).to.equal( + await wrapped.previewRedeem(s) + ); + } + }); + }); + + describe("deposit", () => { + it("exact: deposit(51) mints 100 shares and removes 51 underlying", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.deposit(51, owner.address); + + expect((await wrapped.balanceOf(owner.address)).sub(sharesBefore)).to.equal(100); + expect(ownerAssetsBefore.sub(await token.balanceOf(owner.address))).to.equal(51); + }); + + it("inexact: deposit(52) mints 101 shares and removes 52 underlying", async () => { + // Owner pays the full requested 52, but only gets shares worth previewRedeem(101) = 51. + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.deposit(52, owner.address); + + expect((await wrapped.balanceOf(owner.address)).sub(sharesBefore)).to.equal(101); + expect(ownerAssetsBefore.sub(await token.balanceOf(owner.address))).to.equal(52); + }); + + it("inexact: deposit(50) mints 98 shares and removes 50 underlying", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.deposit(50, owner.address); + + expect((await wrapped.balanceOf(owner.address)).sub(sharesBefore)).to.equal(98); + expect(ownerAssetsBefore.sub(await token.balanceOf(owner.address))).to.equal(50); + }); + }); + + describe("mint", () => { + it("exact: mint(100) credits 100 shares and pulls 51 underlying", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.mint(100, owner.address); + + expect((await wrapped.balanceOf(owner.address)).sub(sharesBefore)).to.equal(100); + expect(ownerAssetsBefore.sub(await token.balanceOf(owner.address))).to.equal(51); + }); + + it("inexact: mint(101) charges previewMint=52 underlying (Up rounding)", async () => { + // Up-rounding ensures the vault never under-charges for shares minted. + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.mint(101, owner.address); + + expect((await wrapped.balanceOf(owner.address)).sub(sharesBefore)).to.equal(101); + expect(ownerAssetsBefore.sub(await token.balanceOf(owner.address))).to.equal(52); + }); + + it("inexact: mint(99) charges previewMint=51 underlying (Up rounding from 50.49)", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.mint(99, owner.address); + + expect((await wrapped.balanceOf(owner.address)).sub(sharesBefore)).to.equal(99); + expect(ownerAssetsBefore.sub(await token.balanceOf(owner.address))).to.equal(51); + }); + + it("sub-unit: mint(1) costs 1 underlying (Up-rounded from 0.51)", async () => { + // Despite a single share being worth less than 1 wei at this multiplier, + // the Up rounding charges 1 wei to avoid free shares. + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.mint(1, owner.address); + + expect((await wrapped.balanceOf(owner.address)).sub(sharesBefore)).to.equal(1); + expect(ownerAssetsBefore.sub(await token.balanceOf(owner.address))).to.equal(1); + }); + }); + + describe("withdraw", () => { + it("exact: withdraw(51) burns 100 shares and pays 51 underlying", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.withdraw(51, owner.address, owner.address); + + expect(sharesBefore.sub(await wrapped.balanceOf(owner.address))).to.equal(100); + expect((await token.balanceOf(owner.address)).sub(ownerAssetsBefore)).to.equal(51); + }); + + it("inexact: withdraw(52) burns 101 shares but pays only 51 underlying (1 wei lost in underlying flooring)", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.withdraw(52, owner.address, owner.address); + + expect(sharesBefore.sub(await wrapped.balanceOf(owner.address))).to.equal(101); + expect((await token.balanceOf(owner.address)).sub(ownerAssetsBefore)).to.equal(51); + }); + + it("inexact: withdraw(50) burns 98 shares and pays 49 underlying", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.withdraw(50, owner.address, owner.address); + + expect(sharesBefore.sub(await wrapped.balanceOf(owner.address))).to.equal(98); + expect((await token.balanceOf(owner.address)).sub(ownerAssetsBefore)).to.equal(49); + }); + + it("sub-unit: withdraw(1) burns 1 share but receives 0 underlying", async () => { + // The wrapper sends 1 underlying to owner, but at multiplier 0.51 the + // underlying token converts that to 1 underlying-share, which contributes + // 0 wei to owner.balanceOf (sub-multiplier resolution). + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.withdraw(1, owner.address, owner.address); + + expect(sharesBefore.sub(await wrapped.balanceOf(owner.address))).to.equal(1); + expect(await token.balanceOf(owner.address)).to.equal(ownerAssetsBefore); + }); + }); + + describe("redeem", () => { + it("exact: redeem(100) burns 100 shares and returns 51 underlying", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.redeem(100, owner.address, owner.address); + + expect(sharesBefore.sub(await wrapped.balanceOf(owner.address))).to.equal(100); + expect((await token.balanceOf(owner.address)).sub(ownerAssetsBefore)).to.equal(51); + }); + + it("inexact: redeem(101) burns 101 shares and returns previewRedeem=51 underlying", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.redeem(101, owner.address, owner.address); + + expect(sharesBefore.sub(await wrapped.balanceOf(owner.address))).to.equal(101); + expect((await token.balanceOf(owner.address)).sub(ownerAssetsBefore)).to.equal(51); + }); + + it("inexact: redeem(50) burns 50 shares and returns 25 underlying (matches preview=25)", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.redeem(50, owner.address, owner.address); + + expect(sharesBefore.sub(await wrapped.balanceOf(owner.address))).to.equal(50); + expect((await token.balanceOf(owner.address)).sub(ownerAssetsBefore)).to.equal(25); + }); + + it("sub-unit: redeem(1) burns 1 share for 0 underlying", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.redeem(1, owner.address, owner.address); + + expect(sharesBefore.sub(await wrapped.balanceOf(owner.address))).to.equal(1); + expect(await token.balanceOf(owner.address)).to.equal(ownerAssetsBefore); + }); + }); + + describe("round trips", () => { + it("deposit(51) -> redeem(100) is exact at the multiplier boundary", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.deposit(51, owner.address); + const sharesMinted = (await wrapped.balanceOf(owner.address)).sub(sharesBefore); + expect(sharesMinted).to.equal(100); + + await wrapped.redeem(sharesMinted, owner.address, owner.address); + + expect(await token.balanceOf(owner.address)).to.equal(ownerAssetsBefore); + }); + + it("deposit(52) -> redeem(101) is a perfect round trip (share-based transfer)", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.deposit(52, owner.address); + const sharesMinted = (await wrapped.balanceOf(owner.address)).sub(sharesBefore); + expect(sharesMinted).to.equal(101); + expect(ownerAssetsBefore.sub(await token.balanceOf(owner.address))).to.equal(52); + + await wrapped.redeem(sharesMinted, owner.address, owner.address); + + // The wrapper moves shares directly, so redeeming the same share count + // restores the owner's underlying balance exactly. + expect(await token.balanceOf(owner.address)).to.equal(ownerAssetsBefore); + }); + + it("mint(100) -> redeem(100) is a perfect round trip — exact at 100-share boundary", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.mint(100, owner.address); + await wrapped.redeem(100, owner.address, owner.address); + + expect(await wrapped.balanceOf(owner.address)).to.equal(sharesBefore); + expect(await token.balanceOf(owner.address)).to.equal(ownerAssetsBefore); + }); + + it("mint(101) -> redeem(101) is a perfect round trip (share-based transfer)", async () => { + const ownerAssetsBefore = await token.balanceOf(owner.address); + const sharesBefore = await wrapped.balanceOf(owner.address); + + await wrapped.mint(101, owner.address); + await wrapped.redeem(101, owner.address, owner.address); + + expect(await wrapped.balanceOf(owner.address)).to.equal(sharesBefore); + // Moving 101 shares out and back nets to zero regardless of preview rounding. + expect(await token.balanceOf(owner.address)).to.equal(ownerAssetsBefore); + }); + }); + }); + + describe('View function consistency', () => { + it("convertToShares and previewDeposit should return same value", async () => { + const assets = BigNumber.from(1000); + const shares1 = await wrapped.convertToShares(assets); + const shares2 = await wrapped.previewDeposit(assets); + + expect(shares1).to.equal(shares2); + }); + + it("convertToAssets and previewRedeem should return same value", async () => { + const shares = BigNumber.from(1000); + const assets1 = await wrapped.convertToAssets(shares); + const assets2 = await wrapped.previewRedeem(shares); + + expect(assets1).to.equal(assets2); + }); + + it("totalAssets should equal sum of underlying shares converted", async () => { + await token.approve(wrapped.address, 1000); + await wrapped.deposit(1000, owner.address); + + const totalAssets = await wrapped.totalAssets(); + const shares = await token.sharesOf(wrapped.address); + const expectedAssets = await token.getUnderlyingAmountByShares(shares); + + expect(totalAssets).to.equal(expectedAssets); + }); + }); + + describe('Owner controls', () => { + it("should transfer ownership", async () => { + await wrapped.transferOwnership(tmpAccount.address); + expect(await wrapped.owner()).to.equal(tmpAccount.address); + + // Transfer back + await wrapped.connect(tmpAccount.signer).transferOwnership(owner.address); + expect(await wrapped.owner()).to.equal(owner.address); + }); + + it("should renounce ownership", async () => { + // Create a new wrapped token for this test to avoid affecting other tests + const newWrappedImpl = await new WrappedBackedTokenImplementation__factory(owner.signer).deploy(); + const newWrapped = WrappedBackedTokenImplementation__factory.connect((await new WrappedBackedTokenProxy__factory(owner.signer).deploy( + newWrappedImpl.address, + proxyAdmin.address, + newWrappedImpl.interface.encodeFunctionData('initialize', [wrappedTokenName, wrappedTokenSymbol, token.address]) + )).address, owner.signer); + + await newWrapped.renounceOwnership(); + expect(await newWrapped.owner()).to.equal(ethers.constants.AddressZero); + }); + }); }); + function nthRoot(annualFee: number, n: number) { return Decimal.pow(1 - annualFee, new Decimal(1).div(n)); } -