diff --git a/contracts/BackedAutoFeeTokenImplementation.sol b/contracts/BackedAutoFeeTokenImplementation.sol index 96c53ac..c44d4f9 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( @@ -164,7 +165,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); } @@ -215,6 +216,13 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation { feePerPeriod = _feePerPeriod; } + /** + * @inheritdoc IERC20MetadataUpgradeable + */ + function decimals() public view virtual override(BackedTokenImplementation, IBackedToken) returns (uint8) { + return ERC20Upgradeable.decimals(); + } + /** * @dev See {IERC20-totalSupply}. */ 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 32cf2ff..2bb2bad 100644 --- a/contracts/WrappedBackedTokenFactory.sol +++ b/contracts/WrappedBackedTokenFactory.sol @@ -76,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 } /** @@ -125,7 +124,6 @@ contract WrappedBackedTokenFactory is Ownable { ); 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 5eb4813..2ccc8e7 100644 --- a/contracts/WrappedBackedTokenImplementation.sol +++ b/contracts/WrappedBackedTokenImplementation.sol @@ -36,12 +36,12 @@ 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 "@openzeppelin/contracts-upgradeable-new/utils/math/MathUpgradeable.sol"; -import "./SanctionsList.sol"; -import "./interfaces/IBackedAutoFeeToken.sol"; /** * @dev @@ -55,7 +55,6 @@ import "./interfaces/IBackedAutoFeeToken.sol"; * 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. * */ @@ -78,22 +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 PauseModeChange(bool pauseMode); event NewTerms(string newTerms); // 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 { @@ -162,21 +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 Function to change the contract terms. Allowed only for owner * @@ -204,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); @@ -219,7 +199,7 @@ 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); } diff --git a/contracts/interfaces/IBackedAutoFeeToken.sol b/contracts/interfaces/IBackedAutoFeeToken.sol index 89dfab3..62a33a1 100644 --- a/contracts/interfaces/IBackedAutoFeeToken.sol +++ b/contracts/interfaces/IBackedAutoFeeToken.sol @@ -1,6 +1,9 @@ // 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 @@ -11,14 +14,9 @@ pragma solidity 0.8.9; * - Fees are automatically applied by decreasing the multiplier over time * - The multiplier can be updated by an authorized multiplierUpdater address */ -interface IBackedAutoFeeToken { - // View functions - EIP-712 and Roles +interface IBackedAutoFeeToken is IBackedToken { - /** - * @dev Returns the EIP-712 typehash for delegated share transfers - * @return The keccak256 hash of the DELEGATED_TRANSFER_SHARES type string - */ - function DELEGATED_TRANSFER_SHARES_TYPEHASH() external view returns (bytes32); + // View functions - EIP-712 and Roles /** * @dev Returns the address authorized to update the multiplier 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/test/BackedAutoFeeTokenImplementation.ts b/test/BackedAutoFeeTokenImplementation.ts index f96a025..972af5b 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) ) }) @@ -655,8 +699,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 +1864,161 @@ 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 + 100; + 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('#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"); + }); + }); + + 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"); + }); + }); + }); 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/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 7512274..4469e25 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 @@ -759,44 +758,12 @@ describe("WrappedBackedTokenImplementation", function () { await expect( wrapped.connect(tmpAccount.signer).setPauser(tmpAccount.address) ).to.be.revertedWith("Ownable: caller is not the owner"); - - await expect( - wrapped.connect(tmpAccount.signer).setSanctionsList(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); @@ -855,7 +822,6 @@ describe("WrappedBackedTokenImplementation", function () { 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]); @@ -1166,7 +1132,7 @@ describe("WrappedBackedTokenImplementation", function () { // 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); @@ -1189,6 +1155,493 @@ describe("WrappedBackedTokenImplementation", function () { }); }); + 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 and previewWithdraw, + // but NOT previewMint — so the active rounding directions are: + // previewDeposit -> _convertToShares Down (OZ default) + // previewMint -> _convertToAssets Up (OZ default — NOT overridden) + // 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) = ceil (s * 51 / 100) (Up — costs more) + // previewRedeem(s) = floor(s * 51 / 100) (Down — pays less) + // + // The underlying BackedAutoFeeToken stores in shares and floors both + // amount->shares (on transfer) and shares->amount (on balanceOf), so the + // observed balanceOf delta of a transfer is a non-trivial composition of + // the wrapper's preview math and the underlying's flooring. The expected + // values in these tests 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 Up (ceil) — Up-Down split with previewRedeem", async () => { + // previewMint = ceil(s * 51 / 100); previewRedeem = floor(s * 51 / 100) + expect(await wrapped.previewMint(0)).to.equal(0); + expect(await wrapped.previewMint(1)).to.equal(1); // ceil(0.51) + expect(await wrapped.previewMint(50)).to.equal(26); // ceil(25.5) + expect(await wrapped.previewMint(99)).to.equal(51); // ceil(50.49) + expect(await wrapped.previewMint(100)).to.equal(51); // exact + expect(await wrapped.previewMint(101)).to.equal(52); // ceil(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 vs previewRedeem differ by exactly 1 wei when not divisible", async () => { + // 100 shares * 0.51 = 51 exactly -> Up == Down + expect((await wrapped.previewMint(100)).sub(await wrapped.previewRedeem(100))).to.equal(0); + // 1 share -> Up=1, Down=0 + expect((await wrapped.previewMint(1)).sub(await wrapped.previewRedeem(1))).to.equal(1); + expect((await wrapped.previewMint(101)).sub(await wrapped.previewRedeem(101))).to.equal(1); + }); + }); + + 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 24 underlying (preview=25, then -1 from underlying flooring)", 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(24); + }); + + 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) loses exactly 1 wei to the vault (Up vs Down split)", 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); + + // Owner ends up 1 wei worse off than they started (paid 52, got back 51). + expect(ownerAssetsBefore.sub(await token.balanceOf(owner.address))).to.equal(1); + }); + + 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) loses 1 wei: pays Up=52, refunds Down=51", 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); + // Up-rounded mint cost vs Down-rounded redeem refund = 1 wei spread. + expect(ownerAssetsBefore.sub(await token.balanceOf(owner.address))).to.equal(1); + }); + }); + }); + describe('View function consistency', () => { it("convertToShares and previewDeposit should return same value", async () => { const assets = BigNumber.from(1000);