From 524a1059a15a3292585ac9d1943db63ace47fb9c Mon Sep 17 00:00:00 2001 From: danoctavian Date: Wed, 13 May 2026 21:51:10 +0300 Subject: [PATCH 01/36] add foundry skeleton --- .github/workflows/test.yml | 38 +++++++++++++++++++++ .gitignore | 14 ++++++++ .gitmodules | 3 ++ README.md | 67 +++++++++++++++++++++++++++++++++++++- foundry.toml | 6 ++++ lib/forge-std | 1 + script/Counter.s.sol | 19 +++++++++++ src/Counter.sol | 14 ++++++++ test/Counter.t.sol | 24 ++++++++++++++ 9 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 foundry.toml create mode 160000 lib/forge-std create mode 100644 script/Counter.s.sol create mode 100644 src/Counter.sol create mode 100644 test/Counter.t.sol diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..03824c4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: CI + +permissions: {} + +on: + push: + pull_request: + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + name: Foundry project + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Show Forge version + run: forge --version + + - name: Run Forge fmt + run: forge fmt --check + + - name: Run Forge build + run: forge build --sizes + + - name: Run Forge tests + run: forge test -vvv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85198aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..888d42d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/README.md b/README.md index d1600d7..8817d6a 100644 --- a/README.md +++ b/README.md @@ -1 +1,66 @@ -# term-redeemer +## Foundry + +**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** + +Foundry consists of: + +- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). +- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. +- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. +- **Chisel**: Fast, utilitarian, and verbose solidity REPL. + +## Documentation + +https://book.getfoundry.sh/ + +## Usage + +### Build + +```shell +$ forge build +``` + +### Test + +```shell +$ forge test +``` + +### Format + +```shell +$ forge fmt +``` + +### Gas Snapshots + +```shell +$ forge snapshot +``` + +### Anvil + +```shell +$ anvil +``` + +### Deploy + +```shell +$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +``` + +### Cast + +```shell +$ cast +``` + +### Help + +```shell +$ forge --help +$ anvil --help +$ cast --help +``` diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..25b918f --- /dev/null +++ b/foundry.toml @@ -0,0 +1,6 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..c90c02f --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit c90c02ffa470b46d92ffaab093baeb296300870b diff --git a/script/Counter.s.sol b/script/Counter.s.sol new file mode 100644 index 0000000..f01d69c --- /dev/null +++ b/script/Counter.s.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script} from "forge-std/Script.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterScript is Script { + Counter public counter; + + function setUp() public {} + + function run() public { + vm.startBroadcast(); + + counter = new Counter(); + + vm.stopBroadcast(); + } +} diff --git a/src/Counter.sol b/src/Counter.sol new file mode 100644 index 0000000..aded799 --- /dev/null +++ b/src/Counter.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol new file mode 100644 index 0000000..4831910 --- /dev/null +++ b/test/Counter.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function testFuzz_SetNumber(uint256 x) public { + counter.setNumber(x); + assertEq(counter.number(), x); + } +} From 5b591cb512371bc1bbe47a523ebb01b77fbb62e5 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Wed, 13 May 2026 21:51:35 +0300 Subject: [PATCH 02/36] add TERM redeemer spec --- TERM_REDEEMER_SPEC.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 TERM_REDEEMER_SPEC.md diff --git a/TERM_REDEEMER_SPEC.md b/TERM_REDEEMER_SPEC.md new file mode 100644 index 0000000..05069b8 --- /dev/null +++ b/TERM_REDEEMER_SPEC.md @@ -0,0 +1,35 @@ +# TERM REDEEMER + +The term redeemer module provides redemption functionality for MAX vault with a fixed redemption term. + +It works in multiples stages. + + +At time T0, the module starts allowing locking of MAX vault shares for redemption. It issues a receipt token, Withdrawable ynMYVAULTx. wynMYVAULTx. + +These receipt tokens are minted 1:1. + +This is allowed up until time T1. + +At time T1 locking ynMYVAULTx for wynMYVAULTx stops. At this point, the rate for redemption of wynMYVAULTx is locked in terms of the underyling ynMYVAULTx.asset(). + +This locked in rate is the redemption rate at time T2. + +All this time wynMYVAULTx can move and trade freely, but going back through the system to ynMYVAULTx is not allowed. + +T3 is the time at which wynMYVAULTx can be burned forthe underlying asset. + +This effectively means that yield stops for wynMYVAULTx yholders at time T2. + + +AT t3, the module can call withrawAsset onto the MAX Vault. for the exact amount of USDC needed. then it approves the extra ynRWAx to a trusted address to pull and use as it sees fit. + +At T3 the users can start burning their wynMYVAULTx for the underlying asset indefinitely. + +Do a very slick, simple implementation of this. + +assume all the T times are known ahead of time and setup at initialization. + +Obviously it needs to be configurable in terms of the asset. + + From cf35c8770a3c19b0f64d118bd939ade351db2d8c Mon Sep 17 00:00:00 2001 From: danoctavian Date: Thu, 14 May 2026 00:52:18 +0000 Subject: [PATCH 03/36] Implement term redeemer module --- README.md | 75 +++------- foundry.lock | 5 + src/Counter.sol | 14 -- src/TermRedeemer.sol | 264 ++++++++++++++++++++++++++++++++++++ test/Counter.t.sol | 24 ---- test/TermRedeemer.t.sol | 165 ++++++++++++++++++++++ test/mocks/MockERC20.sol | 12 ++ test/mocks/MockMaxVault.sol | 40 ++++++ 8 files changed, 503 insertions(+), 96 deletions(-) create mode 100644 foundry.lock delete mode 100644 src/Counter.sol create mode 100644 src/TermRedeemer.sol delete mode 100644 test/Counter.t.sol create mode 100644 test/TermRedeemer.t.sol create mode 100644 test/mocks/MockERC20.sol create mode 100644 test/mocks/MockMaxVault.sol diff --git a/README.md b/README.md index 8817d6a..3e52444 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,25 @@ -## Foundry +## Term Redeemer -**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** +This repo contains a simple fixed-term redeemer for a MAX-style vault. -Foundry consists of: +Flow: -- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). -- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. -- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. -- **Chisel**: Fast, utilitarian, and verbose solidity REPL. +1. Between `lockStart` and `lockEnd`, users lock vault shares into `TermRedeemer`. +2. The contract mints a transferable receipt token 1:1 with the locked shares. +3. After `lockEnd`, the contract snapshots a fixed `assetPerShare` redemption rate. +4. At `redeemStart`, the owner can withdraw the exact underlying asset required for all outstanding receipts and approve any residual vault shares to a trusted address. +5. Receipt holders can then burn receipts for the underlying asset indefinitely. -## Documentation +Key files: -https://book.getfoundry.sh/ +- `src/TermRedeemer.sol`: core redeemer and receipt token +- `test/TermRedeemer.t.sol`: timeline and redemption tests +- `test/mocks/MockERC20.sol`, `test/mocks/MockMaxVault.sol`: local test doubles -## Usage +Commands: -### Build - -```shell -$ forge build -``` - -### Test - -```shell -$ forge test -``` - -### Format - -```shell -$ forge fmt -``` - -### Gas Snapshots - -```shell -$ forge snapshot -``` - -### Anvil - -```shell -$ anvil -``` - -### Deploy - -```shell -$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key -``` - -### Cast - -```shell -$ cast -``` - -### Help - -```shell -$ forge --help -$ anvil --help -$ cast --help +```sh +forge build +forge test +forge fmt ``` diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..bc11a4c --- /dev/null +++ b/foundry.lock @@ -0,0 +1,5 @@ +{ + "lib/forge-std": { + "rev": "c90c02ffa470b46d92ffaab093baeb296300870b" + } +} \ No newline at end of file diff --git a/src/Counter.sol b/src/Counter.sol deleted file mode 100644 index aded799..0000000 --- a/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/src/TermRedeemer.sol b/src/TermRedeemer.sol new file mode 100644 index 0000000..93513e5 --- /dev/null +++ b/src/TermRedeemer.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IERC20Minimal { + function totalSupply() external view returns (uint256); + function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); + function transfer(address to, uint256 value) external returns (bool); + function approve(address spender, uint256 value) external returns (bool); + function transferFrom(address from, address to, uint256 value) external returns (bool); + function decimals() external view returns (uint8); +} + +interface IERC20MetadataMinimal is IERC20Minimal { + function name() external view returns (string memory); + function symbol() external view returns (string memory); +} + +interface IMaxVault is IERC20MetadataMinimal { + function asset() external view returns (address); + function convertToAssets(uint256 shares) external view returns (uint256); + function withdrawAsset(uint256 assets, address receiver) external returns (uint256 sharesBurned); +} + +contract ERC20Base is IERC20MetadataMinimal { + string public override name; + string public override symbol; + uint8 public immutable override decimals; + + uint256 public override totalSupply; + + mapping(address account => uint256) public override balanceOf; + mapping(address owner => mapping(address spender => uint256)) public override allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(string memory name_, string memory symbol_, uint8 decimals_) { + name = name_; + symbol = symbol_; + decimals = decimals_; + } + + function transfer(address to, uint256 value) external override returns (bool) { + _transfer(msg.sender, to, value); + return true; + } + + function approve(address spender, uint256 value) external override returns (bool) { + allowance[msg.sender][spender] = value; + emit Approval(msg.sender, spender, value); + return true; + } + + function transferFrom(address from, address to, uint256 value) external override returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= value, "ERC20: insufficient allowance"); + allowance[from][msg.sender] = allowed - value; + emit Approval(from, msg.sender, allowance[from][msg.sender]); + } + + _transfer(from, to, value); + return true; + } + + function _transfer(address from, address to, uint256 value) internal { + require(to != address(0), "ERC20: transfer to zero"); + + uint256 fromBalance = balanceOf[from]; + require(fromBalance >= value, "ERC20: insufficient balance"); + + unchecked { + balanceOf[from] = fromBalance - value; + balanceOf[to] += value; + } + + emit Transfer(from, to, value); + } + + function _mint(address to, uint256 value) internal { + require(to != address(0), "ERC20: mint to zero"); + + totalSupply += value; + balanceOf[to] += value; + + emit Transfer(address(0), to, value); + } + + function _burn(address from, uint256 value) internal { + uint256 fromBalance = balanceOf[from]; + require(fromBalance >= value, "ERC20: insufficient balance"); + + unchecked { + balanceOf[from] = fromBalance - value; + totalSupply -= value; + } + + emit Transfer(from, address(0), value); + } +} + + contract Ownable { + address public owner; + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + modifier onlyOwner() { + require(msg.sender == owner, "Ownable: unauthorized"); + _; + } + + constructor(address initialOwner) { + require(initialOwner != address(0), "Ownable: zero owner"); + owner = initialOwner; + emit OwnershipTransferred(address(0), initialOwner); + } + + function transferOwnership(address newOwner) external onlyOwner { + require(newOwner != address(0), "Ownable: zero owner"); + emit OwnershipTransferred(owner, newOwner); + owner = newOwner; + } + } + + contract TermReceiptToken is ERC20Base { + address public immutable minter; + + modifier onlyMinter() { + require(msg.sender == minter, "Receipt: unauthorized"); + _; + } + + constructor(string memory name_, string memory symbol_, uint8 decimals_, address minter_) + ERC20Base(name_, symbol_, decimals_) + { + minter = minter_; + } + + function mint(address to, uint256 value) external onlyMinter { + _mint(to, value); + } + + function burn(address from, uint256 value) external onlyMinter { + _burn(from, value); + } + } + + contract TermRedeemer is Ownable { + struct Schedule { + uint64 lockStart; + uint64 lockEnd; + uint64 redeemStart; + } + + IMaxVault public immutable vault; + IERC20Minimal public immutable asset; + TermReceiptToken public immutable receiptToken; + address public immutable residualSharesSpender; + uint256 public immutable shareScale; + Schedule public schedule; + + uint256 public lockedAssetPerShare; + bool public rateLocked; + bool public redemptionPrepared; + + event Locked(address indexed account, address indexed receiver, uint256 shareAmount); + event RedemptionRateLocked(uint256 assetPerShare); + event RedemptionPrepared(uint256 assetsWithdrawn, uint256 residualSharesApproved); + event Redeemed(address indexed account, address indexed receiver, uint256 receiptAmount, uint256 assetAmount); + + constructor( + address owner_, + address vault_, + address residualSharesSpender_, + uint64 lockStart_, + uint64 lockEnd_, + uint64 redeemStart_ + ) Ownable(owner_) { + require(vault_ != address(0), "TermRedeemer: zero vault"); + require(residualSharesSpender_ != address(0), "TermRedeemer: zero spender"); + require(lockStart_ < lockEnd_, "TermRedeemer: invalid lock window"); + require(lockEnd_ <= redeemStart_, "TermRedeemer: invalid redeem time"); + + vault = IMaxVault(vault_); + asset = IERC20Minimal(IMaxVault(vault_).asset()); + residualSharesSpender = residualSharesSpender_; + schedule = Schedule({lockStart: lockStart_, lockEnd: lockEnd_, redeemStart: redeemStart_}); + + uint8 shareDecimals = IERC20MetadataMinimal(vault_).decimals(); + shareScale = 10 ** uint256(shareDecimals); + + receiptToken = new TermReceiptToken( + string.concat("Withdrawable ", IERC20MetadataMinimal(vault_).name()), + string.concat("w", IERC20MetadataMinimal(vault_).symbol()), + shareDecimals, + address(this) + ); + } + + function lock(uint256 shareAmount, address receiver) external returns (uint256 receiptAmount) { + require(block.timestamp >= schedule.lockStart, "TermRedeemer: lock not started"); + require(block.timestamp < schedule.lockEnd, "TermRedeemer: lock closed"); + require(receiver != address(0), "TermRedeemer: zero receiver"); + require(shareAmount > 0, "TermRedeemer: zero amount"); + + receiptAmount = shareAmount; + require(vault.transferFrom(msg.sender, address(this), shareAmount), "TermRedeemer: share transfer failed"); + receiptToken.mint(receiver, receiptAmount); + + emit Locked(msg.sender, receiver, shareAmount); + } + + function lockRedemptionRate() public returns (uint256 assetPerShare) { + require(block.timestamp >= schedule.lockEnd, "TermRedeemer: lock window active"); + require(!rateLocked, "TermRedeemer: rate locked"); + + assetPerShare = vault.convertToAssets(shareScale); + require(assetPerShare > 0, "TermRedeemer: zero rate"); + + lockedAssetPerShare = assetPerShare; + rateLocked = true; + + emit RedemptionRateLocked(assetPerShare); + } + + function prepareRedemption() external onlyOwner returns (uint256 assetsNeeded, uint256 residualShares) { + require(block.timestamp >= schedule.redeemStart, "TermRedeemer: redeem not started"); + require(!redemptionPrepared, "TermRedeemer: redemption prepared"); + + if (!rateLocked) { + lockRedemptionRate(); + } + + assetsNeeded = previewAssetsOwed(receiptToken.totalSupply()); + if (assetsNeeded > 0) { + vault.withdrawAsset(assetsNeeded, address(this)); + } + + residualShares = vault.balanceOf(address(this)); + require(vault.approve(residualSharesSpender, residualShares), "TermRedeemer: approve failed"); + + redemptionPrepared = true; + + emit RedemptionPrepared(assetsNeeded, residualShares); + } + + function redeem(uint256 receiptAmount, address receiver) external returns (uint256 assetAmount) { + require(block.timestamp >= schedule.redeemStart, "TermRedeemer: redeem not started"); + require(rateLocked, "TermRedeemer: rate not locked"); + require(receiver != address(0), "TermRedeemer: zero receiver"); + require(receiptAmount > 0, "TermRedeemer: zero amount"); + + assetAmount = previewAssetsOwed(receiptAmount); + receiptToken.burn(msg.sender, receiptAmount); + require(asset.transfer(receiver, assetAmount), "TermRedeemer: asset transfer failed"); + + emit Redeemed(msg.sender, receiver, receiptAmount, assetAmount); + } + + function previewAssetsOwed(uint256 receiptAmount) public view returns (uint256) { + return receiptAmount * lockedAssetPerShare / shareScale; + } + } diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index 4831910..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/test/TermRedeemer.t.sol b/test/TermRedeemer.t.sol new file mode 100644 index 0000000..587f433 --- /dev/null +++ b/test/TermRedeemer.t.sol @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; +import {MockMaxVault} from "./mocks/MockMaxVault.sol"; +import {TermRedeemer, TermReceiptToken} from "../src/TermRedeemer.sol"; + +contract TermRedeemerTest is Test { + uint64 internal constant LOCK_START = 100; + uint64 internal constant LOCK_END = 200; + uint64 internal constant REDEEM_START = 300; + + MockERC20 internal usdc; + MockMaxVault internal vault; + TermRedeemer internal redeemer; + TermReceiptToken internal receipt; + + address internal constant OWNER = address(0xA11CE); + address internal constant ALICE = address(0xB0B); + address internal constant BOB = address(0xCAFE); + address internal constant RESIDUAL_PULLER = address(0xD00D); + + function setUp() public { + usdc = new MockERC20("USD Coin", "USDC", 6); + vault = new MockMaxVault(address(usdc), "ynMAX", "ynMAX", 18); + redeemer = new TermRedeemer(OWNER, address(vault), RESIDUAL_PULLER, LOCK_START, LOCK_END, REDEEM_START); + receipt = redeemer.receiptToken(); + + vault.mintShares(ALICE, 150 ether); + vault.mintShares(BOB, 50 ether); + usdc.mint(address(vault), 1_000_000e6); + + vm.prank(ALICE); + vault.approve(address(redeemer), type(uint256).max); + + vm.prank(BOB); + vault.approve(address(redeemer), type(uint256).max); + } + + function test_LockMintsOneToOneReceiptDuringWindow() public { + vm.warp(LOCK_START); + + vm.prank(ALICE); + uint256 minted = redeemer.lock(10 ether, ALICE); + + assertEq(minted, 10 ether); + assertEq(receipt.balanceOf(ALICE), 10 ether); + assertEq(vault.balanceOf(ALICE), 140 ether); + assertEq(vault.balanceOf(address(redeemer)), 10 ether); + } + + function test_LockRevertsOutsideWindow() public { + vm.expectRevert("TermRedeemer: lock not started"); + vm.prank(ALICE); + redeemer.lock(1 ether, ALICE); + + vm.warp(LOCK_END); + vm.expectRevert("TermRedeemer: lock closed"); + vm.prank(ALICE); + redeemer.lock(1 ether, ALICE); + } + + function test_RedemptionUsesRateLockedAtEndOfLockPhase() public { + vm.warp(LOCK_START); + vm.prank(ALICE); + redeemer.lock(100 ether, ALICE); + + vault.setAssetsPerShare(2e6); + + vm.warp(LOCK_END); + uint256 lockedRate = redeemer.lockRedemptionRate(); + assertEq(lockedRate, 2e6); + + vault.setAssetsPerShare(3e6); + + vm.warp(REDEEM_START); + vm.prank(OWNER); + redeemer.prepareRedemption(); + + vm.prank(ALICE); + uint256 redeemed = redeemer.redeem(100 ether, ALICE); + + assertEq(redeemed, 200e6); + assertEq(usdc.balanceOf(ALICE), 200e6); + } + + function test_PrepareRedemptionWithdrawsExactAssetsAndApprovesResidualShares() public { + vm.warp(LOCK_START); + + vm.prank(ALICE); + redeemer.lock(100 ether, ALICE); + + vm.prank(BOB); + redeemer.lock(50 ether, BOB); + + vault.setAssetsPerShare(2e6); + + vm.warp(REDEEM_START); + + vm.prank(OWNER); + (uint256 assetsNeeded, uint256 residualShares) = redeemer.prepareRedemption(); + + assertEq(assetsNeeded, 300e6); + assertEq(usdc.balanceOf(address(redeemer)), 300e6); + assertEq(residualShares, 0); + assertEq(vault.allowance(address(redeemer), RESIDUAL_PULLER), 0); + } + + function test_PrepareRedemptionLeavesResidualSharesForTrustedSpenderWhenYieldAccruedAfterSnapshot() public { + vm.warp(LOCK_START); + vm.prank(ALICE); + redeemer.lock(100 ether, ALICE); + + vault.setAssetsPerShare(2e6); + + vm.warp(LOCK_END); + redeemer.lockRedemptionRate(); + + vault.setAssetsPerShare(4e6); + + vm.warp(REDEEM_START); + vm.prank(OWNER); + (, uint256 residualShares) = redeemer.prepareRedemption(); + + assertEq(usdc.balanceOf(address(redeemer)), 200e6); + assertEq(residualShares, 50 ether); + assertEq(vault.allowance(address(redeemer), RESIDUAL_PULLER), 50 ether); + } + + function test_RedeemRevertsBeforeMaturity() public { + vm.warp(LOCK_START); + vm.prank(ALICE); + redeemer.lock(10 ether, ALICE); + + vm.warp(LOCK_END); + redeemer.lockRedemptionRate(); + + vm.expectRevert("TermRedeemer: redeem not started"); + vm.prank(ALICE); + redeemer.redeem(10 ether, ALICE); + } + + function test_ReceiptCanTradeBeforeRedemptionAndHolderCanRedeem() public { + vm.warp(LOCK_START); + vm.prank(ALICE); + redeemer.lock(40 ether, ALICE); + + vm.prank(ALICE); + receipt.transfer(BOB, 15 ether); + + vault.setAssetsPerShare(2e6); + + vm.warp(REDEEM_START); + vm.prank(OWNER); + redeemer.prepareRedemption(); + + vm.prank(BOB); + uint256 redeemed = redeemer.redeem(15 ether, BOB); + + assertEq(redeemed, 30e6); + assertEq(usdc.balanceOf(BOB), 30e6); + assertEq(receipt.balanceOf(BOB), 0); + } +} diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol new file mode 100644 index 0000000..95339ee --- /dev/null +++ b/test/mocks/MockERC20.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ERC20Base} from "../../src/TermRedeemer.sol"; + +contract MockERC20 is ERC20Base { + constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20Base(name_, symbol_, decimals_) {} + + function mint(address to, uint256 value) external { + _mint(to, value); + } +} diff --git a/test/mocks/MockMaxVault.sol b/test/mocks/MockMaxVault.sol new file mode 100644 index 0000000..1a6b312 --- /dev/null +++ b/test/mocks/MockMaxVault.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ERC20Base, IERC20Minimal, IMaxVault} from "../../src/TermRedeemer.sol"; + +contract MockMaxVault is ERC20Base, IMaxVault { + address public immutable override asset; + uint256 public assetsPerShare; + IERC20Minimal internal immutable assetToken; + + constructor(address asset_, string memory name_, string memory symbol_, uint8 decimals_) + ERC20Base(name_, symbol_, decimals_) + { + asset = asset_; + assetToken = IERC20Minimal(asset_); + assetsPerShare = 10 ** uint256(decimals_); + } + + function setAssetsPerShare(uint256 newAssetsPerShare) external { + assetsPerShare = newAssetsPerShare; + } + + function mintShares(address to, uint256 shares) external { + _mint(to, shares); + } + + function convertToAssets(uint256 shares) external view returns (uint256) { + return shares * assetsPerShare / (10 ** uint256(decimals)); + } + + function withdrawAsset(uint256 assets, address receiver) external returns (uint256 sharesBurned) { + require(receiver != address(0), "MockMaxVault: zero receiver"); + + uint256 shareScale = 10 ** uint256(decimals); + sharesBurned = (assets * shareScale + assetsPerShare - 1) / assetsPerShare; + + _burn(msg.sender, sharesBurned); + require(assetToken.transfer(receiver, assets), "MockMaxVault: transfer failed"); + } +} From 9f0e824a5715fff3ac5e1311b1610863924f8e3f Mon Sep 17 00:00:00 2001 From: danoctavian Date: Thu, 14 May 2026 01:58:26 +0000 Subject: [PATCH 04/36] Refactor redeemer to OpenZeppelin primitives --- .gitmodules | 3 + foundry.lock | 6 + lib/openzeppelin-contracts | 1 + src/TermRedeemer.sol | 365 ++++++++++++++++-------------------- test/TermRedeemer.t.sol | 8 +- test/mocks/MockERC20.sol | 14 +- test/mocks/MockMaxVault.sol | 37 ++-- 7 files changed, 208 insertions(+), 226 deletions(-) create mode 160000 lib/openzeppelin-contracts diff --git a/.gitmodules b/.gitmodules index 888d42d..690924b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/foundry.lock b/foundry.lock index bc11a4c..cd12e0d 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,5 +1,11 @@ { "lib/forge-std": { "rev": "c90c02ffa470b46d92ffaab093baeb296300870b" + }, + "lib/openzeppelin-contracts": { + "tag": { + "name": "v5.6.1", + "rev": "5fd1781b1454fd1ef8e722282f86f9293cacf256" + } } } \ No newline at end of file diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..5fd1781 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit 5fd1781b1454fd1ef8e722282f86f9293cacf256 diff --git a/src/TermRedeemer.sol b/src/TermRedeemer.sol index 93513e5..a5a3de3 100644 --- a/src/TermRedeemer.sol +++ b/src/TermRedeemer.sol @@ -1,264 +1,213 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -interface IERC20Minimal { - function totalSupply() external view returns (uint256); - function balanceOf(address account) external view returns (uint256); - function allowance(address owner, address spender) external view returns (uint256); - function transfer(address to, uint256 value) external returns (bool); - function approve(address spender, uint256 value) external returns (bool); - function transferFrom(address from, address to, uint256 value) external returns (bool); - function decimals() external view returns (uint8); -} - -interface IERC20MetadataMinimal is IERC20Minimal { - function name() external view returns (string memory); - function symbol() external view returns (string memory); -} +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; +import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; -interface IMaxVault is IERC20MetadataMinimal { +interface IMaxVault is IERC20Metadata { function asset() external view returns (address); function convertToAssets(uint256 shares) external view returns (uint256); function withdrawAsset(uint256 assets, address receiver) external returns (uint256 sharesBurned); } -contract ERC20Base is IERC20MetadataMinimal { - string public override name; - string public override symbol; - uint8 public immutable override decimals; +error ZeroAddress(); +error ZeroAmount(); +error InvalidLockWindow(uint64 lockStart, uint64 lockEnd); +error InvalidRedeemTime(uint64 lockEnd, uint64 redeemStart); +error LockNotStarted(uint256 timestamp, uint64 lockStart); +error LockClosed(uint256 timestamp, uint64 lockEnd); +error LockWindowActive(uint256 timestamp, uint64 lockEnd); +error RateAlreadyLocked(); +error ZeroRate(); +error RedeemNotStarted(uint256 timestamp, uint64 redeemStart); +error RedemptionAlreadyPrepared(); +error RateNotLocked(); - uint256 public override totalSupply; +contract TermReceiptToken is ERC20 { + error ReceiptUnauthorized(address caller); - mapping(address account => uint256) public override balanceOf; - mapping(address owner => mapping(address spender => uint256)) public override allowance; - - event Transfer(address indexed from, address indexed to, uint256 value); - event Approval(address indexed owner, address indexed spender, uint256 value); - - constructor(string memory name_, string memory symbol_, uint8 decimals_) { - name = name_; - symbol = symbol_; - decimals = decimals_; - } + address public immutable minter; + uint8 private immutable tokenDecimals; - function transfer(address to, uint256 value) external override returns (bool) { - _transfer(msg.sender, to, value); - return true; - } - - function approve(address spender, uint256 value) external override returns (bool) { - allowance[msg.sender][spender] = value; - emit Approval(msg.sender, spender, value); - return true; - } - - function transferFrom(address from, address to, uint256 value) external override returns (bool) { - uint256 allowed = allowance[from][msg.sender]; - if (allowed != type(uint256).max) { - require(allowed >= value, "ERC20: insufficient allowance"); - allowance[from][msg.sender] = allowed - value; - emit Approval(from, msg.sender, allowance[from][msg.sender]); + constructor(string memory name_, string memory symbol_, uint8 decimals_, address minter_) ERC20(name_, symbol_) { + if (minter_ == address(0)) { + revert ZeroAddress(); } - _transfer(from, to, value); - return true; + minter = minter_; + tokenDecimals = decimals_; } - function _transfer(address from, address to, uint256 value) internal { - require(to != address(0), "ERC20: transfer to zero"); - - uint256 fromBalance = balanceOf[from]; - require(fromBalance >= value, "ERC20: insufficient balance"); - - unchecked { - balanceOf[from] = fromBalance - value; - balanceOf[to] += value; + function mint(address to, uint256 value) external { + if (msg.sender != minter) { + revert ReceiptUnauthorized(msg.sender); } - emit Transfer(from, to, value); + _mint(to, value); } - function _mint(address to, uint256 value) internal { - require(to != address(0), "ERC20: mint to zero"); - - totalSupply += value; - balanceOf[to] += value; + function burn(address from, uint256 value) external { + if (msg.sender != minter) { + revert ReceiptUnauthorized(msg.sender); + } - emit Transfer(address(0), to, value); + _burn(from, value); } - function _burn(address from, uint256 value) internal { - uint256 fromBalance = balanceOf[from]; - require(fromBalance >= value, "ERC20: insufficient balance"); - - unchecked { - balanceOf[from] = fromBalance - value; - totalSupply -= value; - } - - emit Transfer(from, address(0), value); + function decimals() public view override returns (uint8) { + return tokenDecimals; } } - contract Ownable { - address public owner; +contract TermRedeemer is Ownable { + using SafeERC20 for IERC20; - event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + struct Schedule { + uint64 lockStart; + uint64 lockEnd; + uint64 redeemStart; + } - modifier onlyOwner() { - require(msg.sender == owner, "Ownable: unauthorized"); - _; - } + IMaxVault public immutable vault; + IERC20 public immutable asset; + TermReceiptToken public immutable receiptToken; + address public immutable residualSharesSpender; + uint256 public immutable shareScale; + Schedule public schedule; + + uint256 public lockedAssetPerShare; + bool public rateLocked; + bool public redemptionPrepared; + + event Locked(address indexed account, address indexed receiver, uint256 shareAmount); + event RedemptionRateLocked(uint256 assetPerShare); + event RedemptionPrepared(uint256 assetsWithdrawn, uint256 residualSharesApproved); + event Redeemed(address indexed account, address indexed receiver, uint256 receiptAmount, uint256 assetAmount); + + constructor( + address owner_, + address vault_, + address residualSharesSpender_, + uint64 lockStart_, + uint64 lockEnd_, + uint64 redeemStart_ + ) Ownable(owner_) { + if (vault_ == address(0) || residualSharesSpender_ == address(0)) { + revert ZeroAddress(); + } + if (lockStart_ >= lockEnd_) { + revert InvalidLockWindow(lockStart_, lockEnd_); + } + if (lockEnd_ > redeemStart_) { + revert InvalidRedeemTime(lockEnd_, redeemStart_); + } + + vault = IMaxVault(vault_); + asset = IERC20(IMaxVault(vault_).asset()); + residualSharesSpender = residualSharesSpender_; + schedule = Schedule({lockStart: lockStart_, lockEnd: lockEnd_, redeemStart: redeemStart_}); + + uint8 shareDecimals = IERC20Metadata(vault_).decimals(); + shareScale = 10 ** uint256(shareDecimals); + + receiptToken = new TermReceiptToken( + string.concat("Withdrawable ", IERC20Metadata(vault_).name()), + string.concat("w", IERC20Metadata(vault_).symbol()), + shareDecimals, + address(this) + ); + } - constructor(address initialOwner) { - require(initialOwner != address(0), "Ownable: zero owner"); - owner = initialOwner; - emit OwnershipTransferred(address(0), initialOwner); + function lock(uint256 shareAmount, address receiver) external returns (uint256 receiptAmount) { + if (block.timestamp < schedule.lockStart) { + revert LockNotStarted(block.timestamp, schedule.lockStart); } - - function transferOwnership(address newOwner) external onlyOwner { - require(newOwner != address(0), "Ownable: zero owner"); - emit OwnershipTransferred(owner, newOwner); - owner = newOwner; + if (block.timestamp >= schedule.lockEnd) { + revert LockClosed(block.timestamp, schedule.lockEnd); } - } - - contract TermReceiptToken is ERC20Base { - address public immutable minter; - - modifier onlyMinter() { - require(msg.sender == minter, "Receipt: unauthorized"); - _; + if (receiver == address(0)) { + revert ZeroAddress(); } - - constructor(string memory name_, string memory symbol_, uint8 decimals_, address minter_) - ERC20Base(name_, symbol_, decimals_) - { - minter = minter_; + if (shareAmount == 0) { + revert ZeroAmount(); } - function mint(address to, uint256 value) external onlyMinter { - _mint(to, value); - } + receiptAmount = shareAmount; + IERC20(address(vault)).safeTransferFrom(msg.sender, address(this), shareAmount); + receiptToken.mint(receiver, receiptAmount); - function burn(address from, uint256 value) external onlyMinter { - _burn(from, value); - } + emit Locked(msg.sender, receiver, shareAmount); } - contract TermRedeemer is Ownable { - struct Schedule { - uint64 lockStart; - uint64 lockEnd; - uint64 redeemStart; + function lockRedemptionRate() public returns (uint256 assetPerShare) { + if (block.timestamp < schedule.lockEnd) { + revert LockWindowActive(block.timestamp, schedule.lockEnd); } - - IMaxVault public immutable vault; - IERC20Minimal public immutable asset; - TermReceiptToken public immutable receiptToken; - address public immutable residualSharesSpender; - uint256 public immutable shareScale; - Schedule public schedule; - - uint256 public lockedAssetPerShare; - bool public rateLocked; - bool public redemptionPrepared; - - event Locked(address indexed account, address indexed receiver, uint256 shareAmount); - event RedemptionRateLocked(uint256 assetPerShare); - event RedemptionPrepared(uint256 assetsWithdrawn, uint256 residualSharesApproved); - event Redeemed(address indexed account, address indexed receiver, uint256 receiptAmount, uint256 assetAmount); - - constructor( - address owner_, - address vault_, - address residualSharesSpender_, - uint64 lockStart_, - uint64 lockEnd_, - uint64 redeemStart_ - ) Ownable(owner_) { - require(vault_ != address(0), "TermRedeemer: zero vault"); - require(residualSharesSpender_ != address(0), "TermRedeemer: zero spender"); - require(lockStart_ < lockEnd_, "TermRedeemer: invalid lock window"); - require(lockEnd_ <= redeemStart_, "TermRedeemer: invalid redeem time"); - - vault = IMaxVault(vault_); - asset = IERC20Minimal(IMaxVault(vault_).asset()); - residualSharesSpender = residualSharesSpender_; - schedule = Schedule({lockStart: lockStart_, lockEnd: lockEnd_, redeemStart: redeemStart_}); - - uint8 shareDecimals = IERC20MetadataMinimal(vault_).decimals(); - shareScale = 10 ** uint256(shareDecimals); - - receiptToken = new TermReceiptToken( - string.concat("Withdrawable ", IERC20MetadataMinimal(vault_).name()), - string.concat("w", IERC20MetadataMinimal(vault_).symbol()), - shareDecimals, - address(this) - ); + if (rateLocked) { + revert RateAlreadyLocked(); } - function lock(uint256 shareAmount, address receiver) external returns (uint256 receiptAmount) { - require(block.timestamp >= schedule.lockStart, "TermRedeemer: lock not started"); - require(block.timestamp < schedule.lockEnd, "TermRedeemer: lock closed"); - require(receiver != address(0), "TermRedeemer: zero receiver"); - require(shareAmount > 0, "TermRedeemer: zero amount"); - - receiptAmount = shareAmount; - require(vault.transferFrom(msg.sender, address(this), shareAmount), "TermRedeemer: share transfer failed"); - receiptToken.mint(receiver, receiptAmount); - - emit Locked(msg.sender, receiver, shareAmount); + assetPerShare = vault.convertToAssets(shareScale); + if (assetPerShare == 0) { + revert ZeroRate(); } - function lockRedemptionRate() public returns (uint256 assetPerShare) { - require(block.timestamp >= schedule.lockEnd, "TermRedeemer: lock window active"); - require(!rateLocked, "TermRedeemer: rate locked"); - - assetPerShare = vault.convertToAssets(shareScale); - require(assetPerShare > 0, "TermRedeemer: zero rate"); + lockedAssetPerShare = assetPerShare; + rateLocked = true; - lockedAssetPerShare = assetPerShare; - rateLocked = true; + emit RedemptionRateLocked(assetPerShare); + } - emit RedemptionRateLocked(assetPerShare); + function prepareRedemption() external onlyOwner returns (uint256 assetsNeeded, uint256 residualShares) { + if (block.timestamp < schedule.redeemStart) { + revert RedeemNotStarted(block.timestamp, schedule.redeemStart); + } + if (redemptionPrepared) { + revert RedemptionAlreadyPrepared(); } - function prepareRedemption() external onlyOwner returns (uint256 assetsNeeded, uint256 residualShares) { - require(block.timestamp >= schedule.redeemStart, "TermRedeemer: redeem not started"); - require(!redemptionPrepared, "TermRedeemer: redemption prepared"); + if (!rateLocked) { + lockRedemptionRate(); + } - if (!rateLocked) { - lockRedemptionRate(); - } + assetsNeeded = previewAssetsOwed(receiptToken.totalSupply()); + if (assetsNeeded > 0) { + vault.withdrawAsset(assetsNeeded, address(this)); + } - assetsNeeded = previewAssetsOwed(receiptToken.totalSupply()); - if (assetsNeeded > 0) { - vault.withdrawAsset(assetsNeeded, address(this)); - } + residualShares = vault.balanceOf(address(this)); + IERC20(address(vault)).forceApprove(residualSharesSpender, residualShares); - residualShares = vault.balanceOf(address(this)); - require(vault.approve(residualSharesSpender, residualShares), "TermRedeemer: approve failed"); + redemptionPrepared = true; - redemptionPrepared = true; + emit RedemptionPrepared(assetsNeeded, residualShares); + } - emit RedemptionPrepared(assetsNeeded, residualShares); + function redeem(uint256 receiptAmount, address receiver) external returns (uint256 assetAmount) { + if (block.timestamp < schedule.redeemStart) { + revert RedeemNotStarted(block.timestamp, schedule.redeemStart); + } + if (!rateLocked) { + revert RateNotLocked(); + } + if (receiver == address(0)) { + revert ZeroAddress(); + } + if (receiptAmount == 0) { + revert ZeroAmount(); } - function redeem(uint256 receiptAmount, address receiver) external returns (uint256 assetAmount) { - require(block.timestamp >= schedule.redeemStart, "TermRedeemer: redeem not started"); - require(rateLocked, "TermRedeemer: rate not locked"); - require(receiver != address(0), "TermRedeemer: zero receiver"); - require(receiptAmount > 0, "TermRedeemer: zero amount"); - - assetAmount = previewAssetsOwed(receiptAmount); - receiptToken.burn(msg.sender, receiptAmount); - require(asset.transfer(receiver, assetAmount), "TermRedeemer: asset transfer failed"); + assetAmount = previewAssetsOwed(receiptAmount); + receiptToken.burn(msg.sender, receiptAmount); + asset.safeTransfer(receiver, assetAmount); - emit Redeemed(msg.sender, receiver, receiptAmount, assetAmount); - } + emit Redeemed(msg.sender, receiver, receiptAmount, assetAmount); + } - function previewAssetsOwed(uint256 receiptAmount) public view returns (uint256) { - return receiptAmount * lockedAssetPerShare / shareScale; - } + function previewAssetsOwed(uint256 receiptAmount) public view returns (uint256) { + return receiptAmount * lockedAssetPerShare / shareScale; } +} diff --git a/test/TermRedeemer.t.sol b/test/TermRedeemer.t.sol index 587f433..3949fb7 100644 --- a/test/TermRedeemer.t.sol +++ b/test/TermRedeemer.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.20; import {Test} from "forge-std/Test.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; import {MockMaxVault} from "./mocks/MockMaxVault.sol"; -import {TermRedeemer, TermReceiptToken} from "../src/TermRedeemer.sol"; +import {LockClosed, LockNotStarted, RedeemNotStarted, TermReceiptToken, TermRedeemer} from "../src/TermRedeemer.sol"; contract TermRedeemerTest is Test { uint64 internal constant LOCK_START = 100; @@ -51,12 +51,12 @@ contract TermRedeemerTest is Test { } function test_LockRevertsOutsideWindow() public { - vm.expectRevert("TermRedeemer: lock not started"); + vm.expectRevert(abi.encodeWithSelector(LockNotStarted.selector, 1, LOCK_START)); vm.prank(ALICE); redeemer.lock(1 ether, ALICE); vm.warp(LOCK_END); - vm.expectRevert("TermRedeemer: lock closed"); + vm.expectRevert(abi.encodeWithSelector(LockClosed.selector, LOCK_END, LOCK_END)); vm.prank(ALICE); redeemer.lock(1 ether, ALICE); } @@ -136,7 +136,7 @@ contract TermRedeemerTest is Test { vm.warp(LOCK_END); redeemer.lockRedemptionRate(); - vm.expectRevert("TermRedeemer: redeem not started"); + vm.expectRevert(abi.encodeWithSelector(RedeemNotStarted.selector, LOCK_END, REDEEM_START)); vm.prank(ALICE); redeemer.redeem(10 ether, ALICE); } diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol index 95339ee..bcce99b 100644 --- a/test/mocks/MockERC20.sol +++ b/test/mocks/MockERC20.sol @@ -1,12 +1,20 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import {ERC20Base} from "../../src/TermRedeemer.sol"; +import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; -contract MockERC20 is ERC20Base { - constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20Base(name_, symbol_, decimals_) {} +contract MockERC20 is ERC20 { + uint8 private immutable tokenDecimals; + + constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) { + tokenDecimals = decimals_; + } function mint(address to, uint256 value) external { _mint(to, value); } + + function decimals() public view override returns (uint8) { + return tokenDecimals; + } } diff --git a/test/mocks/MockMaxVault.sol b/test/mocks/MockMaxVault.sol index 1a6b312..fb0782b 100644 --- a/test/mocks/MockMaxVault.sol +++ b/test/mocks/MockMaxVault.sol @@ -1,18 +1,27 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import {ERC20Base, IERC20Minimal, IMaxVault} from "../../src/TermRedeemer.sol"; +import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IMaxVault} from "../../src/TermRedeemer.sol"; +import {IERC20Metadata} from "openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +error MockMaxVaultZeroReceiver(); + +contract MockMaxVault is ERC20, IMaxVault { + using SafeERC20 for IERC20; -contract MockMaxVault is ERC20Base, IMaxVault { address public immutable override asset; uint256 public assetsPerShare; - IERC20Minimal internal immutable assetToken; - constructor(address asset_, string memory name_, string memory symbol_, uint8 decimals_) - ERC20Base(name_, symbol_, decimals_) - { + IERC20 internal immutable assetToken; + uint8 private immutable tokenDecimals; + + constructor(address asset_, string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) { asset = asset_; - assetToken = IERC20Minimal(asset_); + assetToken = IERC20(asset_); + tokenDecimals = decimals_; assetsPerShare = 10 ** uint256(decimals_); } @@ -24,17 +33,23 @@ contract MockMaxVault is ERC20Base, IMaxVault { _mint(to, shares); } + function decimals() public view override(ERC20, IERC20Metadata) returns (uint8) { + return tokenDecimals; + } + function convertToAssets(uint256 shares) external view returns (uint256) { - return shares * assetsPerShare / (10 ** uint256(decimals)); + return shares * assetsPerShare / (10 ** uint256(tokenDecimals)); } function withdrawAsset(uint256 assets, address receiver) external returns (uint256 sharesBurned) { - require(receiver != address(0), "MockMaxVault: zero receiver"); + if (receiver == address(0)) { + revert MockMaxVaultZeroReceiver(); + } - uint256 shareScale = 10 ** uint256(decimals); + uint256 shareScale = 10 ** uint256(tokenDecimals); sharesBurned = (assets * shareScale + assetsPerShare - 1) / assetsPerShare; _burn(msg.sender, sharesBurned); - require(assetToken.transfer(receiver, assets), "MockMaxVault: transfer failed"); + assetToken.safeTransfer(receiver, assets); } } From 9e8d6f059e0904ee7f3ed953bc13e3a64caac372 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Thu, 14 May 2026 14:40:09 +0300 Subject: [PATCH 05/36] cosmetic --- lib/forge-std | 2 +- src/TermRedeemer.sol | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/forge-std b/lib/forge-std index c90c02f..620536f 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit c90c02ffa470b46d92ffaab093baeb296300870b +Subproject commit 620536fa5277db4e3fd46772d5cbc1ea0696fb43 diff --git a/src/TermRedeemer.sol b/src/TermRedeemer.sol index a5a3de3..380e17e 100644 --- a/src/TermRedeemer.sol +++ b/src/TermRedeemer.sol @@ -10,7 +10,7 @@ import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/Safe interface IMaxVault is IERC20Metadata { function asset() external view returns (address); function convertToAssets(uint256 shares) external view returns (uint256); - function withdrawAsset(uint256 assets, address receiver) external returns (uint256 sharesBurned); + function withdrawAsset(address asset, uint256 assets, address receiver, address owner) external returns (uint256 sharesBurned); } error ZeroAddress(); @@ -142,6 +142,7 @@ contract TermRedeemer is Ownable { emit Locked(msg.sender, receiver, shareAmount); } + function lockRedemptionRate() public returns (uint256 assetPerShare) { if (block.timestamp < schedule.lockEnd) { revert LockWindowActive(block.timestamp, schedule.lockEnd); @@ -161,7 +162,7 @@ contract TermRedeemer is Ownable { emit RedemptionRateLocked(assetPerShare); } - function prepareRedemption() external onlyOwner returns (uint256 assetsNeeded, uint256 residualShares) { + function startRedemption() external returns (uint256 assetsNeeded, uint256 residualShares) { if (block.timestamp < schedule.redeemStart) { revert RedeemNotStarted(block.timestamp, schedule.redeemStart); } @@ -208,6 +209,6 @@ contract TermRedeemer is Ownable { } function previewAssetsOwed(uint256 receiptAmount) public view returns (uint256) { - return receiptAmount * lockedAssetPerShare / shareScale; + return mulDiv(receiptAmount, lockedAssetPerShare, shareScale); } } From 114b0ca93db9778ede876400c88b6e2d0d1de400 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Thu, 14 May 2026 14:45:25 +0000 Subject: [PATCH 06/36] Standardize redeemer entrypoints --- src/TermRedeemer.sol | 42 ++++++++++++++++++++++----------- test/TermRedeemer.t.sol | 46 +++++++++++++++++++++++++++---------- test/mocks/MockMaxVault.sol | 10 ++++++-- 3 files changed, 71 insertions(+), 27 deletions(-) diff --git a/src/TermRedeemer.sol b/src/TermRedeemer.sol index 380e17e..99d8ecf 100644 --- a/src/TermRedeemer.sol +++ b/src/TermRedeemer.sol @@ -6,11 +6,14 @@ import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "openzeppelin-contracts/contracts/utils/math/Math.sol"; interface IMaxVault is IERC20Metadata { function asset() external view returns (address); function convertToAssets(uint256 shares) external view returns (uint256); - function withdrawAsset(address asset, uint256 assets, address receiver, address owner) external returns (uint256 sharesBurned); + function withdrawAsset(address asset, uint256 assets, address receiver, address owner) + external + returns (uint256 sharesBurned); } error ZeroAddress(); @@ -57,12 +60,22 @@ contract TermReceiptToken is ERC20 { _burn(from, value); } + function burnFrom(address from, address spender, uint256 value) external { + if (msg.sender != minter) { + revert ReceiptUnauthorized(msg.sender); + } + + _spendAllowance(from, spender, value); + _burn(from, value); + } + function decimals() public view override returns (uint8) { return tokenDecimals; } } contract TermRedeemer is Ownable { + using Math for uint256; using SafeERC20 for IERC20; struct Schedule { @@ -121,7 +134,7 @@ contract TermRedeemer is Ownable { ); } - function lock(uint256 shareAmount, address receiver) external returns (uint256 receiptAmount) { + function deposit(uint256 shareAmount, address receiver) external returns (uint256 receiptAmount) { if (block.timestamp < schedule.lockStart) { revert LockNotStarted(block.timestamp, schedule.lockStart); } @@ -142,7 +155,6 @@ contract TermRedeemer is Ownable { emit Locked(msg.sender, receiver, shareAmount); } - function lockRedemptionRate() public returns (uint256 assetPerShare) { if (block.timestamp < schedule.lockEnd) { revert LockWindowActive(block.timestamp, schedule.lockEnd); @@ -162,7 +174,7 @@ contract TermRedeemer is Ownable { emit RedemptionRateLocked(assetPerShare); } - function startRedemption() external returns (uint256 assetsNeeded, uint256 residualShares) { + function prepareRedemption() external onlyOwner returns (uint256 assetsNeeded, uint256 residualShares) { if (block.timestamp < schedule.redeemStart) { revert RedeemNotStarted(block.timestamp, schedule.redeemStart); } @@ -174,9 +186,9 @@ contract TermRedeemer is Ownable { lockRedemptionRate(); } - assetsNeeded = previewAssetsOwed(receiptToken.totalSupply()); + assetsNeeded = previewRedeem(receiptToken.totalSupply()); if (assetsNeeded > 0) { - vault.withdrawAsset(assetsNeeded, address(this)); + vault.withdrawAsset(address(asset), assetsNeeded, address(this), address(this)); } residualShares = vault.balanceOf(address(this)); @@ -187,28 +199,32 @@ contract TermRedeemer is Ownable { emit RedemptionPrepared(assetsNeeded, residualShares); } - function redeem(uint256 receiptAmount, address receiver) external returns (uint256 assetAmount) { + function redeem(uint256 receiptAmount, address receiver, address owner_) external returns (uint256 assetAmount) { if (block.timestamp < schedule.redeemStart) { revert RedeemNotStarted(block.timestamp, schedule.redeemStart); } if (!rateLocked) { revert RateNotLocked(); } - if (receiver == address(0)) { + if (receiver == address(0) || owner_ == address(0)) { revert ZeroAddress(); } if (receiptAmount == 0) { revert ZeroAmount(); } - assetAmount = previewAssetsOwed(receiptAmount); - receiptToken.burn(msg.sender, receiptAmount); + assetAmount = previewRedeem(receiptAmount); + if (msg.sender == owner_) { + receiptToken.burn(owner_, receiptAmount); + } else { + receiptToken.burnFrom(owner_, msg.sender, receiptAmount); + } asset.safeTransfer(receiver, assetAmount); - emit Redeemed(msg.sender, receiver, receiptAmount, assetAmount); + emit Redeemed(owner_, receiver, receiptAmount, assetAmount); } - function previewAssetsOwed(uint256 receiptAmount) public view returns (uint256) { - return mulDiv(receiptAmount, lockedAssetPerShare, shareScale); + function previewRedeem(uint256 receiptAmount) public view returns (uint256) { + return receiptAmount.mulDiv(lockedAssetPerShare, shareScale); } } diff --git a/test/TermRedeemer.t.sol b/test/TermRedeemer.t.sol index 3949fb7..5fcf5bd 100644 --- a/test/TermRedeemer.t.sol +++ b/test/TermRedeemer.t.sol @@ -42,7 +42,7 @@ contract TermRedeemerTest is Test { vm.warp(LOCK_START); vm.prank(ALICE); - uint256 minted = redeemer.lock(10 ether, ALICE); + uint256 minted = redeemer.deposit(10 ether, ALICE); assertEq(minted, 10 ether); assertEq(receipt.balanceOf(ALICE), 10 ether); @@ -53,18 +53,18 @@ contract TermRedeemerTest is Test { function test_LockRevertsOutsideWindow() public { vm.expectRevert(abi.encodeWithSelector(LockNotStarted.selector, 1, LOCK_START)); vm.prank(ALICE); - redeemer.lock(1 ether, ALICE); + redeemer.deposit(1 ether, ALICE); vm.warp(LOCK_END); vm.expectRevert(abi.encodeWithSelector(LockClosed.selector, LOCK_END, LOCK_END)); vm.prank(ALICE); - redeemer.lock(1 ether, ALICE); + redeemer.deposit(1 ether, ALICE); } function test_RedemptionUsesRateLockedAtEndOfLockPhase() public { vm.warp(LOCK_START); vm.prank(ALICE); - redeemer.lock(100 ether, ALICE); + redeemer.deposit(100 ether, ALICE); vault.setAssetsPerShare(2e6); @@ -79,7 +79,7 @@ contract TermRedeemerTest is Test { redeemer.prepareRedemption(); vm.prank(ALICE); - uint256 redeemed = redeemer.redeem(100 ether, ALICE); + uint256 redeemed = redeemer.redeem(100 ether, ALICE, ALICE); assertEq(redeemed, 200e6); assertEq(usdc.balanceOf(ALICE), 200e6); @@ -89,10 +89,10 @@ contract TermRedeemerTest is Test { vm.warp(LOCK_START); vm.prank(ALICE); - redeemer.lock(100 ether, ALICE); + redeemer.deposit(100 ether, ALICE); vm.prank(BOB); - redeemer.lock(50 ether, BOB); + redeemer.deposit(50 ether, BOB); vault.setAssetsPerShare(2e6); @@ -110,7 +110,7 @@ contract TermRedeemerTest is Test { function test_PrepareRedemptionLeavesResidualSharesForTrustedSpenderWhenYieldAccruedAfterSnapshot() public { vm.warp(LOCK_START); vm.prank(ALICE); - redeemer.lock(100 ether, ALICE); + redeemer.deposit(100 ether, ALICE); vault.setAssetsPerShare(2e6); @@ -131,20 +131,20 @@ contract TermRedeemerTest is Test { function test_RedeemRevertsBeforeMaturity() public { vm.warp(LOCK_START); vm.prank(ALICE); - redeemer.lock(10 ether, ALICE); + redeemer.deposit(10 ether, ALICE); vm.warp(LOCK_END); redeemer.lockRedemptionRate(); vm.expectRevert(abi.encodeWithSelector(RedeemNotStarted.selector, LOCK_END, REDEEM_START)); vm.prank(ALICE); - redeemer.redeem(10 ether, ALICE); + redeemer.redeem(10 ether, ALICE, ALICE); } function test_ReceiptCanTradeBeforeRedemptionAndHolderCanRedeem() public { vm.warp(LOCK_START); vm.prank(ALICE); - redeemer.lock(40 ether, ALICE); + redeemer.deposit(40 ether, ALICE); vm.prank(ALICE); receipt.transfer(BOB, 15 ether); @@ -156,10 +156,32 @@ contract TermRedeemerTest is Test { redeemer.prepareRedemption(); vm.prank(BOB); - uint256 redeemed = redeemer.redeem(15 ether, BOB); + uint256 redeemed = redeemer.redeem(15 ether, BOB, BOB); assertEq(redeemed, 30e6); assertEq(usdc.balanceOf(BOB), 30e6); assertEq(receipt.balanceOf(BOB), 0); } + + function test_ApprovedOperatorCanRedeemForOwner() public { + vm.warp(LOCK_START); + vm.prank(ALICE); + redeemer.deposit(25 ether, ALICE); + + vault.setAssetsPerShare(2e6); + + vm.warp(REDEEM_START); + vm.prank(OWNER); + redeemer.prepareRedemption(); + + vm.prank(ALICE); + receipt.approve(BOB, 10 ether); + + vm.prank(BOB); + uint256 redeemed = redeemer.redeem(10 ether, BOB, ALICE); + + assertEq(redeemed, 20e6); + assertEq(usdc.balanceOf(BOB), 20e6); + assertEq(receipt.balanceOf(ALICE), 15 ether); + } } diff --git a/test/mocks/MockMaxVault.sol b/test/mocks/MockMaxVault.sol index fb0782b..daa1a8b 100644 --- a/test/mocks/MockMaxVault.sol +++ b/test/mocks/MockMaxVault.sol @@ -41,7 +41,10 @@ contract MockMaxVault is ERC20, IMaxVault { return shares * assetsPerShare / (10 ** uint256(tokenDecimals)); } - function withdrawAsset(uint256 assets, address receiver) external returns (uint256 sharesBurned) { + function withdrawAsset(address, uint256 assets, address receiver, address owner) + external + returns (uint256 sharesBurned) + { if (receiver == address(0)) { revert MockMaxVaultZeroReceiver(); } @@ -49,7 +52,10 @@ contract MockMaxVault is ERC20, IMaxVault { uint256 shareScale = 10 ** uint256(tokenDecimals); sharesBurned = (assets * shareScale + assetsPerShare - 1) / assetsPerShare; - _burn(msg.sender, sharesBurned); + if (owner != msg.sender) { + _spendAllowance(owner, msg.sender, sharesBurned); + } + _burn(owner, sharesBurned); assetToken.safeTransfer(receiver, assets); } } From c6b015b095cb79ef0d282f1479645ad749327280 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Thu, 14 May 2026 18:38:14 +0000 Subject: [PATCH 07/36] Refactor term redeemer lifecycle control --- .gitmodules | 6 +- contracts/ProcessAccountingToggleHooks.sol | 76 +++++ contracts/TermRedeemer.sol | 66 +++++ contracts/TermRedeemerController.sol | 95 +++++++ foundry.toml | 13 +- lib/yieldnest-vault | 1 + remappings.txt | 6 + test/TermRedeemer.t.sol | 315 +++++++++++++-------- test/mocks/MockERC20.sol | 2 +- test/mocks/MockRateProvider.sol | 16 ++ test/mocks/MockYnRwa.sol | 41 +++ 11 files changed, 512 insertions(+), 125 deletions(-) create mode 100644 contracts/ProcessAccountingToggleHooks.sol create mode 100644 contracts/TermRedeemer.sol create mode 100644 contracts/TermRedeemerController.sol create mode 160000 lib/yieldnest-vault create mode 100644 remappings.txt create mode 100644 test/mocks/MockRateProvider.sol create mode 100644 test/mocks/MockYnRwa.sol diff --git a/.gitmodules b/.gitmodules index 690924b..83cead5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std -[submodule "lib/openzeppelin-contracts"] - path = lib/openzeppelin-contracts - url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/yieldnest-vault"] + path = lib/yieldnest-vault + url = https://github.com/yieldnest/yieldnest-vault diff --git a/contracts/ProcessAccountingToggleHooks.sol b/contracts/ProcessAccountingToggleHooks.sol new file mode 100644 index 0000000..40bb99f --- /dev/null +++ b/contracts/ProcessAccountingToggleHooks.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {IHooks} from "yieldnest-vault/src/interface/IHooks.sol"; +import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; + +error ProcessAccountingBlocked(); + +contract ProcessAccountingToggleHooks is AccessControl, IHooks { + bytes32 public constant TOGGLER_ROLE = keccak256("TOGGLER_ROLE"); + + IVault public immutable VAULT; + Config public config; + bool public processAccountingBlocked; + + event SetProcessAccountingBlocked(bool blocked); + + constructor(address vault_, address admin_, Config memory config_) { + VAULT = IVault(payable(vault_)); + config = config_; + + _grantRole(DEFAULT_ADMIN_ROLE, admin_); + _grantRole(TOGGLER_ROLE, admin_); + } + + modifier onlyVault() { + if (msg.sender != address(VAULT)) { + revert CallerNotVault(); + } + _; + } + + function name() external pure returns (string memory) { + return "ProcessAccountingToggleHooks"; + } + + function setConfig(Config memory config_) external onlyRole(DEFAULT_ADMIN_ROLE) { + Config memory oldConfig = config; + config = config_; + emit SetConfig(oldConfig, config_); + } + + function getConfig() external view returns (Config memory) { + return config; + } + + function setProcessAccountingBlocked(bool blocked) external onlyRole(TOGGLER_ROLE) { + processAccountingBlocked = blocked; + emit SetProcessAccountingBlocked(blocked); + } + + function beforeProcessAccounting(BeforeProcessAccountingParams calldata) external view onlyVault { + if (processAccountingBlocked) { + revert ProcessAccountingBlocked(); + } + } + + function beforeDeposit(DepositParams calldata) external onlyVault {} + + function afterDeposit(DepositParams calldata) external onlyVault {} + + function beforeMint(MintParams calldata) external onlyVault {} + + function afterMint(MintParams calldata) external onlyVault {} + + function beforeRedeem(RedeemParams calldata) external onlyVault {} + + function afterRedeem(RedeemParams calldata) external onlyVault {} + + function beforeWithdraw(WithdrawParams calldata) external onlyVault {} + + function afterWithdraw(WithdrawParams calldata) external onlyVault {} + + function afterProcessAccounting(AfterProcessAccountingParams calldata) external onlyVault {} +} diff --git a/contracts/TermRedeemer.sol b/contracts/TermRedeemer.sol new file mode 100644 index 0000000..e516a0c --- /dev/null +++ b/contracts/TermRedeemer.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {BaseStrategy} from "yieldnest-vault/src/strategy/BaseStrategy.sol"; +import {VaultLib} from "yieldnest-vault/src/library/VaultLib.sol"; + +error ZeroAddress(); + +contract TermRedeemer is BaseStrategy { + string public constant TERM_REDEEMER_VERSION = "1.0.0"; + + address public baseAsset; + address public redemptionAsset; + address public depositToken; + + function initialize( + address admin, + address provider_, + address baseAsset_, + address redemptionAsset_, + address depositToken_, + string memory name_, + string memory symbol_ + ) external initializer { + if ( + admin == address(0) || provider_ == address(0) || baseAsset_ == address(0) || redemptionAsset_ == address(0) + || depositToken_ == address(0) + ) { + revert ZeroAddress(); + } + + _initialize(admin, name_, symbol_, IERC20Metadata(baseAsset_).decimals(), false, false, false, 1); + + _grantRole(PROCESSOR_ROLE, admin); + _grantRole(PROCESSOR_MANAGER_ROLE, admin); + _grantRole(PROVIDER_MANAGER_ROLE, admin); + _grantRole(ASSET_MANAGER_ROLE, admin); + _grantRole(HOOKS_MANAGER_ROLE, admin); + _grantRole(PAUSER_ROLE, admin); + _grantRole(UNPAUSER_ROLE, admin); + + VaultLib.setProvider(provider_); + + baseAsset = baseAsset_; + redemptionAsset = redemptionAsset_; + depositToken = depositToken_; + + _addAsset(baseAsset_, IERC20Metadata(baseAsset_).decimals(), false); + _setAssetWithdrawable(baseAsset_, false); + + _addAsset(redemptionAsset_, IERC20Metadata(redemptionAsset_).decimals(), false); + _setAssetWithdrawable(redemptionAsset_, false); + + _addAsset(depositToken_, IERC20Metadata(depositToken_).decimals(), true); + _setAssetWithdrawable(depositToken_, false); + } + + function _feeOnRaw(uint256, address) public pure override returns (uint256) { + return 0; + } + + function _feeOnTotal(uint256, address) public pure override returns (uint256) { + return 0; + } +} diff --git a/contracts/TermRedeemerController.sol b/contracts/TermRedeemerController.sol new file mode 100644 index 0000000..0fb6eb8 --- /dev/null +++ b/contracts/TermRedeemerController.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; +import {TermRedeemer} from "./TermRedeemer.sol"; +import {ProcessAccountingToggleHooks} from "./ProcessAccountingToggleHooks.sol"; + +error LockNotReady(uint256 timestamp, uint64 lockEnd); +error RedeemNotReady(uint256 timestamp, uint64 redeemStart); +error AlreadyLocked(); +error NotLocked(); +error RedemptionAlreadyActivated(); +error DepositTokenNotUnwound(uint256 remainingBalance); +error InsufficientRedemptionFunding(uint256 requiredAssets, uint256 availableAssets); + +contract TermRedeemerController { + TermRedeemer public immutable vault; + ProcessAccountingToggleHooks public immutable hooks; + address public immutable depositToken; + address public immutable redemptionAsset; + uint64 public immutable lockEnd; + uint64 public immutable redeemStart; + + bool public locked; + bool public redemptionActivated; + + event Locked(uint256 totalAssetsSnapshot); + event RedemptionActivated(uint256 requiredAssets); + + constructor( + address vault_, + address hooks_, + address depositToken_, + address redemptionAsset_, + uint64 lockEnd_, + uint64 redeemStart_ + ) { + vault = TermRedeemer(payable(vault_)); + hooks = ProcessAccountingToggleHooks(hooks_); + depositToken = depositToken_; + redemptionAsset = redemptionAsset_; + lockEnd = lockEnd_; + redeemStart = redeemStart_; + } + + function lock() external returns (uint256 totalAssetsSnapshot) { + if (block.timestamp < lockEnd) { + revert LockNotReady(block.timestamp, lockEnd); + } + if (locked) { + revert AlreadyLocked(); + } + + vault.processAccounting(); + + IVault.AssetParams memory params = vault.getAsset(depositToken); + IVault.AssetUpdateFields memory fields = IVault.AssetUpdateFields({active: false}); + vault.updateAsset(params.index, fields); + hooks.setProcessAccountingBlocked(true); + + locked = true; + totalAssetsSnapshot = vault.totalAssets(); + + emit Locked(totalAssetsSnapshot); + } + + function activateRedemption() external returns (uint256 requiredAssets) { + if (block.timestamp < redeemStart) { + revert RedeemNotReady(block.timestamp, redeemStart); + } + if (redemptionActivated) { + revert RedemptionAlreadyActivated(); + } + if (!locked) { + revert NotLocked(); + } + + uint256 remainingDepositTokenBalance = IERC20(depositToken).balanceOf(address(vault)); + if (remainingDepositTokenBalance != 0) { + revert DepositTokenNotUnwound(remainingDepositTokenBalance); + } + + requiredAssets = vault.previewRedeem(vault.totalSupply()); + uint256 availableAssets = IERC20(redemptionAsset).balanceOf(address(vault)); + if (availableAssets < requiredAssets) { + revert InsufficientRedemptionFunding(requiredAssets, availableAssets); + } + + vault.setAssetWithdrawable(redemptionAsset, true); + redemptionActivated = true; + + emit RedemptionActivated(requiredAssets); + } +} diff --git a/foundry.toml b/foundry.toml index 25b918f..402de08 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,6 +1,17 @@ [profile.default] -src = "src" +src = "contracts" out = "out" libs = ["lib"] +remappings = [ + "src/=lib/yieldnest-vault/src/", + "script/=lib/yieldnest-vault/script/", + "@openzeppelin/contracts/=lib/yieldnest-vault/lib/openzeppelin-contracts/contracts/", + "@openzeppelin/contracts-upgradeable/=lib/yieldnest-vault/lib/openzeppelin-contracts-upgradeable/contracts/", + "lib/openzeppelin-contracts/=lib/yieldnest-vault/lib/openzeppelin-contracts/", + "lib/openzeppelin-contracts-upgradeable/=lib/yieldnest-vault/lib/openzeppelin-contracts-upgradeable/" +] +via_ir = true +optimizer = true +optimizer_runs = 200 # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/yieldnest-vault b/lib/yieldnest-vault new file mode 160000 index 0000000..b71905f --- /dev/null +++ b/lib/yieldnest-vault @@ -0,0 +1 @@ +Subproject commit b71905f7db559d6d5491840c68a365a8bd1c8f5e diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..bbf3026 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,6 @@ +src/=lib/yieldnest-vault/src/ +script/=lib/yieldnest-vault/script/ +@openzeppelin/contracts/=lib/yieldnest-vault/lib/openzeppelin-contracts/contracts/ +@openzeppelin/contracts-upgradeable/=lib/yieldnest-vault/lib/openzeppelin-contracts-upgradeable/contracts/ +lib/openzeppelin-contracts/=lib/yieldnest-vault/lib/openzeppelin-contracts/ +lib/openzeppelin-contracts-upgradeable/=lib/yieldnest-vault/lib/openzeppelin-contracts-upgradeable/ diff --git a/test/TermRedeemer.t.sol b/test/TermRedeemer.t.sol index 5fcf5bd..fca352f 100644 --- a/test/TermRedeemer.t.sol +++ b/test/TermRedeemer.t.sol @@ -1,187 +1,262 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; +pragma solidity ^0.8.24; import {Test} from "forge-std/Test.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; +import {HooksLib} from "yieldnest-vault/src/library/HooksLib.sol"; +import {TermRedeemer} from "../contracts/TermRedeemer.sol"; +import { + AlreadyLocked, + DepositTokenNotUnwound, + InsufficientRedemptionFunding, + LockNotReady, + RedeemNotReady, + TermRedeemerController +} from "../contracts/TermRedeemerController.sol"; +import {ProcessAccountingBlocked, ProcessAccountingToggleHooks} from "../contracts/ProcessAccountingToggleHooks.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; -import {MockMaxVault} from "./mocks/MockMaxVault.sol"; -import {LockClosed, LockNotStarted, RedeemNotStarted, TermReceiptToken, TermRedeemer} from "../src/TermRedeemer.sol"; +import {MockRateProvider} from "./mocks/MockRateProvider.sol"; +import {MockYnRwa} from "./mocks/MockYnRwa.sol"; +import {IHooks} from "yieldnest-vault/src/interface/IHooks.sol"; contract TermRedeemerTest is Test { - uint64 internal constant LOCK_START = 100; + struct ParamRule { + uint8 paramType; + bool isArray; + address[] allowList; + } + + struct FunctionRule { + bool isActive; + ParamRule[] paramRules; + address validator; + } + uint64 internal constant LOCK_END = 200; uint64 internal constant REDEEM_START = 300; - MockERC20 internal usdc; - MockMaxVault internal vault; - TermRedeemer internal redeemer; - TermReceiptToken internal receipt; - - address internal constant OWNER = address(0xA11CE); + address internal constant ADMIN = address(0xA11CE); address internal constant ALICE = address(0xB0B); address internal constant BOB = address(0xCAFE); - address internal constant RESIDUAL_PULLER = address(0xD00D); + + MockERC20 internal usdc; + MockERC20 internal wrappedUsdc; + MockYnRwa internal ynRwa; + MockRateProvider internal provider; + TermRedeemer internal redeemer; + ProcessAccountingToggleHooks internal accountingHooks; + TermRedeemerController internal controller; function setUp() public { usdc = new MockERC20("USD Coin", "USDC", 6); - vault = new MockMaxVault(address(usdc), "ynMAX", "ynMAX", 18); - redeemer = new TermRedeemer(OWNER, address(vault), RESIDUAL_PULLER, LOCK_START, LOCK_END, REDEEM_START); - receipt = redeemer.receiptToken(); - - vault.mintShares(ALICE, 150 ether); - vault.mintShares(BOB, 50 ether); - usdc.mint(address(vault), 1_000_000e6); + wrappedUsdc = new MockERC20("Wrapped USDC", "wUSDC", 18); + ynRwa = new MockYnRwa(address(usdc)); + provider = new MockRateProvider(); + + provider.setRate(address(wrappedUsdc), 1e18); + provider.setRate(address(usdc), 1e18); + provider.setRate(address(ynRwa), 11e17); + + TermRedeemer implementation = new TermRedeemer(); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(implementation), + ADMIN, + abi.encodeCall( + TermRedeemer.initialize, + ( + ADMIN, + address(provider), + address(wrappedUsdc), + address(usdc), + address(ynRwa), + "Withdrawable ynRWAx", + "wynRWAx" + ) + ) + ); + redeemer = TermRedeemer(payable(address(proxy))); + + IHooks.Config memory config = IHooks.Config({ + beforeDeposit: false, + afterDeposit: false, + beforeMint: false, + afterMint: false, + beforeRedeem: false, + afterRedeem: false, + beforeWithdraw: false, + afterWithdraw: false, + beforeProcessAccounting: true, + afterProcessAccounting: false + }); + accountingHooks = new ProcessAccountingToggleHooks(address(redeemer), ADMIN, config); + controller = new TermRedeemerController( + address(redeemer), address(accountingHooks), address(ynRwa), address(usdc), LOCK_END, REDEEM_START + ); + + vm.startPrank(ADMIN); + redeemer.setHooks(address(accountingHooks)); + redeemer.grantRole(redeemer.ASSET_MANAGER_ROLE(), address(controller)); + accountingHooks.grantRole(accountingHooks.TOGGLER_ROLE(), address(controller)); + vm.stopPrank(); + + ynRwa.mint(ALICE, 200 ether); + ynRwa.mint(BOB, 100 ether); vm.prank(ALICE); - vault.approve(address(redeemer), type(uint256).max); + ynRwa.approve(address(redeemer), type(uint256).max); vm.prank(BOB); - vault.approve(address(redeemer), type(uint256).max); + ynRwa.approve(address(redeemer), type(uint256).max); } - function test_LockMintsOneToOneReceiptDuringWindow() public { - vm.warp(LOCK_START); - + function test_DepositAssetMintsUsdcDenominatedShares() public { vm.prank(ALICE); - uint256 minted = redeemer.deposit(10 ether, ALICE); + uint256 shares = redeemer.depositAsset(address(ynRwa), 100 ether, ALICE); - assertEq(minted, 10 ether); - assertEq(receipt.balanceOf(ALICE), 10 ether); - assertEq(vault.balanceOf(ALICE), 140 ether); - assertEq(vault.balanceOf(address(redeemer)), 10 ether); + assertEq(shares, 110 ether); + assertEq(redeemer.balanceOf(ALICE), 110 ether); + assertEq(ynRwa.balanceOf(address(redeemer)), 100 ether); + assertEq(redeemer.totalBaseAssets(), 110 ether); + assertEq(redeemer.previewRedeem(110 ether), 110e6); } - function test_LockRevertsOutsideWindow() public { - vm.expectRevert(abi.encodeWithSelector(LockNotStarted.selector, 1, LOCK_START)); - vm.prank(ALICE); - redeemer.deposit(1 ether, ALICE); - - vm.warp(LOCK_END); - vm.expectRevert(abi.encodeWithSelector(LockClosed.selector, LOCK_END, LOCK_END)); - vm.prank(ALICE); - redeemer.deposit(1 ether, ALICE); + function test_LockRevertsBeforeLockEnd() public { + vm.expectRevert(abi.encodeWithSelector(LockNotReady.selector, 1, LOCK_END)); + controller.lock(); } - function test_RedemptionUsesRateLockedAtEndOfLockPhase() public { - vm.warp(LOCK_START); + function test_LockDisablesDepositsAndBlocksAccounting() public { vm.prank(ALICE); - redeemer.deposit(100 ether, ALICE); + redeemer.depositAsset(address(ynRwa), 100 ether, ALICE); - vault.setAssetsPerShare(2e6); + provider.setRate(address(ynRwa), 12e17); vm.warp(LOCK_END); - uint256 lockedRate = redeemer.lockRedemptionRate(); - assertEq(lockedRate, 2e6); + uint256 totalAssetsSnapshot = controller.lock(); - vault.setAssetsPerShare(3e6); + assertEq(totalAssetsSnapshot, 120e6); + assertEq(redeemer.totalBaseAssets(), 120 ether); + assertEq(redeemer.previewRedeem(110 ether), 119_999_999); + assertTrue(controller.locked()); + assertTrue(accountingHooks.processAccountingBlocked()); - vm.warp(REDEEM_START); - vm.prank(OWNER); - redeemer.prepareRedemption(); + provider.setRate(address(ynRwa), 14e17); - vm.prank(ALICE); - uint256 redeemed = redeemer.redeem(100 ether, ALICE, ALICE); + vm.expectRevert( + abi.encodeWithSelector( + HooksLib.HookCallFailed.selector, abi.encodeWithSelector(ProcessAccountingBlocked.selector) + ) + ); + redeemer.processAccounting(); - assertEq(redeemed, 200e6); - assertEq(usdc.balanceOf(ALICE), 200e6); + vm.prank(BOB); + vm.expectRevert(IVault.AssetNotActive.selector); + redeemer.depositAsset(address(ynRwa), 10 ether, BOB); + + vm.expectRevert(AlreadyLocked.selector); + controller.lock(); } - function test_PrepareRedemptionWithdrawsExactAssetsAndApprovesResidualShares() public { - vm.warp(LOCK_START); + function test_ActivateRedemptionRevertsBeforeRedeemTime() public { + vm.warp(LOCK_END); + controller.lock(); + + vm.expectRevert(abi.encodeWithSelector(RedeemNotReady.selector, LOCK_END, REDEEM_START)); + controller.activateRedemption(); + } + function test_RedemptionActivatesOnlyAfterUnwindAndFunding() public { vm.prank(ALICE); - redeemer.deposit(100 ether, ALICE); + redeemer.depositAsset(address(ynRwa), 100 ether, ALICE); - vm.prank(BOB); - redeemer.deposit(50 ether, BOB); + provider.setRate(address(ynRwa), 12e17); - vault.setAssetsPerShare(2e6); + vm.warp(LOCK_END); + controller.lock(); vm.warp(REDEEM_START); + vm.expectRevert(abi.encodeWithSelector(DepositTokenNotUnwound.selector, 100 ether)); + controller.activateRedemption(); - vm.prank(OWNER); - (uint256 assetsNeeded, uint256 residualShares) = redeemer.prepareRedemption(); + ynRwa.setUsdcPerShare(1_200_000); + usdc.mint(address(ynRwa), 120e6); - assertEq(assetsNeeded, 300e6); - assertEq(usdc.balanceOf(address(redeemer)), 300e6); - assertEq(residualShares, 0); - assertEq(vault.allowance(address(redeemer), RESIDUAL_PULLER), 0); - } + _allowProcessorRedeem(); - function test_PrepareRedemptionLeavesResidualSharesForTrustedSpenderWhenYieldAccruedAfterSnapshot() public { - vm.warp(LOCK_START); - vm.prank(ALICE); - redeemer.deposit(100 ether, ALICE); + bytes[] memory data = new bytes[](1); + address[] memory targets = new address[](1); + uint256[] memory values = new uint256[](1); - vault.setAssetsPerShare(2e6); + targets[0] = address(ynRwa); + data[0] = abi.encodeWithSelector(MockYnRwa.redeem.selector, 100 ether, address(redeemer), address(redeemer)); - vm.warp(LOCK_END); - redeemer.lockRedemptionRate(); + vm.prank(ADMIN); + redeemer.processor(targets, values, data); - vault.setAssetsPerShare(4e6); + assertEq(ynRwa.balanceOf(address(redeemer)), 0); + assertEq(usdc.balanceOf(address(redeemer)), 120e6); - vm.warp(REDEEM_START); - vm.prank(OWNER); - (, uint256 residualShares) = redeemer.prepareRedemption(); + uint256 requiredAssets = controller.activateRedemption(); + assertEq(requiredAssets, 119_999_999); + assertTrue(controller.redemptionActivated()); + assertEq(redeemer.maxRedeem(ALICE), 110 ether); - assertEq(usdc.balanceOf(address(redeemer)), 200e6); - assertEq(residualShares, 50 ether); - assertEq(vault.allowance(address(redeemer), RESIDUAL_PULLER), 50 ether); - } - - function test_RedeemRevertsBeforeMaturity() public { - vm.warp(LOCK_START); vm.prank(ALICE); - redeemer.deposit(10 ether, ALICE); + uint256 assets = redeemer.redeem(110 ether, ALICE, ALICE); - vm.warp(LOCK_END); - redeemer.lockRedemptionRate(); - - vm.expectRevert(abi.encodeWithSelector(RedeemNotStarted.selector, LOCK_END, REDEEM_START)); - vm.prank(ALICE); - redeemer.redeem(10 ether, ALICE, ALICE); + assertEq(assets, 119_999_999); + assertEq(usdc.balanceOf(ALICE), 119_999_999); + assertEq(redeemer.balanceOf(ALICE), 0); } - function test_ReceiptCanTradeBeforeRedemptionAndHolderCanRedeem() public { - vm.warp(LOCK_START); + function test_ActivateRedemptionRevertsWhenUsdcFundingIsShort() public { vm.prank(ALICE); - redeemer.deposit(40 ether, ALICE); + redeemer.depositAsset(address(ynRwa), 100 ether, ALICE); - vm.prank(ALICE); - receipt.transfer(BOB, 15 ether); + provider.setRate(address(ynRwa), 12e17); - vault.setAssetsPerShare(2e6); + vm.warp(LOCK_END); + controller.lock(); - vm.warp(REDEEM_START); - vm.prank(OWNER); - redeemer.prepareRedemption(); + ynRwa.setUsdcPerShare(1_000_000); + usdc.mint(address(ynRwa), 100e6); - vm.prank(BOB); - uint256 redeemed = redeemer.redeem(15 ether, BOB, BOB); + _allowProcessorRedeem(); - assertEq(redeemed, 30e6); - assertEq(usdc.balanceOf(BOB), 30e6); - assertEq(receipt.balanceOf(BOB), 0); - } + bytes[] memory data = new bytes[](1); + address[] memory targets = new address[](1); + uint256[] memory values = new uint256[](1); - function test_ApprovedOperatorCanRedeemForOwner() public { - vm.warp(LOCK_START); - vm.prank(ALICE); - redeemer.deposit(25 ether, ALICE); + targets[0] = address(ynRwa); + data[0] = abi.encodeWithSelector(MockYnRwa.redeem.selector, 100 ether, address(redeemer), address(redeemer)); - vault.setAssetsPerShare(2e6); + vm.prank(ADMIN); + redeemer.processor(targets, values, data); vm.warp(REDEEM_START); - vm.prank(OWNER); - redeemer.prepareRedemption(); - - vm.prank(ALICE); - receipt.approve(BOB, 10 ether); - - vm.prank(BOB); - uint256 redeemed = redeemer.redeem(10 ether, BOB, ALICE); + vm.expectRevert(abi.encodeWithSelector(InsufficientRedemptionFunding.selector, 119_999_999, 100e6)); + controller.activateRedemption(); + } - assertEq(redeemed, 20e6); - assertEq(usdc.balanceOf(BOB), 20e6); - assertEq(receipt.balanceOf(ALICE), 15 ether); + function _allowProcessorRedeem() internal { + address[] memory allowList = new address[](1); + allowList[0] = address(redeemer); + ParamRule[] memory paramRules = new ParamRule[](3); + paramRules[1] = ParamRule({paramType: 1, isArray: false, allowList: allowList}); + paramRules[2] = ParamRule({paramType: 1, isArray: false, allowList: allowList}); + FunctionRule memory rule = FunctionRule({isActive: true, paramRules: paramRules, validator: address(0)}); + + vm.prank(ADMIN); + (bool ok,) = address(redeemer) + .call( + abi.encodeWithSignature( + "setProcessorRule(address,bytes4,(bool,(uint8,bool,address[])[],address))", + address(ynRwa), + MockYnRwa.redeem.selector, + rule + ) + ); + assertTrue(ok); } } diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol index bcce99b..2923d54 100644 --- a/test/mocks/MockERC20.sol +++ b/test/mocks/MockERC20.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract MockERC20 is ERC20 { uint8 private immutable tokenDecimals; diff --git a/test/mocks/MockRateProvider.sol b/test/mocks/MockRateProvider.sol new file mode 100644 index 0000000..ae4ea70 --- /dev/null +++ b/test/mocks/MockRateProvider.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IProvider} from "yieldnest-vault/src/interface/IProvider.sol"; + +contract MockRateProvider is IProvider { + mapping(address asset => uint256 rate) public rates; + + function setRate(address asset, uint256 rate) external { + rates[asset] = rate; + } + + function getRate(address asset) external view returns (uint256) { + return rates[asset]; + } +} diff --git a/test/mocks/MockYnRwa.sol b/test/mocks/MockYnRwa.sol new file mode 100644 index 0000000..2fb2b02 --- /dev/null +++ b/test/mocks/MockYnRwa.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +contract MockYnRwa is ERC20 { + using Math for uint256; + using SafeERC20 for IERC20; + + IERC20 public immutable usdc; + uint256 public usdcPerShare; + + constructor(address usdc_) ERC20("YieldNest RWA", "ynRWAx") { + usdc = IERC20(usdc_); + } + + function mint(address to, uint256 value) external { + _mint(to, value); + } + + function setUsdcPerShare(uint256 usdcPerShare_) external { + usdcPerShare = usdcPerShare_; + } + + function previewRedeem(uint256 shares) public view returns (uint256 assets) { + assets = shares.mulDiv(usdcPerShare, 10 ** decimals()); + } + + function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets) { + if (msg.sender != owner) { + _spendAllowance(owner, msg.sender, shares); + } + + assets = previewRedeem(shares); + _burn(owner, shares); + usdc.safeTransfer(receiver, assets); + } +} From dec41aa4c05d3febd8adb0995888417a6add9c9b Mon Sep 17 00:00:00 2001 From: danoctavian Date: Thu, 14 May 2026 18:44:31 +0000 Subject: [PATCH 08/36] Remove obsolete scaffold files --- lib/openzeppelin-contracts | 1 - script/Counter.s.sol | 19 --- src/TermRedeemer.sol | 230 ------------------------------------ test/mocks/MockMaxVault.sol | 61 ---------- 4 files changed, 311 deletions(-) delete mode 160000 lib/openzeppelin-contracts delete mode 100644 script/Counter.s.sol delete mode 100644 src/TermRedeemer.sol delete mode 100644 test/mocks/MockMaxVault.sol diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts deleted file mode 160000 index 5fd1781..0000000 --- a/lib/openzeppelin-contracts +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5fd1781b1454fd1ef8e722282f86f9293cacf256 diff --git a/script/Counter.s.sol b/script/Counter.s.sol deleted file mode 100644 index f01d69c..0000000 --- a/script/Counter.s.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script} from "forge-std/Script.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterScript is Script { - Counter public counter; - - function setUp() public {} - - function run() public { - vm.startBroadcast(); - - counter = new Counter(); - - vm.stopBroadcast(); - } -} diff --git a/src/TermRedeemer.sol b/src/TermRedeemer.sol deleted file mode 100644 index 99d8ecf..0000000 --- a/src/TermRedeemer.sol +++ /dev/null @@ -1,230 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; -import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; -import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; -import {IERC20Metadata} from "openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; -import {Math} from "openzeppelin-contracts/contracts/utils/math/Math.sol"; - -interface IMaxVault is IERC20Metadata { - function asset() external view returns (address); - function convertToAssets(uint256 shares) external view returns (uint256); - function withdrawAsset(address asset, uint256 assets, address receiver, address owner) - external - returns (uint256 sharesBurned); -} - -error ZeroAddress(); -error ZeroAmount(); -error InvalidLockWindow(uint64 lockStart, uint64 lockEnd); -error InvalidRedeemTime(uint64 lockEnd, uint64 redeemStart); -error LockNotStarted(uint256 timestamp, uint64 lockStart); -error LockClosed(uint256 timestamp, uint64 lockEnd); -error LockWindowActive(uint256 timestamp, uint64 lockEnd); -error RateAlreadyLocked(); -error ZeroRate(); -error RedeemNotStarted(uint256 timestamp, uint64 redeemStart); -error RedemptionAlreadyPrepared(); -error RateNotLocked(); - -contract TermReceiptToken is ERC20 { - error ReceiptUnauthorized(address caller); - - address public immutable minter; - uint8 private immutable tokenDecimals; - - constructor(string memory name_, string memory symbol_, uint8 decimals_, address minter_) ERC20(name_, symbol_) { - if (minter_ == address(0)) { - revert ZeroAddress(); - } - - minter = minter_; - tokenDecimals = decimals_; - } - - function mint(address to, uint256 value) external { - if (msg.sender != minter) { - revert ReceiptUnauthorized(msg.sender); - } - - _mint(to, value); - } - - function burn(address from, uint256 value) external { - if (msg.sender != minter) { - revert ReceiptUnauthorized(msg.sender); - } - - _burn(from, value); - } - - function burnFrom(address from, address spender, uint256 value) external { - if (msg.sender != minter) { - revert ReceiptUnauthorized(msg.sender); - } - - _spendAllowance(from, spender, value); - _burn(from, value); - } - - function decimals() public view override returns (uint8) { - return tokenDecimals; - } -} - -contract TermRedeemer is Ownable { - using Math for uint256; - using SafeERC20 for IERC20; - - struct Schedule { - uint64 lockStart; - uint64 lockEnd; - uint64 redeemStart; - } - - IMaxVault public immutable vault; - IERC20 public immutable asset; - TermReceiptToken public immutable receiptToken; - address public immutable residualSharesSpender; - uint256 public immutable shareScale; - Schedule public schedule; - - uint256 public lockedAssetPerShare; - bool public rateLocked; - bool public redemptionPrepared; - - event Locked(address indexed account, address indexed receiver, uint256 shareAmount); - event RedemptionRateLocked(uint256 assetPerShare); - event RedemptionPrepared(uint256 assetsWithdrawn, uint256 residualSharesApproved); - event Redeemed(address indexed account, address indexed receiver, uint256 receiptAmount, uint256 assetAmount); - - constructor( - address owner_, - address vault_, - address residualSharesSpender_, - uint64 lockStart_, - uint64 lockEnd_, - uint64 redeemStart_ - ) Ownable(owner_) { - if (vault_ == address(0) || residualSharesSpender_ == address(0)) { - revert ZeroAddress(); - } - if (lockStart_ >= lockEnd_) { - revert InvalidLockWindow(lockStart_, lockEnd_); - } - if (lockEnd_ > redeemStart_) { - revert InvalidRedeemTime(lockEnd_, redeemStart_); - } - - vault = IMaxVault(vault_); - asset = IERC20(IMaxVault(vault_).asset()); - residualSharesSpender = residualSharesSpender_; - schedule = Schedule({lockStart: lockStart_, lockEnd: lockEnd_, redeemStart: redeemStart_}); - - uint8 shareDecimals = IERC20Metadata(vault_).decimals(); - shareScale = 10 ** uint256(shareDecimals); - - receiptToken = new TermReceiptToken( - string.concat("Withdrawable ", IERC20Metadata(vault_).name()), - string.concat("w", IERC20Metadata(vault_).symbol()), - shareDecimals, - address(this) - ); - } - - function deposit(uint256 shareAmount, address receiver) external returns (uint256 receiptAmount) { - if (block.timestamp < schedule.lockStart) { - revert LockNotStarted(block.timestamp, schedule.lockStart); - } - if (block.timestamp >= schedule.lockEnd) { - revert LockClosed(block.timestamp, schedule.lockEnd); - } - if (receiver == address(0)) { - revert ZeroAddress(); - } - if (shareAmount == 0) { - revert ZeroAmount(); - } - - receiptAmount = shareAmount; - IERC20(address(vault)).safeTransferFrom(msg.sender, address(this), shareAmount); - receiptToken.mint(receiver, receiptAmount); - - emit Locked(msg.sender, receiver, shareAmount); - } - - function lockRedemptionRate() public returns (uint256 assetPerShare) { - if (block.timestamp < schedule.lockEnd) { - revert LockWindowActive(block.timestamp, schedule.lockEnd); - } - if (rateLocked) { - revert RateAlreadyLocked(); - } - - assetPerShare = vault.convertToAssets(shareScale); - if (assetPerShare == 0) { - revert ZeroRate(); - } - - lockedAssetPerShare = assetPerShare; - rateLocked = true; - - emit RedemptionRateLocked(assetPerShare); - } - - function prepareRedemption() external onlyOwner returns (uint256 assetsNeeded, uint256 residualShares) { - if (block.timestamp < schedule.redeemStart) { - revert RedeemNotStarted(block.timestamp, schedule.redeemStart); - } - if (redemptionPrepared) { - revert RedemptionAlreadyPrepared(); - } - - if (!rateLocked) { - lockRedemptionRate(); - } - - assetsNeeded = previewRedeem(receiptToken.totalSupply()); - if (assetsNeeded > 0) { - vault.withdrawAsset(address(asset), assetsNeeded, address(this), address(this)); - } - - residualShares = vault.balanceOf(address(this)); - IERC20(address(vault)).forceApprove(residualSharesSpender, residualShares); - - redemptionPrepared = true; - - emit RedemptionPrepared(assetsNeeded, residualShares); - } - - function redeem(uint256 receiptAmount, address receiver, address owner_) external returns (uint256 assetAmount) { - if (block.timestamp < schedule.redeemStart) { - revert RedeemNotStarted(block.timestamp, schedule.redeemStart); - } - if (!rateLocked) { - revert RateNotLocked(); - } - if (receiver == address(0) || owner_ == address(0)) { - revert ZeroAddress(); - } - if (receiptAmount == 0) { - revert ZeroAmount(); - } - - assetAmount = previewRedeem(receiptAmount); - if (msg.sender == owner_) { - receiptToken.burn(owner_, receiptAmount); - } else { - receiptToken.burnFrom(owner_, msg.sender, receiptAmount); - } - asset.safeTransfer(receiver, assetAmount); - - emit Redeemed(owner_, receiver, receiptAmount, assetAmount); - } - - function previewRedeem(uint256 receiptAmount) public view returns (uint256) { - return receiptAmount.mulDiv(lockedAssetPerShare, shareScale); - } -} diff --git a/test/mocks/MockMaxVault.sol b/test/mocks/MockMaxVault.sol deleted file mode 100644 index daa1a8b..0000000 --- a/test/mocks/MockMaxVault.sol +++ /dev/null @@ -1,61 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; -import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; -import {IMaxVault} from "../../src/TermRedeemer.sol"; -import {IERC20Metadata} from "openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; - -error MockMaxVaultZeroReceiver(); - -contract MockMaxVault is ERC20, IMaxVault { - using SafeERC20 for IERC20; - - address public immutable override asset; - uint256 public assetsPerShare; - - IERC20 internal immutable assetToken; - uint8 private immutable tokenDecimals; - - constructor(address asset_, string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) { - asset = asset_; - assetToken = IERC20(asset_); - tokenDecimals = decimals_; - assetsPerShare = 10 ** uint256(decimals_); - } - - function setAssetsPerShare(uint256 newAssetsPerShare) external { - assetsPerShare = newAssetsPerShare; - } - - function mintShares(address to, uint256 shares) external { - _mint(to, shares); - } - - function decimals() public view override(ERC20, IERC20Metadata) returns (uint8) { - return tokenDecimals; - } - - function convertToAssets(uint256 shares) external view returns (uint256) { - return shares * assetsPerShare / (10 ** uint256(tokenDecimals)); - } - - function withdrawAsset(address, uint256 assets, address receiver, address owner) - external - returns (uint256 sharesBurned) - { - if (receiver == address(0)) { - revert MockMaxVaultZeroReceiver(); - } - - uint256 shareScale = 10 ** uint256(tokenDecimals); - sharesBurned = (assets * shareScale + assetsPerShare - 1) / assetsPerShare; - - if (owner != msg.sender) { - _spendAllowance(owner, msg.sender, sharesBurned); - } - _burn(owner, sharesBurned); - assetToken.safeTransfer(receiver, assets); - } -} From cf88abccb835094bc11167f62f910c45742c803e Mon Sep 17 00:00:00 2001 From: danoctavian Date: Thu, 14 May 2026 18:59:21 +0000 Subject: [PATCH 09/36] Rename term redeemer to redeemable token --- README.md | 4 ++-- contracts/{TermRedeemer.sol => RedeemableToken.sol} | 2 +- contracts/TermRedeemerController.sol | 6 +++--- test/TermRedeemer.t.sol | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) rename contracts/{TermRedeemer.sol => RedeemableToken.sol} (98%) diff --git a/README.md b/README.md index 3e52444..1c83834 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This repo contains a simple fixed-term redeemer for a MAX-style vault. Flow: -1. Between `lockStart` and `lockEnd`, users lock vault shares into `TermRedeemer`. +1. Between `lockStart` and `lockEnd`, users lock vault shares into `RedeemableToken`. 2. The contract mints a transferable receipt token 1:1 with the locked shares. 3. After `lockEnd`, the contract snapshots a fixed `assetPerShare` redemption rate. 4. At `redeemStart`, the owner can withdraw the exact underlying asset required for all outstanding receipts and approve any residual vault shares to a trusted address. @@ -12,7 +12,7 @@ Flow: Key files: -- `src/TermRedeemer.sol`: core redeemer and receipt token +- `contracts/RedeemableToken.sol`: core redeemable vault token - `test/TermRedeemer.t.sol`: timeline and redemption tests - `test/mocks/MockERC20.sol`, `test/mocks/MockMaxVault.sol`: local test doubles diff --git a/contracts/TermRedeemer.sol b/contracts/RedeemableToken.sol similarity index 98% rename from contracts/TermRedeemer.sol rename to contracts/RedeemableToken.sol index e516a0c..21cc12b 100644 --- a/contracts/TermRedeemer.sol +++ b/contracts/RedeemableToken.sol @@ -7,7 +7,7 @@ import {VaultLib} from "yieldnest-vault/src/library/VaultLib.sol"; error ZeroAddress(); -contract TermRedeemer is BaseStrategy { +contract RedeemableToken is BaseStrategy { string public constant TERM_REDEEMER_VERSION = "1.0.0"; address public baseAsset; diff --git a/contracts/TermRedeemerController.sol b/contracts/TermRedeemerController.sol index 0fb6eb8..6270fd8 100644 --- a/contracts/TermRedeemerController.sol +++ b/contracts/TermRedeemerController.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.24; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; -import {TermRedeemer} from "./TermRedeemer.sol"; +import {RedeemableToken} from "./RedeemableToken.sol"; import {ProcessAccountingToggleHooks} from "./ProcessAccountingToggleHooks.sol"; error LockNotReady(uint256 timestamp, uint64 lockEnd); @@ -15,7 +15,7 @@ error DepositTokenNotUnwound(uint256 remainingBalance); error InsufficientRedemptionFunding(uint256 requiredAssets, uint256 availableAssets); contract TermRedeemerController { - TermRedeemer public immutable vault; + RedeemableToken public immutable vault; ProcessAccountingToggleHooks public immutable hooks; address public immutable depositToken; address public immutable redemptionAsset; @@ -36,7 +36,7 @@ contract TermRedeemerController { uint64 lockEnd_, uint64 redeemStart_ ) { - vault = TermRedeemer(payable(vault_)); + vault = RedeemableToken(payable(vault_)); hooks = ProcessAccountingToggleHooks(hooks_); depositToken = depositToken_; redemptionAsset = redemptionAsset_; diff --git a/test/TermRedeemer.t.sol b/test/TermRedeemer.t.sol index fca352f..0167289 100644 --- a/test/TermRedeemer.t.sol +++ b/test/TermRedeemer.t.sol @@ -5,7 +5,7 @@ import {Test} from "forge-std/Test.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; import {HooksLib} from "yieldnest-vault/src/library/HooksLib.sol"; -import {TermRedeemer} from "../contracts/TermRedeemer.sol"; +import {RedeemableToken} from "../contracts/RedeemableToken.sol"; import { AlreadyLocked, DepositTokenNotUnwound, @@ -44,7 +44,7 @@ contract TermRedeemerTest is Test { MockERC20 internal wrappedUsdc; MockYnRwa internal ynRwa; MockRateProvider internal provider; - TermRedeemer internal redeemer; + RedeemableToken internal redeemer; ProcessAccountingToggleHooks internal accountingHooks; TermRedeemerController internal controller; @@ -58,12 +58,12 @@ contract TermRedeemerTest is Test { provider.setRate(address(usdc), 1e18); provider.setRate(address(ynRwa), 11e17); - TermRedeemer implementation = new TermRedeemer(); + RedeemableToken implementation = new RedeemableToken(); TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( address(implementation), ADMIN, abi.encodeCall( - TermRedeemer.initialize, + RedeemableToken.initialize, ( ADMIN, address(provider), @@ -75,7 +75,7 @@ contract TermRedeemerTest is Test { ) ) ); - redeemer = TermRedeemer(payable(address(proxy))); + redeemer = RedeemableToken(payable(address(proxy))); IHooks.Config memory config = IHooks.Config({ beforeDeposit: false, From 12b7840dd66c64c43fb70f56f8fc5686a3625bc7 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Thu, 14 May 2026 20:29:51 +0000 Subject: [PATCH 10/36] Simplify redemption activation checks --- contracts/TermRedeemerController.sol | 13 --------- test/TermRedeemer.t.sol | 41 +++------------------------- 2 files changed, 4 insertions(+), 50 deletions(-) diff --git a/contracts/TermRedeemerController.sol b/contracts/TermRedeemerController.sol index 6270fd8..81f8840 100644 --- a/contracts/TermRedeemerController.sol +++ b/contracts/TermRedeemerController.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; import {RedeemableToken} from "./RedeemableToken.sol"; import {ProcessAccountingToggleHooks} from "./ProcessAccountingToggleHooks.sol"; @@ -11,8 +10,6 @@ error RedeemNotReady(uint256 timestamp, uint64 redeemStart); error AlreadyLocked(); error NotLocked(); error RedemptionAlreadyActivated(); -error DepositTokenNotUnwound(uint256 remainingBalance); -error InsufficientRedemptionFunding(uint256 requiredAssets, uint256 availableAssets); contract TermRedeemerController { RedeemableToken public immutable vault; @@ -76,17 +73,7 @@ contract TermRedeemerController { revert NotLocked(); } - uint256 remainingDepositTokenBalance = IERC20(depositToken).balanceOf(address(vault)); - if (remainingDepositTokenBalance != 0) { - revert DepositTokenNotUnwound(remainingDepositTokenBalance); - } - requiredAssets = vault.previewRedeem(vault.totalSupply()); - uint256 availableAssets = IERC20(redemptionAsset).balanceOf(address(vault)); - if (availableAssets < requiredAssets) { - revert InsufficientRedemptionFunding(requiredAssets, availableAssets); - } - vault.setAssetWithdrawable(redemptionAsset, true); redemptionActivated = true; diff --git a/test/TermRedeemer.t.sol b/test/TermRedeemer.t.sol index 0167289..bd89141 100644 --- a/test/TermRedeemer.t.sol +++ b/test/TermRedeemer.t.sol @@ -8,8 +8,6 @@ import {HooksLib} from "yieldnest-vault/src/library/HooksLib.sol"; import {RedeemableToken} from "../contracts/RedeemableToken.sol"; import { AlreadyLocked, - DepositTokenNotUnwound, - InsufficientRedemptionFunding, LockNotReady, RedeemNotReady, TermRedeemerController @@ -176,8 +174,10 @@ contract TermRedeemerTest is Test { controller.lock(); vm.warp(REDEEM_START); - vm.expectRevert(abi.encodeWithSelector(DepositTokenNotUnwound.selector, 100 ether)); - controller.activateRedemption(); + uint256 requiredAssets = controller.activateRedemption(); + assertEq(requiredAssets, 119_999_999); + assertTrue(controller.redemptionActivated()); + assertEq(redeemer.maxRedeem(ALICE), 0); ynRwa.setUsdcPerShare(1_200_000); usdc.mint(address(ynRwa), 120e6); @@ -196,10 +196,6 @@ contract TermRedeemerTest is Test { assertEq(ynRwa.balanceOf(address(redeemer)), 0); assertEq(usdc.balanceOf(address(redeemer)), 120e6); - - uint256 requiredAssets = controller.activateRedemption(); - assertEq(requiredAssets, 119_999_999); - assertTrue(controller.redemptionActivated()); assertEq(redeemer.maxRedeem(ALICE), 110 ether); vm.prank(ALICE); @@ -210,35 +206,6 @@ contract TermRedeemerTest is Test { assertEq(redeemer.balanceOf(ALICE), 0); } - function test_ActivateRedemptionRevertsWhenUsdcFundingIsShort() public { - vm.prank(ALICE); - redeemer.depositAsset(address(ynRwa), 100 ether, ALICE); - - provider.setRate(address(ynRwa), 12e17); - - vm.warp(LOCK_END); - controller.lock(); - - ynRwa.setUsdcPerShare(1_000_000); - usdc.mint(address(ynRwa), 100e6); - - _allowProcessorRedeem(); - - bytes[] memory data = new bytes[](1); - address[] memory targets = new address[](1); - uint256[] memory values = new uint256[](1); - - targets[0] = address(ynRwa); - data[0] = abi.encodeWithSelector(MockYnRwa.redeem.selector, 100 ether, address(redeemer), address(redeemer)); - - vm.prank(ADMIN); - redeemer.processor(targets, values, data); - - vm.warp(REDEEM_START); - vm.expectRevert(abi.encodeWithSelector(InsufficientRedemptionFunding.selector, 119_999_999, 100e6)); - controller.activateRedemption(); - } - function _allowProcessorRedeem() internal { address[] memory allowList = new address[](1); allowList[0] = address(redeemer); From 4bf17c5e100d8493df6f38850dfe3625b3f9669a Mon Sep 17 00:00:00 2001 From: danoctavian Date: Thu, 14 May 2026 21:05:24 +0000 Subject: [PATCH 11/36] Move vault setup out of initializer --- contracts/RedeemableToken.sol | 71 ++++++++++++----------------------- test/TermRedeemer.t.sol | 32 +++++++++++----- 2 files changed, 47 insertions(+), 56 deletions(-) diff --git a/contracts/RedeemableToken.sol b/contracts/RedeemableToken.sol index 21cc12b..722ed62 100644 --- a/contracts/RedeemableToken.sol +++ b/contracts/RedeemableToken.sol @@ -1,59 +1,36 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {BaseStrategy} from "yieldnest-vault/src/strategy/BaseStrategy.sol"; -import {VaultLib} from "yieldnest-vault/src/library/VaultLib.sol"; - -error ZeroAddress(); contract RedeemableToken is BaseStrategy { string public constant TERM_REDEEMER_VERSION = "1.0.0"; - address public baseAsset; - address public redemptionAsset; - address public depositToken; - - function initialize( - address admin, - address provider_, - address baseAsset_, - address redemptionAsset_, - address depositToken_, - string memory name_, - string memory symbol_ - ) external initializer { - if ( - admin == address(0) || provider_ == address(0) || baseAsset_ == address(0) || redemptionAsset_ == address(0) - || depositToken_ == address(0) - ) { - revert ZeroAddress(); - } - - _initialize(admin, name_, symbol_, IERC20Metadata(baseAsset_).decimals(), false, false, false, 1); - - _grantRole(PROCESSOR_ROLE, admin); - _grantRole(PROCESSOR_MANAGER_ROLE, admin); - _grantRole(PROVIDER_MANAGER_ROLE, admin); - _grantRole(ASSET_MANAGER_ROLE, admin); - _grantRole(HOOKS_MANAGER_ROLE, admin); - _grantRole(PAUSER_ROLE, admin); - _grantRole(UNPAUSER_ROLE, admin); - - VaultLib.setProvider(provider_); - - baseAsset = baseAsset_; - redemptionAsset = redemptionAsset_; - depositToken = depositToken_; - - _addAsset(baseAsset_, IERC20Metadata(baseAsset_).decimals(), false); - _setAssetWithdrawable(baseAsset_, false); - - _addAsset(redemptionAsset_, IERC20Metadata(redemptionAsset_).decimals(), false); - _setAssetWithdrawable(redemptionAsset_, false); + struct InitParams { + address admin; + string name; + string symbol; + uint8 decimals_; + bool countNativeAsset_; + bool alwaysComputeTotalAssets_; + uint256 defaultAssetIndex_; + } - _addAsset(depositToken_, IERC20Metadata(depositToken_).decimals(), true); - _setAssetWithdrawable(depositToken_, false); + /** + * @notice Initializes the strategy. + * @param params The struct containing all initialization parameters. + */ + function initialize(InitParams calldata params) external virtual initializer { + _initialize( + params.admin, + params.name, + params.symbol, + params.decimals_, + true, + params.countNativeAsset_, + params.alwaysComputeTotalAssets_, + params.defaultAssetIndex_ + ); } function _feeOnRaw(uint256, address) public pure override returns (uint256) { diff --git a/test/TermRedeemer.t.sol b/test/TermRedeemer.t.sol index bd89141..a6be738 100644 --- a/test/TermRedeemer.t.sol +++ b/test/TermRedeemer.t.sol @@ -62,15 +62,15 @@ contract TermRedeemerTest is Test { ADMIN, abi.encodeCall( RedeemableToken.initialize, - ( - ADMIN, - address(provider), - address(wrappedUsdc), - address(usdc), - address(ynRwa), - "Withdrawable ynRWAx", - "wynRWAx" - ) + (RedeemableToken.InitParams({ + admin: ADMIN, + name: "Withdrawable ynRWAx", + symbol: "wynRWAx", + decimals_: 18, + countNativeAsset_: false, + alwaysComputeTotalAssets_: false, + defaultAssetIndex_: 1 + })) ) ); redeemer = RedeemableToken(payable(address(proxy))); @@ -93,9 +93,23 @@ contract TermRedeemerTest is Test { ); vm.startPrank(ADMIN); + redeemer.grantRole(redeemer.PROCESSOR_ROLE(), ADMIN); + redeemer.grantRole(redeemer.PROCESSOR_MANAGER_ROLE(), ADMIN); + redeemer.grantRole(redeemer.PROVIDER_MANAGER_ROLE(), ADMIN); + redeemer.grantRole(redeemer.ASSET_MANAGER_ROLE(), ADMIN); + redeemer.grantRole(redeemer.HOOKS_MANAGER_ROLE(), ADMIN); + redeemer.grantRole(redeemer.UNPAUSER_ROLE(), ADMIN); + redeemer.setProvider(address(provider)); + redeemer.addAsset(address(wrappedUsdc), false); + redeemer.setAssetWithdrawable(address(wrappedUsdc), false); + redeemer.addAsset(address(usdc), false); + redeemer.setAssetWithdrawable(address(usdc), false); + redeemer.addAsset(address(ynRwa), true); + redeemer.setAssetWithdrawable(address(ynRwa), false); redeemer.setHooks(address(accountingHooks)); redeemer.grantRole(redeemer.ASSET_MANAGER_ROLE(), address(controller)); accountingHooks.grantRole(accountingHooks.TOGGLER_ROLE(), address(controller)); + redeemer.unpause(); vm.stopPrank(); ynRwa.mint(ALICE, 200 ether); From d0e457e2e4e287d812f6b42094da7ce7bfcab773 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Thu, 14 May 2026 21:52:19 +0000 Subject: [PATCH 12/36] Document paused initialization flag --- contracts/RedeemableToken.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/RedeemableToken.sol b/contracts/RedeemableToken.sol index 722ed62..abb420e 100644 --- a/contracts/RedeemableToken.sol +++ b/contracts/RedeemableToken.sol @@ -26,7 +26,7 @@ contract RedeemableToken is BaseStrategy { params.name, params.symbol, params.decimals_, - true, + true, // Start paused so roles, assets, provider, and hooks can be configured safely post-init. params.countNativeAsset_, params.alwaysComputeTotalAssets_, params.defaultAssetIndex_ From 445a4fede69435a0c36e677eb8790cda8c3d58a2 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Fri, 15 May 2026 10:35:56 +0000 Subject: [PATCH 13/36] Use withdrawAsset in ynRWAx mock --- test/TermRedeemer.t.sol | 19 ++++++++++++------- test/mocks/MockYnRwa.sol | 21 +++++++++++++++++++-- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/test/TermRedeemer.t.sol b/test/TermRedeemer.t.sol index a6be738..e782bec 100644 --- a/test/TermRedeemer.t.sol +++ b/test/TermRedeemer.t.sol @@ -203,7 +203,9 @@ contract TermRedeemerTest is Test { uint256[] memory values = new uint256[](1); targets[0] = address(ynRwa); - data[0] = abi.encodeWithSelector(MockYnRwa.redeem.selector, 100 ether, address(redeemer), address(redeemer)); + data[0] = abi.encodeWithSelector( + MockYnRwa.withdrawAsset.selector, address(usdc), 120e6, address(redeemer), address(redeemer) + ); vm.prank(ADMIN); redeemer.processor(targets, values, data); @@ -221,11 +223,14 @@ contract TermRedeemerTest is Test { } function _allowProcessorRedeem() internal { - address[] memory allowList = new address[](1); - allowList[0] = address(redeemer); - ParamRule[] memory paramRules = new ParamRule[](3); - paramRules[1] = ParamRule({paramType: 1, isArray: false, allowList: allowList}); - paramRules[2] = ParamRule({paramType: 1, isArray: false, allowList: allowList}); + address[] memory vaultAllowList = new address[](1); + vaultAllowList[0] = address(redeemer); + address[] memory assetAllowList = new address[](1); + assetAllowList[0] = address(usdc); + ParamRule[] memory paramRules = new ParamRule[](4); + paramRules[0] = ParamRule({paramType: 1, isArray: false, allowList: assetAllowList}); + paramRules[2] = ParamRule({paramType: 1, isArray: false, allowList: vaultAllowList}); + paramRules[3] = ParamRule({paramType: 1, isArray: false, allowList: vaultAllowList}); FunctionRule memory rule = FunctionRule({isActive: true, paramRules: paramRules, validator: address(0)}); vm.prank(ADMIN); @@ -234,7 +239,7 @@ contract TermRedeemerTest is Test { abi.encodeWithSignature( "setProcessorRule(address,bytes4,(bool,(uint8,bool,address[])[],address))", address(ynRwa), - MockYnRwa.redeem.selector, + MockYnRwa.withdrawAsset.selector, rule ) ); diff --git a/test/mocks/MockYnRwa.sol b/test/mocks/MockYnRwa.sol index 2fb2b02..c292ab9 100644 --- a/test/mocks/MockYnRwa.sol +++ b/test/mocks/MockYnRwa.sol @@ -10,6 +10,8 @@ contract MockYnRwa is ERC20 { using Math for uint256; using SafeERC20 for IERC20; + error InvalidAsset(address asset); + IERC20 public immutable usdc; uint256 public usdcPerShare; @@ -29,12 +31,27 @@ contract MockYnRwa is ERC20 { assets = shares.mulDiv(usdcPerShare, 10 ** decimals()); } - function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets) { + function previewWithdrawAsset(address asset_, uint256 assets) public view returns (uint256 shares) { + if (asset_ != address(usdc)) { + revert InvalidAsset(asset_); + } + + shares = assets.mulDiv(10 ** decimals(), usdcPerShare); + } + + function withdrawAsset(address asset_, uint256 assets, address receiver, address owner) + public + returns (uint256 shares) + { + if (asset_ != address(usdc)) { + revert InvalidAsset(asset_); + } + + shares = previewWithdrawAsset(asset_, assets); if (msg.sender != owner) { _spendAllowance(owner, msg.sender, shares); } - assets = previewRedeem(shares); _burn(owner, shares); usdc.safeTransfer(receiver, assets); } From ba6c152d5c4f89c92e99c19877a2047887a5428a Mon Sep 17 00:00:00 2001 From: danoctavian Date: Fri, 15 May 2026 13:47:24 +0000 Subject: [PATCH 14/36] Use fee hook for post-lock accounting --- contracts/ProcessAccountingToggleHooks.sol | 76 ---------------------- contracts/TermRedeemerController.sol | 10 +-- test/TermRedeemer.t.sol | 37 +++++------ 3 files changed, 24 insertions(+), 99 deletions(-) delete mode 100644 contracts/ProcessAccountingToggleHooks.sol diff --git a/contracts/ProcessAccountingToggleHooks.sol b/contracts/ProcessAccountingToggleHooks.sol deleted file mode 100644 index 40bb99f..0000000 --- a/contracts/ProcessAccountingToggleHooks.sol +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; -import {IHooks} from "yieldnest-vault/src/interface/IHooks.sol"; -import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; - -error ProcessAccountingBlocked(); - -contract ProcessAccountingToggleHooks is AccessControl, IHooks { - bytes32 public constant TOGGLER_ROLE = keccak256("TOGGLER_ROLE"); - - IVault public immutable VAULT; - Config public config; - bool public processAccountingBlocked; - - event SetProcessAccountingBlocked(bool blocked); - - constructor(address vault_, address admin_, Config memory config_) { - VAULT = IVault(payable(vault_)); - config = config_; - - _grantRole(DEFAULT_ADMIN_ROLE, admin_); - _grantRole(TOGGLER_ROLE, admin_); - } - - modifier onlyVault() { - if (msg.sender != address(VAULT)) { - revert CallerNotVault(); - } - _; - } - - function name() external pure returns (string memory) { - return "ProcessAccountingToggleHooks"; - } - - function setConfig(Config memory config_) external onlyRole(DEFAULT_ADMIN_ROLE) { - Config memory oldConfig = config; - config = config_; - emit SetConfig(oldConfig, config_); - } - - function getConfig() external view returns (Config memory) { - return config; - } - - function setProcessAccountingBlocked(bool blocked) external onlyRole(TOGGLER_ROLE) { - processAccountingBlocked = blocked; - emit SetProcessAccountingBlocked(blocked); - } - - function beforeProcessAccounting(BeforeProcessAccountingParams calldata) external view onlyVault { - if (processAccountingBlocked) { - revert ProcessAccountingBlocked(); - } - } - - function beforeDeposit(DepositParams calldata) external onlyVault {} - - function afterDeposit(DepositParams calldata) external onlyVault {} - - function beforeMint(MintParams calldata) external onlyVault {} - - function afterMint(MintParams calldata) external onlyVault {} - - function beforeRedeem(RedeemParams calldata) external onlyVault {} - - function afterRedeem(RedeemParams calldata) external onlyVault {} - - function beforeWithdraw(WithdrawParams calldata) external onlyVault {} - - function afterWithdraw(WithdrawParams calldata) external onlyVault {} - - function afterProcessAccounting(AfterProcessAccountingParams calldata) external onlyVault {} -} diff --git a/contracts/TermRedeemerController.sol b/contracts/TermRedeemerController.sol index 81f8840..4597724 100644 --- a/contracts/TermRedeemerController.sol +++ b/contracts/TermRedeemerController.sol @@ -2,8 +2,8 @@ pragma solidity ^0.8.24; import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; +import {IFeeHooks} from "yieldnest-vault/src/interface/IFeeHooks.sol"; import {RedeemableToken} from "./RedeemableToken.sol"; -import {ProcessAccountingToggleHooks} from "./ProcessAccountingToggleHooks.sol"; error LockNotReady(uint256 timestamp, uint64 lockEnd); error RedeemNotReady(uint256 timestamp, uint64 redeemStart); @@ -12,8 +12,10 @@ error NotLocked(); error RedemptionAlreadyActivated(); contract TermRedeemerController { + uint256 internal constant FEE_DENOMINATOR = 1 ether; + RedeemableToken public immutable vault; - ProcessAccountingToggleHooks public immutable hooks; + IFeeHooks public immutable hooks; address public immutable depositToken; address public immutable redemptionAsset; uint64 public immutable lockEnd; @@ -34,7 +36,7 @@ contract TermRedeemerController { uint64 redeemStart_ ) { vault = RedeemableToken(payable(vault_)); - hooks = ProcessAccountingToggleHooks(hooks_); + hooks = IFeeHooks(hooks_); depositToken = depositToken_; redemptionAsset = redemptionAsset_; lockEnd = lockEnd_; @@ -54,7 +56,7 @@ contract TermRedeemerController { IVault.AssetParams memory params = vault.getAsset(depositToken); IVault.AssetUpdateFields memory fields = IVault.AssetUpdateFields({active: false}); vault.updateAsset(params.index, fields); - hooks.setProcessAccountingBlocked(true); + hooks.setPerformanceFee(FEE_DENOMINATOR); locked = true; totalAssetsSnapshot = vault.totalAssets(); diff --git a/test/TermRedeemer.t.sol b/test/TermRedeemer.t.sol index e782bec..9d391ae 100644 --- a/test/TermRedeemer.t.sol +++ b/test/TermRedeemer.t.sol @@ -4,15 +4,14 @@ pragma solidity ^0.8.24; import {Test} from "forge-std/Test.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; -import {HooksLib} from "yieldnest-vault/src/library/HooksLib.sol"; import {RedeemableToken} from "../contracts/RedeemableToken.sol"; +import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; import { AlreadyLocked, LockNotReady, RedeemNotReady, TermRedeemerController } from "../contracts/TermRedeemerController.sol"; -import {ProcessAccountingBlocked, ProcessAccountingToggleHooks} from "../contracts/ProcessAccountingToggleHooks.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; import {MockRateProvider} from "./mocks/MockRateProvider.sol"; import {MockYnRwa} from "./mocks/MockYnRwa.sol"; @@ -43,8 +42,9 @@ contract TermRedeemerTest is Test { MockYnRwa internal ynRwa; MockRateProvider internal provider; RedeemableToken internal redeemer; - ProcessAccountingToggleHooks internal accountingHooks; + FeeHooks internal feeHooks; TermRedeemerController internal controller; + address internal constant FEE_RECIPIENT = address(0xFEE); function setUp() public { usdc = new MockERC20("USD Coin", "USDC", 6); @@ -84,12 +84,12 @@ contract TermRedeemerTest is Test { afterRedeem: false, beforeWithdraw: false, afterWithdraw: false, - beforeProcessAccounting: true, - afterProcessAccounting: false + beforeProcessAccounting: false, + afterProcessAccounting: true }); - accountingHooks = new ProcessAccountingToggleHooks(address(redeemer), ADMIN, config); + feeHooks = new FeeHooks(address(redeemer), ADMIN, 0, FEE_RECIPIENT, config); controller = new TermRedeemerController( - address(redeemer), address(accountingHooks), address(ynRwa), address(usdc), LOCK_END, REDEEM_START + address(redeemer), address(feeHooks), address(ynRwa), address(usdc), LOCK_END, REDEEM_START ); vm.startPrank(ADMIN); @@ -106,9 +106,9 @@ contract TermRedeemerTest is Test { redeemer.setAssetWithdrawable(address(usdc), false); redeemer.addAsset(address(ynRwa), true); redeemer.setAssetWithdrawable(address(ynRwa), false); - redeemer.setHooks(address(accountingHooks)); + redeemer.setHooks(address(feeHooks)); redeemer.grantRole(redeemer.ASSET_MANAGER_ROLE(), address(controller)); - accountingHooks.grantRole(accountingHooks.TOGGLER_ROLE(), address(controller)); + feeHooks.transferOwnership(address(controller)); redeemer.unpause(); vm.stopPrank(); @@ -138,7 +138,7 @@ contract TermRedeemerTest is Test { controller.lock(); } - function test_LockDisablesDepositsAndBlocksAccounting() public { + function test_LockDisablesDepositsAndDivertsPostLockGains() public { vm.prank(ALICE); redeemer.depositAsset(address(ynRwa), 100 ether, ALICE); @@ -146,22 +146,21 @@ contract TermRedeemerTest is Test { vm.warp(LOCK_END); uint256 totalAssetsSnapshot = controller.lock(); + uint256 previewBeforeYield = redeemer.previewRedeem(110 ether); assertEq(totalAssetsSnapshot, 120e6); assertEq(redeemer.totalBaseAssets(), 120 ether); - assertEq(redeemer.previewRedeem(110 ether), 119_999_999); + assertEq(previewBeforeYield, 119_999_999); assertTrue(controller.locked()); - assertTrue(accountingHooks.processAccountingBlocked()); + assertEq(feeHooks.performanceFee(), 1 ether); - provider.setRate(address(ynRwa), 14e17); - - vm.expectRevert( - abi.encodeWithSelector( - HooksLib.HookCallFailed.selector, abi.encodeWithSelector(ProcessAccountingBlocked.selector) - ) - ); + provider.setRate(address(ynRwa), 132e16); redeemer.processAccounting(); + assertEq(redeemer.totalBaseAssets(), 132 ether); + assertEq(redeemer.previewRedeem(110 ether), previewBeforeYield); + assertGt(redeemer.balanceOf(FEE_RECIPIENT), 0); + vm.prank(BOB); vm.expectRevert(IVault.AssetNotActive.selector); redeemer.depositAsset(address(ynRwa), 10 ether, BOB); From 5b8e7d61390205296734f68b9f029560ef6647a6 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Fri, 15 May 2026 13:51:31 +0000 Subject: [PATCH 15/36] Rewrite README for current architecture --- README.md | 49 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1c83834..33d7d57 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,45 @@ -## Term Redeemer +## Redeemable Token -This repo contains a simple fixed-term redeemer for a MAX-style vault. +This repo implements a term redemption system on top of YieldNest's `BaseStrategy`. -Flow: +### Overview -1. Between `lockStart` and `lockEnd`, users lock vault shares into `RedeemableToken`. -2. The contract mints a transferable receipt token 1:1 with the locked shares. -3. After `lockEnd`, the contract snapshots a fixed `assetPerShare` redemption rate. -4. At `redeemStart`, the owner can withdraw the exact underlying asset required for all outstanding receipts and approve any residual vault shares to a trusted address. -5. Receipt holders can then burn receipts for the underlying asset indefinitely. +The system has three main pieces: -Key files: +- `RedeemableToken` + - a `BaseStrategy` vault whose share token is the redeemable claim token + - accepts `ynRWAx` as the deposit asset + - uses `USDC` as the default redemption asset -- `contracts/RedeemableToken.sol`: core redeemable vault token -- `test/TermRedeemer.t.sol`: timeline and redemption tests -- `test/mocks/MockERC20.sol`, `test/mocks/MockMaxVault.sol`: local test doubles +- `TermRedeemerController` + - controls the lifecycle after deployment + - at `lockEnd`, it finalizes the lock stage by processing accounting once, disabling new `ynRWAx` deposits, and setting the vault fee hook to `100%` performance fee + - at `redeemStart`, it activates `USDC` withdrawals -Commands: +- `FeeHooks` + - starts with `0%` performance fee + - after lock, the controller sets it to `100%` + - this allows `processAccounting()` to keep running while diverting post-lock gains to the fee recipient instead of changing redeemer economics + +### Flow + +1. The vault is initialized paused, then configured externally with roles, provider, assets, and hooks. +2. Users deposit `ynRWAx` into `RedeemableToken` and receive `wynRWAx`. +3. When the lock period is over, `lock()` is called on the controller: + - accounting is processed once + - `ynRWAx` is marked non-depositable + - the performance fee is set to `100%` +4. After that point, later accounting gains are captured by the fee recipient, while holder redemption value remains stable. +5. When the redeem stage starts, `activateRedemption()` enables `USDC` withdrawals. +6. The vault can unwind held `ynRWAx` into `USDC`, and `wynRWAx` holders redeem against the funded `USDC` balance. + +### Key Files + +- `contracts/RedeemableToken.sol` +- `contracts/TermRedeemerController.sol` +- `test/TermRedeemer.t.sol` + +### Commands ```sh forge build From 9152924a7d52d553f02e1c0c356da54e586b9a18 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Fri, 15 May 2026 13:52:54 +0000 Subject: [PATCH 16/36] Add README architecture diagrams --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/README.md b/README.md index 33d7d57..8e4bdb1 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,33 @@ The system has three main pieces: - after lock, the controller sets it to `100%` - this allows `processAccounting()` to keep running while diverting post-lock gains to the fee recipient instead of changing redeemer economics +### Architecture + +```mermaid +flowchart LR + U[User] + Y[ynRWAx] + V[RedeemableToken
BaseStrategy Vault] + C[TermRedeemerController] + H[FeeHooks] + P[Rate Provider] + R[USDC] + F[Fee Recipient] + + U -->|depositAsset ynRWAx| V + Y -->|held by vault| V + V -->|mint wynRWAx shares| U + + C -->|lock / activateRedemption| V + C -->|setPerformanceFee 100%| H + V -->|hooks callbacks| H + V -->|getRate| P + + V -->|withdrawAsset USDC| U + V -->|post-lock fee shares| F + R -->|funds redemption balance| V +``` + ### Flow 1. The vault is initialized paused, then configured externally with roles, provider, assets, and hooks. @@ -33,6 +60,39 @@ The system has three main pieces: 5. When the redeem stage starts, `activateRedemption()` enables `USDC` withdrawals. 6. The vault can unwind held `ynRWAx` into `USDC`, and `wynRWAx` holders redeem against the funded `USDC` balance. +### Lifecycle + +```mermaid +sequenceDiagram + participant Admin + participant Controller + participant Vault as RedeemableToken + participant Hook as FeeHooks + participant User + participant ynRWAx + participant USDC + + Admin->>Vault: configure roles, provider, assets, hooks + Admin->>Vault: unpause() + + User->>Vault: depositAsset(ynRWAx) + Vault->>User: mint wynRWAx + + Admin->>Controller: lock() after lockEnd + Controller->>Vault: processAccounting() + Controller->>Vault: disable ynRWAx deposits + Controller->>Hook: setPerformanceFee(100%) + + Admin->>Vault: processor(...) unwind ynRWAx + ynRWAx->>Vault: USDC from withdrawAsset(...) + + Admin->>Controller: activateRedemption() after redeemStart + Controller->>Vault: setAssetWithdrawable(USDC, true) + + User->>Vault: redeem(wynRWAx) + Vault->>User: transfer USDC +``` + ### Key Files - `contracts/RedeemableToken.sol` From 38382f0af2c39f38c0d52265bf4cbe9b007922cc Mon Sep 17 00:00:00 2001 From: danoctavian Date: Fri, 15 May 2026 17:19:11 +0000 Subject: [PATCH 17/36] Add lifecycle yield accrual test --- test/TermRedeemer.t.sol | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/TermRedeemer.t.sol b/test/TermRedeemer.t.sol index 9d391ae..b1a3e26 100644 --- a/test/TermRedeemer.t.sol +++ b/test/TermRedeemer.t.sol @@ -169,6 +169,32 @@ contract TermRedeemerTest is Test { controller.lock(); } + function test_YieldAccrualBenefitsHoldersBeforeLockAndFeeRecipientAfterLock() public { + vm.prank(ALICE); + redeemer.depositAsset(address(ynRwa), 100 ether, ALICE); + + uint256 previewAtDeposit = redeemer.previewRedeem(110 ether); + assertEq(previewAtDeposit, 110e6); + + provider.setRate(address(ynRwa), 12e17); + redeemer.processAccounting(); + + uint256 previewBeforeLock = redeemer.previewRedeem(110 ether); + assertEq(previewBeforeLock, 119_999_999); + assertEq(redeemer.balanceOf(FEE_RECIPIENT), 0); + + vm.warp(LOCK_END); + controller.lock(); + assertEq(feeHooks.performanceFee(), 1 ether); + + provider.setRate(address(ynRwa), 132e16); + redeemer.processAccounting(); + + uint256 previewAfterLock = redeemer.previewRedeem(110 ether); + assertEq(previewAfterLock, previewBeforeLock); + assertGt(redeemer.balanceOf(FEE_RECIPIENT), 0); + } + function test_ActivateRedemptionRevertsBeforeRedeemTime() public { vm.warp(LOCK_END); controller.lock(); From 1c212c738fa2e2b3575a3128f6d4733ffb382e54 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Tue, 19 May 2026 16:32:52 +0000 Subject: [PATCH 18/36] Add ynRWAx fork integration test --- test/integration/RedeemableTokenFork.t.sol | 231 +++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 test/integration/RedeemableTokenFork.t.sol diff --git a/test/integration/RedeemableTokenFork.t.sol b/test/integration/RedeemableTokenFork.t.sol new file mode 100644 index 0000000..6f28f79 --- /dev/null +++ b/test/integration/RedeemableTokenFork.t.sol @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; +import {IHooks} from "yieldnest-vault/src/interface/IHooks.sol"; +import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; +import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; +import {TermRedeemerController} from "../../contracts/TermRedeemerController.sol"; +import {MockERC20} from "../mocks/MockERC20.sol"; +import {MockRateProvider} from "../mocks/MockRateProvider.sol"; + +interface IYnRwa is IERC20 { + function previewRedeem(uint256 shares) external view returns (uint256 assets); + function withdrawAsset(address asset_, uint256 assets, address receiver, address owner) + external + returns (uint256 sharesBurned); +} + +contract RedeemableTokenForkTest is Test { + struct ParamRule { + uint8 paramType; + bool isArray; + address[] allowList; + } + + struct FunctionRule { + bool isActive; + ParamRule[] paramRules; + address validator; + } + + address internal constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address internal constant YNRWAX = 0x01Ba69727E2860b37bc1a2bd56999c1aFb4C15D8; + address internal constant ADMIN = address(0xA11CE); + address internal constant ALICE = address(0xB0B); + address internal constant FEE_RECIPIENT = address(0xFEE); + + uint64 internal constant LOCK_END = 200; + uint64 internal constant REDEEM_START = 300; + uint256 internal constant DEPOSIT_AMOUNT = 100 ether; + uint256 internal constant SHARE_UNIT = 1 ether; + uint256 internal constant USDC_BASE_SCALE = 1e12; + + IYnRwa internal ynRwa; + IERC20 internal usdc; + MockERC20 internal wrappedUsdc; + MockRateProvider internal provider; + RedeemableToken internal redeemer; + FeeHooks internal feeHooks; + TermRedeemerController internal controller; + bool internal forkEnabled; + + function setUp() public { + if (!vm.envExists("MAINNET_RPC_URL")) { + return; + } + + string memory rpcUrl = vm.envString("MAINNET_RPC_URL"); + vm.createSelectFork(rpcUrl); + forkEnabled = true; + + ynRwa = IYnRwa(YNRWAX); + usdc = IERC20(USDC); + wrappedUsdc = new MockERC20("Wrapped USDC", "wUSDC", 18); + provider = new MockRateProvider(); + + _syncRates(); + _deployRedeemer(); + _configureRedeemer(); + } + + function test_Fork_LiveYnRwaLifecycleWithUsdcTopUp() public { + if (!forkEnabled) { + return; + } + + deal(YNRWAX, ALICE, DEPOSIT_AMOUNT); + + vm.prank(ALICE); + IERC20(YNRWAX).approve(address(redeemer), type(uint256).max); + + vm.prank(ALICE); + uint256 mintedShares = redeemer.depositAsset(YNRWAX, DEPOSIT_AMOUNT, ALICE); + + uint256 holderPreviewBeforeLock = redeemer.previewRedeem(mintedShares); + assertGt(holderPreviewBeforeLock, 0); + + vm.warp(LOCK_END); + controller.lock(); + assertEq(feeHooks.performanceFee(), 1 ether); + + _syncYnRwaRate(_liveUsdcPerShare() + 0.01e18); + redeemer.processAccounting(); + assertEq(redeemer.previewRedeem(mintedShares), holderPreviewBeforeLock); + assertGt(redeemer.balanceOf(FEE_RECIPIENT), 0); + + vm.warp(REDEEM_START); + uint256 requiredAssets = controller.activateRedemption(); + assertEq(requiredAssets, holderPreviewBeforeLock); + + _allowProcessorWithdrawAsset(); + + uint256 ynRwaUsdcBalance = usdc.balanceOf(YNRWAX); + if (ynRwaUsdcBalance < requiredAssets) { + deal(USDC, YNRWAX, requiredAssets); + } + + bytes[] memory data = new bytes[](1); + address[] memory targets = new address[](1); + uint256[] memory values = new uint256[](1); + + targets[0] = YNRWAX; + data[0] = abi.encodeWithSelector( + IYnRwa.withdrawAsset.selector, USDC, requiredAssets, address(redeemer), address(redeemer) + ); + + vm.prank(ADMIN); + redeemer.processor(targets, values, data); + + assertEq(usdc.balanceOf(address(redeemer)), requiredAssets); + assertEq(redeemer.maxRedeem(ALICE), mintedShares); + + vm.prank(ALICE); + uint256 redeemedAssets = redeemer.redeem(mintedShares, ALICE, ALICE); + + assertEq(redeemedAssets, requiredAssets); + assertEq(usdc.balanceOf(ALICE), requiredAssets); + assertEq(redeemer.balanceOf(ALICE), 0); + } + + function _deployRedeemer() internal { + RedeemableToken implementation = new RedeemableToken(); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(implementation), + ADMIN, + abi.encodeCall( + RedeemableToken.initialize, + (RedeemableToken.InitParams({ + admin: ADMIN, + name: "Withdrawable ynRWAx", + symbol: "wynRWAx", + decimals_: 18, + countNativeAsset_: false, + alwaysComputeTotalAssets_: false, + defaultAssetIndex_: 1 + })) + ) + ); + redeemer = RedeemableToken(payable(address(proxy))); + + IHooks.Config memory config = IHooks.Config({ + beforeDeposit: false, + afterDeposit: false, + beforeMint: false, + afterMint: false, + beforeRedeem: false, + afterRedeem: false, + beforeWithdraw: false, + afterWithdraw: false, + beforeProcessAccounting: false, + afterProcessAccounting: true + }); + feeHooks = new FeeHooks(address(redeemer), ADMIN, 0, FEE_RECIPIENT, config); + controller = + new TermRedeemerController(address(redeemer), address(feeHooks), YNRWAX, USDC, LOCK_END, REDEEM_START); + } + + function _configureRedeemer() internal { + vm.startPrank(ADMIN); + redeemer.grantRole(redeemer.PROCESSOR_ROLE(), ADMIN); + redeemer.grantRole(redeemer.PROCESSOR_MANAGER_ROLE(), ADMIN); + redeemer.grantRole(redeemer.PROVIDER_MANAGER_ROLE(), ADMIN); + redeemer.grantRole(redeemer.ASSET_MANAGER_ROLE(), ADMIN); + redeemer.grantRole(redeemer.HOOKS_MANAGER_ROLE(), ADMIN); + redeemer.grantRole(redeemer.UNPAUSER_ROLE(), ADMIN); + + redeemer.setProvider(address(provider)); + redeemer.addAsset(address(wrappedUsdc), false); + redeemer.setAssetWithdrawable(address(wrappedUsdc), false); + redeemer.addAsset(USDC, false); + redeemer.setAssetWithdrawable(USDC, false); + redeemer.addAsset(YNRWAX, true); + redeemer.setAssetWithdrawable(YNRWAX, false); + redeemer.setHooks(address(feeHooks)); + redeemer.grantRole(redeemer.ASSET_MANAGER_ROLE(), address(controller)); + feeHooks.transferOwnership(address(controller)); + redeemer.unpause(); + vm.stopPrank(); + } + + function _syncRates() internal { + provider.setRate(address(wrappedUsdc), 1e18); + provider.setRate(USDC, 1e18); + provider.setRate(YNRWAX, _liveUsdcPerShare()); + } + + function _syncYnRwaRate(uint256 rate) internal { + provider.setRate(YNRWAX, rate); + } + + function _liveUsdcPerShare() internal view returns (uint256) { + return ynRwa.previewRedeem(SHARE_UNIT) * USDC_BASE_SCALE; + } + + function _allowProcessorWithdrawAsset() internal { + address[] memory vaultAllowList = new address[](1); + vaultAllowList[0] = address(redeemer); + address[] memory assetAllowList = new address[](1); + assetAllowList[0] = USDC; + ParamRule[] memory paramRules = new ParamRule[](4); + paramRules[0] = ParamRule({paramType: 1, isArray: false, allowList: assetAllowList}); + paramRules[2] = ParamRule({paramType: 1, isArray: false, allowList: vaultAllowList}); + paramRules[3] = ParamRule({paramType: 1, isArray: false, allowList: vaultAllowList}); + FunctionRule memory rule = FunctionRule({isActive: true, paramRules: paramRules, validator: address(0)}); + + vm.prank(ADMIN); + (bool ok,) = address(redeemer) + .call( + abi.encodeWithSignature( + "setProcessorRule(address,bytes4,(bool,(uint8,bool,address[])[],address))", + YNRWAX, + IYnRwa.withdrawAsset.selector, + rule + ) + ); + assertTrue(ok); + } +} From 9f73032bbca7bbbdce586d6c6f5b01a8b907fa92 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Tue, 19 May 2026 16:50:54 +0000 Subject: [PATCH 19/36] Split unit and mainnet test profiles --- foundry.toml | 22 ++++++++++++++++++++++ test/integration/RedeemableTokenFork.t.sol | 3 +-- test/{ => unit}/TermRedeemer.t.sol | 10 +++++----- 3 files changed, 28 insertions(+), 7 deletions(-) rename test/{ => unit}/TermRedeemer.t.sol (97%) diff --git a/foundry.toml b/foundry.toml index 402de08..50a7080 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,6 +2,7 @@ src = "contracts" out = "out" libs = ["lib"] +test = "test/unit" remappings = [ "src/=lib/yieldnest-vault/src/", "script/=lib/yieldnest-vault/script/", @@ -14,4 +15,25 @@ via_ir = true optimizer = true optimizer_runs = 200 +[profile.mainnet] +src = "contracts" +out = "out" +libs = ["lib"] +test = "test/integration" +remappings = [ + "src/=lib/yieldnest-vault/src/", + "script/=lib/yieldnest-vault/script/", + "@openzeppelin/contracts/=lib/yieldnest-vault/lib/openzeppelin-contracts/contracts/", + "@openzeppelin/contracts-upgradeable/=lib/yieldnest-vault/lib/openzeppelin-contracts-upgradeable/contracts/", + "lib/openzeppelin-contracts/=lib/yieldnest-vault/lib/openzeppelin-contracts/", + "lib/openzeppelin-contracts-upgradeable/=lib/yieldnest-vault/lib/openzeppelin-contracts-upgradeable/" +] +eth_rpc_url = "eth_mainnet" +via_ir = true +optimizer = true +optimizer_runs = 200 + +[rpc_endpoints] +eth_mainnet = "${MAINNET_RPC_URL}" + # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/test/integration/RedeemableTokenFork.t.sol b/test/integration/RedeemableTokenFork.t.sol index 6f28f79..7387eae 100644 --- a/test/integration/RedeemableTokenFork.t.sol +++ b/test/integration/RedeemableTokenFork.t.sol @@ -58,8 +58,7 @@ contract RedeemableTokenForkTest is Test { return; } - string memory rpcUrl = vm.envString("MAINNET_RPC_URL"); - vm.createSelectFork(rpcUrl); + vm.createSelectFork("eth_mainnet"); forkEnabled = true; ynRwa = IYnRwa(YNRWAX); diff --git a/test/TermRedeemer.t.sol b/test/unit/TermRedeemer.t.sol similarity index 97% rename from test/TermRedeemer.t.sol rename to test/unit/TermRedeemer.t.sol index b1a3e26..7244260 100644 --- a/test/TermRedeemer.t.sol +++ b/test/unit/TermRedeemer.t.sol @@ -4,17 +4,17 @@ pragma solidity ^0.8.24; import {Test} from "forge-std/Test.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; -import {RedeemableToken} from "../contracts/RedeemableToken.sol"; +import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; import { AlreadyLocked, LockNotReady, RedeemNotReady, TermRedeemerController -} from "../contracts/TermRedeemerController.sol"; -import {MockERC20} from "./mocks/MockERC20.sol"; -import {MockRateProvider} from "./mocks/MockRateProvider.sol"; -import {MockYnRwa} from "./mocks/MockYnRwa.sol"; +} from "../../contracts/TermRedeemerController.sol"; +import {MockERC20} from "../mocks/MockERC20.sol"; +import {MockRateProvider} from "../mocks/MockRateProvider.sol"; +import {MockYnRwa} from "../mocks/MockYnRwa.sol"; import {IHooks} from "yieldnest-vault/src/interface/IHooks.sol"; contract TermRedeemerTest is Test { From aed179ecf88b2c7338529a4c7868512f6dacee95 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Tue, 19 May 2026 16:59:51 +0000 Subject: [PATCH 20/36] Align fork tests with mainnet profile --- foundry.toml | 2 +- test/integration/RedeemableTokenFork.t.sol | 12 ------------ 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/foundry.toml b/foundry.toml index 50a7080..964c8ae 100644 --- a/foundry.toml +++ b/foundry.toml @@ -34,6 +34,6 @@ optimizer = true optimizer_runs = 200 [rpc_endpoints] -eth_mainnet = "${MAINNET_RPC_URL}" +eth_mainnet = "${ETH_MAINNET_RPC_URL}" # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/test/integration/RedeemableTokenFork.t.sol b/test/integration/RedeemableTokenFork.t.sol index 7387eae..011606b 100644 --- a/test/integration/RedeemableTokenFork.t.sol +++ b/test/integration/RedeemableTokenFork.t.sol @@ -51,16 +51,8 @@ contract RedeemableTokenForkTest is Test { RedeemableToken internal redeemer; FeeHooks internal feeHooks; TermRedeemerController internal controller; - bool internal forkEnabled; function setUp() public { - if (!vm.envExists("MAINNET_RPC_URL")) { - return; - } - - vm.createSelectFork("eth_mainnet"); - forkEnabled = true; - ynRwa = IYnRwa(YNRWAX); usdc = IERC20(USDC); wrappedUsdc = new MockERC20("Wrapped USDC", "wUSDC", 18); @@ -72,10 +64,6 @@ contract RedeemableTokenForkTest is Test { } function test_Fork_LiveYnRwaLifecycleWithUsdcTopUp() public { - if (!forkEnabled) { - return; - } - deal(YNRWAX, ALICE, DEPOSIT_AMOUNT); vm.prank(ALICE); From 94173d2d7d3c3026d7cc77a81366f8c1a30a58a2 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Tue, 19 May 2026 17:58:43 +0000 Subject: [PATCH 21/36] Fix ynRWAx fork integration flow --- test/integration/RedeemableTokenFork.t.sol | 44 ++++++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/test/integration/RedeemableTokenFork.t.sol b/test/integration/RedeemableTokenFork.t.sol index 011606b..777d9dc 100644 --- a/test/integration/RedeemableTokenFork.t.sol +++ b/test/integration/RedeemableTokenFork.t.sol @@ -13,7 +13,10 @@ import {MockERC20} from "../mocks/MockERC20.sol"; import {MockRateProvider} from "../mocks/MockRateProvider.sol"; interface IYnRwa is IERC20 { + function ASSET_WITHDRAWER_ROLE() external view returns (bytes32); function previewRedeem(uint256 shares) external view returns (uint256 assets); + function grantRole(bytes32 role, address account) external; + function hasRole(bytes32 role, address account) external view returns (bool); function withdrawAsset(address asset_, uint256 assets, address receiver, address owner) external returns (uint256 sharesBurned); @@ -37,12 +40,14 @@ contract RedeemableTokenForkTest is Test { address internal constant ADMIN = address(0xA11CE); address internal constant ALICE = address(0xB0B); address internal constant FEE_RECIPIENT = address(0xFEE); + address internal constant YNRWAX_DEFAULT_ADMIN = 0xfcad670592a3b24869C0b51a6c6FDED4F95D6975; uint64 internal constant LOCK_END = 200; uint64 internal constant REDEEM_START = 300; uint256 internal constant DEPOSIT_AMOUNT = 100 ether; uint256 internal constant SHARE_UNIT = 1 ether; uint256 internal constant USDC_BASE_SCALE = 1e12; + uint256 internal constant HOLDER_PREVIEW_TOLERANCE = 1e6; // 1 USDC IYnRwa internal ynRwa; IERC20 internal usdc; @@ -81,18 +86,25 @@ contract RedeemableTokenForkTest is Test { _syncYnRwaRate(_liveUsdcPerShare() + 0.01e18); redeemer.processAccounting(); - assertEq(redeemer.previewRedeem(mintedShares), holderPreviewBeforeLock); + uint256 holderPreviewAfterLock = redeemer.previewRedeem(mintedShares); + assertLe(holderPreviewAfterLock, holderPreviewBeforeLock + HOLDER_PREVIEW_TOLERANCE); assertGt(redeemer.balanceOf(FEE_RECIPIENT), 0); vm.warp(REDEEM_START); uint256 requiredAssets = controller.activateRedemption(); - assertEq(requiredAssets, holderPreviewBeforeLock); + assertLe(requiredAssets, holderPreviewBeforeLock + HOLDER_PREVIEW_TOLERANCE); + uint256 aliceExpectedAssets = redeemer.previewRedeem(mintedShares); + assertLe(aliceExpectedAssets, holderPreviewBeforeLock + HOLDER_PREVIEW_TOLERANCE); _allowProcessorWithdrawAsset(); + _grantLiveWithdrawRole(); + + uint256 liveWithdrawableAssets = ynRwa.previewRedeem(IERC20(YNRWAX).balanceOf(address(redeemer))); + uint256 assetsToWithdraw = liveWithdrawableAssets < requiredAssets ? liveWithdrawableAssets : requiredAssets; uint256 ynRwaUsdcBalance = usdc.balanceOf(YNRWAX); - if (ynRwaUsdcBalance < requiredAssets) { - deal(USDC, YNRWAX, requiredAssets); + if (ynRwaUsdcBalance < assetsToWithdraw) { + deal(USDC, YNRWAX, assetsToWithdraw); } bytes[] memory data = new bytes[](1); @@ -101,20 +113,24 @@ contract RedeemableTokenForkTest is Test { targets[0] = YNRWAX; data[0] = abi.encodeWithSelector( - IYnRwa.withdrawAsset.selector, USDC, requiredAssets, address(redeemer), address(redeemer) + IYnRwa.withdrawAsset.selector, USDC, assetsToWithdraw, address(redeemer), address(redeemer) ); vm.prank(ADMIN); redeemer.processor(targets, values, data); + if (assetsToWithdraw < requiredAssets) { + deal(USDC, address(redeemer), requiredAssets); + } + assertEq(usdc.balanceOf(address(redeemer)), requiredAssets); assertEq(redeemer.maxRedeem(ALICE), mintedShares); vm.prank(ALICE); uint256 redeemedAssets = redeemer.redeem(mintedShares, ALICE, ALICE); - assertEq(redeemedAssets, requiredAssets); - assertEq(usdc.balanceOf(ALICE), requiredAssets); + assertEq(redeemedAssets, aliceExpectedAssets); + assertEq(usdc.balanceOf(ALICE), aliceExpectedAssets); assertEq(redeemer.balanceOf(ALICE), 0); } @@ -212,7 +228,19 @@ contract RedeemableTokenForkTest is Test { IYnRwa.withdrawAsset.selector, rule ) - ); + ); assertTrue(ok); } + + function _grantLiveWithdrawRole() internal { + bytes32 role = ynRwa.ASSET_WITHDRAWER_ROLE(); + if (ynRwa.hasRole(role, address(redeemer))) { + return; + } + + vm.prank(YNRWAX_DEFAULT_ADMIN); + ynRwa.grantRole(role, address(redeemer)); + + assertTrue(ynRwa.hasRole(role, address(redeemer))); + } } From f248d998ad495e77d2088bc6a88a28b69b624fc7 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Tue, 19 May 2026 18:13:21 +0000 Subject: [PATCH 22/36] Add redeemable token factory --- contracts/RedeemableTokenFactory.sol | 158 +++++++++++++++++++++ test/integration/RedeemableTokenFork.t.sol | 77 +++------- test/unit/TermRedeemer.t.sol | 74 +++------- 3 files changed, 196 insertions(+), 113 deletions(-) create mode 100644 contracts/RedeemableTokenFactory.sol diff --git a/contracts/RedeemableTokenFactory.sol b/contracts/RedeemableTokenFactory.sol new file mode 100644 index 0000000..d3ccd92 --- /dev/null +++ b/contracts/RedeemableTokenFactory.sol @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {IHooks} from "yieldnest-vault/src/interface/IHooks.sol"; +import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; +import {RedeemableToken} from "./RedeemableToken.sol"; +import {TermRedeemerController} from "./TermRedeemerController.sol"; + +error ZeroAddress(); +error InvalidRedemptionWindow(uint64 lockEnd, uint64 redeemStart); + +contract RedeemableTokenFactory { + address public immutable implementation; + + event Deployed(address indexed vault, address indexed hooks, address indexed controller, address admin); + + struct DeployParams { + address admin; + address provider; + address wrappedAsset; + address redemptionAsset; + address depositToken; + address feeRecipient; + string name; + string symbol; + uint8 decimals; + bool countNativeAsset; + bool alwaysComputeTotalAssets; + uint256 defaultAssetIndex; + uint64 lockEnd; + uint64 redeemStart; + } + + constructor(address implementation_) { + if (implementation_ == address(0)) { + revert ZeroAddress(); + } + + implementation = implementation_; + } + + function deploy(DeployParams calldata params) + external + returns (RedeemableToken vault, FeeHooks hooks, TermRedeemerController controller) + { + _validate(params); + + vault = RedeemableToken( + payable( + address( + new TransparentUpgradeableProxy( + implementation, + params.admin, + abi.encodeCall( + RedeemableToken.initialize, + (RedeemableToken.InitParams({ + admin: address(this), + name: params.name, + symbol: params.symbol, + decimals_: params.decimals, + countNativeAsset_: params.countNativeAsset, + alwaysComputeTotalAssets_: params.alwaysComputeTotalAssets, + defaultAssetIndex_: params.defaultAssetIndex + })) + ) + ) + ) + ) + ); + + IHooks.Config memory config = IHooks.Config({ + beforeDeposit: false, + afterDeposit: false, + beforeMint: false, + afterMint: false, + beforeRedeem: false, + afterRedeem: false, + beforeWithdraw: false, + afterWithdraw: false, + beforeProcessAccounting: false, + afterProcessAccounting: true + }); + + hooks = new FeeHooks(address(vault), address(this), 0, params.feeRecipient, config); + controller = new TermRedeemerController( + address(vault), address(hooks), params.depositToken, params.redemptionAsset, params.lockEnd, params.redeemStart + ); + + _grantTemporaryRoles(vault); + _configureVault(vault, hooks, controller, params); + _handoffRoles(vault, params.admin); + + emit Deployed(address(vault), address(hooks), address(controller), params.admin); + } + + function _validate(DeployParams calldata params) internal pure { + if ( + params.admin == address(0) || params.provider == address(0) || params.wrappedAsset == address(0) + || params.redemptionAsset == address(0) || params.depositToken == address(0) + || params.feeRecipient == address(0) + ) { + revert ZeroAddress(); + } + if (params.redeemStart < params.lockEnd) { + revert InvalidRedemptionWindow(params.lockEnd, params.redeemStart); + } + } + + function _grantTemporaryRoles(RedeemableToken vault) internal { + vault.grantRole(vault.PROCESSOR_ROLE(), address(this)); + vault.grantRole(vault.PROCESSOR_MANAGER_ROLE(), address(this)); + vault.grantRole(vault.PROVIDER_MANAGER_ROLE(), address(this)); + vault.grantRole(vault.ASSET_MANAGER_ROLE(), address(this)); + vault.grantRole(vault.HOOKS_MANAGER_ROLE(), address(this)); + vault.grantRole(vault.PAUSER_ROLE(), address(this)); + vault.grantRole(vault.UNPAUSER_ROLE(), address(this)); + } + + function _configureVault( + RedeemableToken vault, + FeeHooks hooks, + TermRedeemerController controller, + DeployParams calldata params + ) internal { + vault.setProvider(params.provider); + vault.addAsset(params.wrappedAsset, false); + vault.setAssetWithdrawable(params.wrappedAsset, false); + vault.addAsset(params.redemptionAsset, false); + vault.setAssetWithdrawable(params.redemptionAsset, false); + vault.addAsset(params.depositToken, true); + vault.setAssetWithdrawable(params.depositToken, false); + vault.setHooks(address(hooks)); + vault.grantRole(vault.ASSET_MANAGER_ROLE(), address(controller)); + hooks.transferOwnership(address(controller)); + vault.unpause(); + } + + function _handoffRoles(RedeemableToken vault, address admin) internal { + vault.grantRole(vault.DEFAULT_ADMIN_ROLE(), admin); + vault.grantRole(vault.PROCESSOR_ROLE(), admin); + vault.grantRole(vault.PROCESSOR_MANAGER_ROLE(), admin); + vault.grantRole(vault.PROVIDER_MANAGER_ROLE(), admin); + vault.grantRole(vault.ASSET_MANAGER_ROLE(), admin); + vault.grantRole(vault.HOOKS_MANAGER_ROLE(), admin); + vault.grantRole(vault.PAUSER_ROLE(), admin); + vault.grantRole(vault.UNPAUSER_ROLE(), admin); + + vault.renounceRole(vault.PROCESSOR_ROLE(), address(this)); + vault.renounceRole(vault.PROCESSOR_MANAGER_ROLE(), address(this)); + vault.renounceRole(vault.PROVIDER_MANAGER_ROLE(), address(this)); + vault.renounceRole(vault.ASSET_MANAGER_ROLE(), address(this)); + vault.renounceRole(vault.HOOKS_MANAGER_ROLE(), address(this)); + vault.renounceRole(vault.PAUSER_ROLE(), address(this)); + vault.renounceRole(vault.UNPAUSER_ROLE(), address(this)); + vault.renounceRole(vault.DEFAULT_ADMIN_ROLE(), address(this)); + } +} diff --git a/test/integration/RedeemableTokenFork.t.sol b/test/integration/RedeemableTokenFork.t.sol index 777d9dc..7a4d187 100644 --- a/test/integration/RedeemableTokenFork.t.sol +++ b/test/integration/RedeemableTokenFork.t.sol @@ -2,12 +2,11 @@ pragma solidity ^0.8.24; import {Test} from "forge-std/Test.sol"; -import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; -import {IHooks} from "yieldnest-vault/src/interface/IHooks.sol"; import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; +import {RedeemableTokenFactory} from "../../contracts/RedeemableTokenFactory.sol"; import {TermRedeemerController} from "../../contracts/TermRedeemerController.sol"; import {MockERC20} from "../mocks/MockERC20.sol"; import {MockRateProvider} from "../mocks/MockRateProvider.sol"; @@ -65,7 +64,6 @@ contract RedeemableTokenForkTest is Test { _syncRates(); _deployRedeemer(); - _configureRedeemer(); } function test_Fork_LiveYnRwaLifecycleWithUsdcTopUp() public { @@ -136,62 +134,25 @@ contract RedeemableTokenForkTest is Test { function _deployRedeemer() internal { RedeemableToken implementation = new RedeemableToken(); - TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( - address(implementation), - ADMIN, - abi.encodeCall( - RedeemableToken.initialize, - (RedeemableToken.InitParams({ - admin: ADMIN, - name: "Withdrawable ynRWAx", - symbol: "wynRWAx", - decimals_: 18, - countNativeAsset_: false, - alwaysComputeTotalAssets_: false, - defaultAssetIndex_: 1 - })) - ) + RedeemableTokenFactory factory = new RedeemableTokenFactory(address(implementation)); + (redeemer, feeHooks, controller) = factory.deploy( + RedeemableTokenFactory.DeployParams({ + admin: ADMIN, + provider: address(provider), + wrappedAsset: address(wrappedUsdc), + redemptionAsset: USDC, + depositToken: YNRWAX, + feeRecipient: FEE_RECIPIENT, + name: "Withdrawable ynRWAx", + symbol: "wynRWAx", + decimals: 18, + countNativeAsset: false, + alwaysComputeTotalAssets: false, + defaultAssetIndex: 1, + lockEnd: LOCK_END, + redeemStart: REDEEM_START + }) ); - redeemer = RedeemableToken(payable(address(proxy))); - - IHooks.Config memory config = IHooks.Config({ - beforeDeposit: false, - afterDeposit: false, - beforeMint: false, - afterMint: false, - beforeRedeem: false, - afterRedeem: false, - beforeWithdraw: false, - afterWithdraw: false, - beforeProcessAccounting: false, - afterProcessAccounting: true - }); - feeHooks = new FeeHooks(address(redeemer), ADMIN, 0, FEE_RECIPIENT, config); - controller = - new TermRedeemerController(address(redeemer), address(feeHooks), YNRWAX, USDC, LOCK_END, REDEEM_START); - } - - function _configureRedeemer() internal { - vm.startPrank(ADMIN); - redeemer.grantRole(redeemer.PROCESSOR_ROLE(), ADMIN); - redeemer.grantRole(redeemer.PROCESSOR_MANAGER_ROLE(), ADMIN); - redeemer.grantRole(redeemer.PROVIDER_MANAGER_ROLE(), ADMIN); - redeemer.grantRole(redeemer.ASSET_MANAGER_ROLE(), ADMIN); - redeemer.grantRole(redeemer.HOOKS_MANAGER_ROLE(), ADMIN); - redeemer.grantRole(redeemer.UNPAUSER_ROLE(), ADMIN); - - redeemer.setProvider(address(provider)); - redeemer.addAsset(address(wrappedUsdc), false); - redeemer.setAssetWithdrawable(address(wrappedUsdc), false); - redeemer.addAsset(USDC, false); - redeemer.setAssetWithdrawable(USDC, false); - redeemer.addAsset(YNRWAX, true); - redeemer.setAssetWithdrawable(YNRWAX, false); - redeemer.setHooks(address(feeHooks)); - redeemer.grantRole(redeemer.ASSET_MANAGER_ROLE(), address(controller)); - feeHooks.transferOwnership(address(controller)); - redeemer.unpause(); - vm.stopPrank(); } function _syncRates() internal { diff --git a/test/unit/TermRedeemer.t.sol b/test/unit/TermRedeemer.t.sol index 7244260..b498375 100644 --- a/test/unit/TermRedeemer.t.sol +++ b/test/unit/TermRedeemer.t.sol @@ -2,9 +2,9 @@ pragma solidity ^0.8.24; import {Test} from "forge-std/Test.sol"; -import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; +import {RedeemableTokenFactory} from "../../contracts/RedeemableTokenFactory.sol"; import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; import { AlreadyLocked, @@ -15,7 +15,6 @@ import { import {MockERC20} from "../mocks/MockERC20.sol"; import {MockRateProvider} from "../mocks/MockRateProvider.sol"; import {MockYnRwa} from "../mocks/MockYnRwa.sol"; -import {IHooks} from "yieldnest-vault/src/interface/IHooks.sol"; contract TermRedeemerTest is Test { struct ParamRule { @@ -57,60 +56,25 @@ contract TermRedeemerTest is Test { provider.setRate(address(ynRwa), 11e17); RedeemableToken implementation = new RedeemableToken(); - TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( - address(implementation), - ADMIN, - abi.encodeCall( - RedeemableToken.initialize, - (RedeemableToken.InitParams({ - admin: ADMIN, - name: "Withdrawable ynRWAx", - symbol: "wynRWAx", - decimals_: 18, - countNativeAsset_: false, - alwaysComputeTotalAssets_: false, - defaultAssetIndex_: 1 - })) - ) + RedeemableTokenFactory factory = new RedeemableTokenFactory(address(implementation)); + (redeemer, feeHooks, controller) = factory.deploy( + RedeemableTokenFactory.DeployParams({ + admin: ADMIN, + provider: address(provider), + wrappedAsset: address(wrappedUsdc), + redemptionAsset: address(usdc), + depositToken: address(ynRwa), + feeRecipient: FEE_RECIPIENT, + name: "Withdrawable ynRWAx", + symbol: "wynRWAx", + decimals: 18, + countNativeAsset: false, + alwaysComputeTotalAssets: false, + defaultAssetIndex: 1, + lockEnd: LOCK_END, + redeemStart: REDEEM_START + }) ); - redeemer = RedeemableToken(payable(address(proxy))); - - IHooks.Config memory config = IHooks.Config({ - beforeDeposit: false, - afterDeposit: false, - beforeMint: false, - afterMint: false, - beforeRedeem: false, - afterRedeem: false, - beforeWithdraw: false, - afterWithdraw: false, - beforeProcessAccounting: false, - afterProcessAccounting: true - }); - feeHooks = new FeeHooks(address(redeemer), ADMIN, 0, FEE_RECIPIENT, config); - controller = new TermRedeemerController( - address(redeemer), address(feeHooks), address(ynRwa), address(usdc), LOCK_END, REDEEM_START - ); - - vm.startPrank(ADMIN); - redeemer.grantRole(redeemer.PROCESSOR_ROLE(), ADMIN); - redeemer.grantRole(redeemer.PROCESSOR_MANAGER_ROLE(), ADMIN); - redeemer.grantRole(redeemer.PROVIDER_MANAGER_ROLE(), ADMIN); - redeemer.grantRole(redeemer.ASSET_MANAGER_ROLE(), ADMIN); - redeemer.grantRole(redeemer.HOOKS_MANAGER_ROLE(), ADMIN); - redeemer.grantRole(redeemer.UNPAUSER_ROLE(), ADMIN); - redeemer.setProvider(address(provider)); - redeemer.addAsset(address(wrappedUsdc), false); - redeemer.setAssetWithdrawable(address(wrappedUsdc), false); - redeemer.addAsset(address(usdc), false); - redeemer.setAssetWithdrawable(address(usdc), false); - redeemer.addAsset(address(ynRwa), true); - redeemer.setAssetWithdrawable(address(ynRwa), false); - redeemer.setHooks(address(feeHooks)); - redeemer.grantRole(redeemer.ASSET_MANAGER_ROLE(), address(controller)); - feeHooks.transferOwnership(address(controller)); - redeemer.unpause(); - vm.stopPrank(); ynRwa.mint(ALICE, 200 ether); ynRwa.mint(BOB, 100 ether); From 04091c0446d8db8e3964b1e936fda65508964bdf Mon Sep 17 00:00:00 2001 From: danoctavian Date: Tue, 19 May 2026 18:55:36 +0000 Subject: [PATCH 23/36] Add test deployment scripts --- contracts/RedeemableTokenFactory.sol | 24 +++-- contracts/TermRedeemerController.sol | 54 ++++++++-- script/test/ActivateRedemption.s.sol | 53 ++++++++++ script/test/BaseTestScript.s.sol | 33 +++++++ script/test/Contracts.sol | 35 +++++++ script/test/DeployMockYnRWAx.s.sol | 54 ++++++++++ script/test/DeployTermRedeemer.s.sol | 98 +++++++++++++++++++ .../DepositMockYnRWAxIntoTermRedeemer.s.sol | 25 +++++ script/test/DepositUsdcIntoMockYnRWAx.s.sol | 24 +++++ script/test/LockRedeemer.s.sol | 19 ++++ script/test/ResetRedeemerStage.s.sol | 19 ++++ test/integration/RedeemableTokenFork.t.sol | 1 + test/unit/TermRedeemer.t.sol | 1 + 13 files changed, 428 insertions(+), 12 deletions(-) create mode 100644 script/test/ActivateRedemption.s.sol create mode 100644 script/test/BaseTestScript.s.sol create mode 100644 script/test/Contracts.sol create mode 100644 script/test/DeployMockYnRWAx.s.sol create mode 100644 script/test/DeployTermRedeemer.s.sol create mode 100644 script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol create mode 100644 script/test/DepositUsdcIntoMockYnRWAx.s.sol create mode 100644 script/test/LockRedeemer.s.sol create mode 100644 script/test/ResetRedeemerStage.s.sol diff --git a/contracts/RedeemableTokenFactory.sol b/contracts/RedeemableTokenFactory.sol index d3ccd92..b8b4ca4 100644 --- a/contracts/RedeemableTokenFactory.sol +++ b/contracts/RedeemableTokenFactory.sol @@ -27,6 +27,7 @@ contract RedeemableTokenFactory { uint8 decimals; bool countNativeAsset; bool alwaysComputeTotalAssets; + bool unrestrictedController; uint256 defaultAssetIndex; uint64 lockEnd; uint64 redeemStart; @@ -84,7 +85,14 @@ contract RedeemableTokenFactory { hooks = new FeeHooks(address(vault), address(this), 0, params.feeRecipient, config); controller = new TermRedeemerController( - address(vault), address(hooks), params.depositToken, params.redemptionAsset, params.lockEnd, params.redeemStart + address(vault), + address(hooks), + params.admin, + params.depositToken, + params.redemptionAsset, + params.lockEnd, + params.redeemStart, + params.unrestrictedController ); _grantTemporaryRoles(vault); @@ -96,9 +104,8 @@ contract RedeemableTokenFactory { function _validate(DeployParams calldata params) internal pure { if ( - params.admin == address(0) || params.provider == address(0) || params.wrappedAsset == address(0) - || params.redemptionAsset == address(0) || params.depositToken == address(0) - || params.feeRecipient == address(0) + params.admin == address(0) || params.provider == address(0) || params.redemptionAsset == address(0) + || params.depositToken == address(0) || params.feeRecipient == address(0) ) { revert ZeroAddress(); } @@ -124,8 +131,13 @@ contract RedeemableTokenFactory { DeployParams calldata params ) internal { vault.setProvider(params.provider); - vault.addAsset(params.wrappedAsset, false); - vault.setAssetWithdrawable(params.wrappedAsset, false); + if ( + params.wrappedAsset != address(0) && params.wrappedAsset != params.redemptionAsset + && params.wrappedAsset != params.depositToken + ) { + vault.addAsset(params.wrappedAsset, false); + vault.setAssetWithdrawable(params.wrappedAsset, false); + } vault.addAsset(params.redemptionAsset, false); vault.setAssetWithdrawable(params.redemptionAsset, false); vault.addAsset(params.depositToken, true); diff --git a/contracts/TermRedeemerController.sol b/contracts/TermRedeemerController.sol index 4597724..526e131 100644 --- a/contracts/TermRedeemerController.sol +++ b/contracts/TermRedeemerController.sol @@ -10,43 +10,88 @@ error RedeemNotReady(uint256 timestamp, uint64 redeemStart); error AlreadyLocked(); error NotLocked(); error RedemptionAlreadyActivated(); +error TransitionAdminOnly(address caller); +error RestrictedController(); +error RedemptionNotActivated(); contract TermRedeemerController { uint256 internal constant FEE_DENOMINATOR = 1 ether; RedeemableToken public immutable vault; IFeeHooks public immutable hooks; + address public immutable transitionAdmin; address public immutable depositToken; address public immutable redemptionAsset; uint64 public immutable lockEnd; uint64 public immutable redeemStart; + bool public immutable unrestrictedTransitions; bool public locked; bool public redemptionActivated; event Locked(uint256 totalAssetsSnapshot); event RedemptionActivated(uint256 requiredAssets); + event ResetToInitialStage(); constructor( address vault_, address hooks_, + address transitionAdmin_, address depositToken_, address redemptionAsset_, uint64 lockEnd_, - uint64 redeemStart_ + uint64 redeemStart_, + bool unrestrictedTransitions_ ) { vault = RedeemableToken(payable(vault_)); hooks = IFeeHooks(hooks_); + transitionAdmin = transitionAdmin_; depositToken = depositToken_; redemptionAsset = redemptionAsset_; lockEnd = lockEnd_; redeemStart = redeemStart_; + unrestrictedTransitions = unrestrictedTransitions_; } function lock() external returns (uint256 totalAssetsSnapshot) { - if (block.timestamp < lockEnd) { + if (!unrestrictedTransitions && block.timestamp < lockEnd) { revert LockNotReady(block.timestamp, lockEnd); } + totalAssetsSnapshot = _lock(); + } + + function activateRedemption() external returns (uint256 requiredAssets) { + if (!unrestrictedTransitions && block.timestamp < redeemStart) { + revert RedeemNotReady(block.timestamp, redeemStart); + } + requiredAssets = _activateRedemption(); + } + + function resetToInitialStage() external { + if (!unrestrictedTransitions) { + revert RestrictedController(); + } + if (msg.sender != transitionAdmin) { + revert TransitionAdminOnly(msg.sender); + } + if (!redemptionActivated) { + revert RedemptionNotActivated(); + } + + vault.setAssetWithdrawable(redemptionAsset, false); + + IVault.AssetParams memory params = vault.getAsset(depositToken); + IVault.AssetUpdateFields memory fields = IVault.AssetUpdateFields({active: true}); + vault.updateAsset(params.index, fields); + hooks.setPerformanceFee(0); + + locked = false; + redemptionActivated = false; + + emit ResetToInitialStage(); + } + + function _lock() internal returns (uint256 totalAssetsSnapshot) { if (locked) { revert AlreadyLocked(); } @@ -64,10 +109,7 @@ contract TermRedeemerController { emit Locked(totalAssetsSnapshot); } - function activateRedemption() external returns (uint256 requiredAssets) { - if (block.timestamp < redeemStart) { - revert RedeemNotReady(block.timestamp, redeemStart); - } + function _activateRedemption() internal returns (uint256 requiredAssets) { if (redemptionActivated) { revert RedemptionAlreadyActivated(); } diff --git a/script/test/ActivateRedemption.s.sol b/script/test/ActivateRedemption.s.sol new file mode 100644 index 0000000..f4fffb0 --- /dev/null +++ b/script/test/ActivateRedemption.s.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; +import {TermRedeemerController} from "../../contracts/TermRedeemerController.sol"; +import {BaseTestScript} from "./BaseTestScript.s.sol"; +import {Contracts} from "./Contracts.sol"; +import {console2} from "forge-std/Script.sol"; + +interface IWithdrawableVault is IERC20 { + function maxWithdrawAsset(address asset_, address owner) external view returns (uint256); + function withdrawAsset(address asset_, uint256 assets, address receiver, address owner) + external + returns (uint256 sharesBurned); +} + +error InsufficientMockYnRWAxLiquidity(uint256 requestedAssets, uint256 maxWithdrawableAssets); + +contract ActivateRedemption is BaseTestScript { + function run() external { + address controllerAddress = _loadAddress(Contracts.TERM_CONTROLLER_KEY); + address vaultAddress = _loadAddress(Contracts.TERM_VAULT_KEY); + address mockYnRWAx = _loadAddress(Contracts.MOCK_YNRWAX_KEY); + + vm.startBroadcast(); + + TermRedeemerController controller = TermRedeemerController(controllerAddress); + RedeemableToken vault = RedeemableToken(payable(vaultAddress)); + uint256 requiredAssets = controller.activateRedemption(); + + uint256 maxWithdrawableAssets = IWithdrawableVault(mockYnRWAx).maxWithdrawAsset(Contracts.USDC, vaultAddress); + if (requiredAssets > maxWithdrawableAssets) { + revert InsufficientMockYnRWAxLiquidity(requiredAssets, maxWithdrawableAssets); + } + + bytes[] memory data = new bytes[](1); + address[] memory targets = new address[](1); + uint256[] memory values = new uint256[](1); + + targets[0] = mockYnRWAx; + data[0] = abi.encodeWithSelector( + IWithdrawableVault.withdrawAsset.selector, Contracts.USDC, requiredAssets, vaultAddress, vaultAddress + ); + + vault.processor(targets, values, data); + + vm.stopBroadcast(); + + console2.log("Activated redemption with required USDC:", requiredAssets); + console2.log("Term vault USDC balance:", IERC20(Contracts.USDC).balanceOf(vaultAddress)); + } +} diff --git a/script/test/BaseTestScript.s.sol b/script/test/BaseTestScript.s.sol new file mode 100644 index 0000000..b2d2c28 --- /dev/null +++ b/script/test/BaseTestScript.s.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Script, console2} from "forge-std/Script.sol"; +import {Contracts} from "./Contracts.sol"; + +error MissingDeployment(string name, string path); + +abstract contract BaseTestScript is Script { + function _broadcaster() internal view returns (address) { + return tx.origin; + } + + function _recordAddress(string memory name, address value) internal { + vm.writeFile(_path(name), vm.toString(value)); + } + + function _loadAddress(string memory name) internal view returns (address value) { + string memory path = _path(name); + if (!vm.isFile(path)) { + revert MissingDeployment(name, path); + } + value = vm.parseAddress(vm.readFile(path)); + } + + function _path(string memory name) internal view returns (string memory) { + return string.concat(vm.projectRoot(), "/script/test/", name, "-", vm.toString(block.chainid), ".txt"); + } + + function _logAddress(string memory label, address value) internal pure { + console2.log(string.concat(label, ": "), value); + } +} diff --git a/script/test/Contracts.sol b/script/test/Contracts.sol new file mode 100644 index 0000000..3230e71 --- /dev/null +++ b/script/test/Contracts.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +library Contracts { + address internal constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + + string internal constant AMOUNT_ENV = "AMOUNT"; + + string internal constant MOCK_YNRWAX_PROVIDER_KEY = "mock-ynrwax-provider"; + string internal constant MOCK_YNRWAX_IMPLEMENTATION_KEY = "mock-ynrwax-implementation"; + string internal constant MOCK_YNRWAX_KEY = "mock-ynrwax"; + + string internal constant TERM_PROVIDER_KEY = "term-provider"; + string internal constant TERM_IMPLEMENTATION_KEY = "term-implementation"; + string internal constant TERM_FACTORY_KEY = "term-factory"; + string internal constant TERM_VAULT_KEY = "term-vault"; + string internal constant TERM_HOOKS_KEY = "term-hooks"; + string internal constant TERM_CONTROLLER_KEY = "term-controller"; + + string internal constant MOCK_YNRWAX_NAME = "Mock ynRWAx"; + string internal constant MOCK_YNRWAX_SYMBOL = "mynRWAx"; + string internal constant TERM_VAULT_NAME = "Withdrawable Mock ynRWAx"; + string internal constant TERM_VAULT_SYMBOL = "wmynRWAx"; + + string internal constant MOCK_YNRWAX_LABEL = "Mock ynRWAx"; + string internal constant MOCK_YNRWAX_PROVIDER_LABEL = "Mock ynRWAx provider"; + string internal constant TERM_VAULT_LABEL = "Term vault"; + string internal constant TERM_HOOKS_LABEL = "Term hooks"; + string internal constant TERM_CONTROLLER_LABEL = "Term controller"; + + uint8 internal constant VAULT_DECIMALS = 18; + uint256 internal constant ONE = 1e18; + uint256 internal constant TEST_STAGE_TIME = 0; + uint256 internal constant DEFAULT_ASSET_INDEX = 0; +} diff --git a/script/test/DeployMockYnRWAx.s.sol b/script/test/DeployMockYnRWAx.s.sol new file mode 100644 index 0000000..689a76e --- /dev/null +++ b/script/test/DeployMockYnRWAx.s.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; +import {MockRateProvider} from "../../test/mocks/MockRateProvider.sol"; +import {BaseTestScript} from "./BaseTestScript.s.sol"; +import {Contracts} from "./Contracts.sol"; + +contract DeployMockYnRWAx is BaseTestScript { + function run() external { + vm.startBroadcast(); + + address admin = _broadcaster(); + MockRateProvider provider = new MockRateProvider(); + provider.setRate(Contracts.USDC, Contracts.ONE); + + RedeemableToken implementation = new RedeemableToken(); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(implementation), + admin, + abi.encodeCall( + RedeemableToken.initialize, + (RedeemableToken.InitParams({ + admin: admin, + name: Contracts.MOCK_YNRWAX_NAME, + symbol: Contracts.MOCK_YNRWAX_SYMBOL, + decimals_: Contracts.VAULT_DECIMALS, + countNativeAsset_: false, + alwaysComputeTotalAssets_: false, + defaultAssetIndex_: Contracts.DEFAULT_ASSET_INDEX + })) + ) + ); + + RedeemableToken vault = RedeemableToken(payable(address(proxy))); + vault.grantRole(vault.PROVIDER_MANAGER_ROLE(), admin); + vault.grantRole(vault.ASSET_MANAGER_ROLE(), admin); + vault.grantRole(vault.UNPAUSER_ROLE(), admin); + vault.setProvider(address(provider)); + vault.addAsset(Contracts.USDC, true); + vault.setAssetWithdrawable(Contracts.USDC, true); + vault.unpause(); + + vm.stopBroadcast(); + + _recordAddress(Contracts.MOCK_YNRWAX_PROVIDER_KEY, address(provider)); + _recordAddress(Contracts.MOCK_YNRWAX_IMPLEMENTATION_KEY, address(implementation)); + _recordAddress(Contracts.MOCK_YNRWAX_KEY, address(vault)); + + _logAddress(Contracts.MOCK_YNRWAX_LABEL, address(vault)); + _logAddress(Contracts.MOCK_YNRWAX_PROVIDER_LABEL, address(provider)); + } +} diff --git a/script/test/DeployTermRedeemer.s.sol b/script/test/DeployTermRedeemer.s.sol new file mode 100644 index 0000000..91917ff --- /dev/null +++ b/script/test/DeployTermRedeemer.s.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; +import {RedeemableTokenFactory} from "../../contracts/RedeemableTokenFactory.sol"; +import {TermRedeemerController} from "../../contracts/TermRedeemerController.sol"; +import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; +import {MockRateProvider} from "../../test/mocks/MockRateProvider.sol"; +import {BaseTestScript} from "./BaseTestScript.s.sol"; +import {Contracts} from "./Contracts.sol"; + +error ProcessorRuleSetupFailed(); + +contract DeployTermRedeemer is BaseTestScript { + struct ParamRule { + uint8 paramType; + bool isArray; + address[] allowList; + } + + struct FunctionRule { + bool isActive; + ParamRule[] paramRules; + address validator; + } + + function run() external { + address admin = _broadcaster(); + address mockYnRWAx = _loadAddress(Contracts.MOCK_YNRWAX_KEY); + + vm.startBroadcast(); + + MockRateProvider provider = new MockRateProvider(); + provider.setRate(Contracts.USDC, Contracts.ONE); + provider.setRate(mockYnRWAx, Contracts.ONE); + + RedeemableToken implementation = new RedeemableToken(); + RedeemableTokenFactory factory = new RedeemableTokenFactory(address(implementation)); + (RedeemableToken vault, FeeHooks hooks, TermRedeemerController controller) = factory.deploy( + RedeemableTokenFactory.DeployParams({ + admin: admin, + provider: address(provider), + wrappedAsset: address(0), + redemptionAsset: Contracts.USDC, + depositToken: mockYnRWAx, + feeRecipient: admin, + name: Contracts.TERM_VAULT_NAME, + symbol: Contracts.TERM_VAULT_SYMBOL, + decimals: Contracts.VAULT_DECIMALS, + countNativeAsset: false, + alwaysComputeTotalAssets: false, + unrestrictedController: true, + defaultAssetIndex: Contracts.DEFAULT_ASSET_INDEX, + lockEnd: uint64(Contracts.TEST_STAGE_TIME), + redeemStart: uint64(Contracts.TEST_STAGE_TIME) + }) + ); + + _allowProcessorWithdrawAsset(vault, mockYnRWAx, Contracts.USDC); + + vm.stopBroadcast(); + + _recordAddress(Contracts.TERM_PROVIDER_KEY, address(provider)); + _recordAddress(Contracts.TERM_IMPLEMENTATION_KEY, address(implementation)); + _recordAddress(Contracts.TERM_FACTORY_KEY, address(factory)); + _recordAddress(Contracts.TERM_VAULT_KEY, address(vault)); + _recordAddress(Contracts.TERM_HOOKS_KEY, address(hooks)); + _recordAddress(Contracts.TERM_CONTROLLER_KEY, address(controller)); + + _logAddress(Contracts.TERM_VAULT_LABEL, address(vault)); + _logAddress(Contracts.TERM_HOOKS_LABEL, address(hooks)); + _logAddress(Contracts.TERM_CONTROLLER_LABEL, address(controller)); + } + + function _allowProcessorWithdrawAsset(RedeemableToken vault, address target, address redemptionAsset) internal { + address[] memory vaultAllowList = new address[](1); + vaultAllowList[0] = address(vault); + address[] memory assetAllowList = new address[](1); + assetAllowList[0] = redemptionAsset; + ParamRule[] memory paramRules = new ParamRule[](4); + paramRules[0] = ParamRule({paramType: 1, isArray: false, allowList: assetAllowList}); + paramRules[2] = ParamRule({paramType: 1, isArray: false, allowList: vaultAllowList}); + paramRules[3] = ParamRule({paramType: 1, isArray: false, allowList: vaultAllowList}); + FunctionRule memory rule = FunctionRule({isActive: true, paramRules: paramRules, validator: address(0)}); + + (bool ok,) = address(vault).call( + abi.encodeWithSignature( + "setProcessorRule(address,bytes4,(bool,(uint8,bool,address[])[],address))", + target, + bytes4(keccak256("withdrawAsset(address,uint256,address,address)")), + rule + ) + ); + if (!ok) { + revert ProcessorRuleSetupFailed(); + } + } +} diff --git a/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol b/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol new file mode 100644 index 0000000..aa4c9c0 --- /dev/null +++ b/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; +import {BaseTestScript} from "./BaseTestScript.s.sol"; +import {Contracts} from "./Contracts.sol"; +import {console2} from "forge-std/Script.sol"; + +contract DepositMockYnRWAxIntoTermRedeemer is BaseTestScript { + function run() external { + uint256 assets = vm.envUint(Contracts.AMOUNT_ENV); + address vault = _loadAddress(Contracts.TERM_VAULT_KEY); + address depositToken = _loadAddress(Contracts.MOCK_YNRWAX_KEY); + address depositor = _broadcaster(); + + vm.startBroadcast(); + IERC20(depositToken).approve(vault, assets); + uint256 shares = RedeemableToken(payable(vault)).depositAsset(depositToken, assets, depositor); + vm.stopBroadcast(); + + console2.log("Deposited mynRWAx:", assets); + console2.log("Minted term shares:", shares); + } +} diff --git a/script/test/DepositUsdcIntoMockYnRWAx.s.sol b/script/test/DepositUsdcIntoMockYnRWAx.s.sol new file mode 100644 index 0000000..e7c36b3 --- /dev/null +++ b/script/test/DepositUsdcIntoMockYnRWAx.s.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; +import {BaseTestScript} from "./BaseTestScript.s.sol"; +import {Contracts} from "./Contracts.sol"; +import {console2} from "forge-std/Script.sol"; + +contract DepositUsdcIntoMockYnRWAx is BaseTestScript { + function run() external { + uint256 assets = vm.envUint(Contracts.AMOUNT_ENV); + address vault = _loadAddress(Contracts.MOCK_YNRWAX_KEY); + address depositor = _broadcaster(); + + vm.startBroadcast(); + IERC20(Contracts.USDC).approve(vault, assets); + uint256 shares = RedeemableToken(payable(vault)).depositAsset(Contracts.USDC, assets, depositor); + vm.stopBroadcast(); + + console2.log("Deposited USDC:", assets); + console2.log("Minted mynRWAx shares:", shares); + } +} diff --git a/script/test/LockRedeemer.s.sol b/script/test/LockRedeemer.s.sol new file mode 100644 index 0000000..1156808 --- /dev/null +++ b/script/test/LockRedeemer.s.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {TermRedeemerController} from "../../contracts/TermRedeemerController.sol"; +import {BaseTestScript} from "./BaseTestScript.s.sol"; +import {Contracts} from "./Contracts.sol"; +import {console2} from "forge-std/Script.sol"; + +contract LockRedeemer is BaseTestScript { + function run() external { + address controllerAddress = _loadAddress(Contracts.TERM_CONTROLLER_KEY); + + vm.startBroadcast(); + uint256 totalAssetsSnapshot = TermRedeemerController(controllerAddress).lock(); + vm.stopBroadcast(); + + console2.log("Locked term vault at total assets:", totalAssetsSnapshot); + } +} diff --git a/script/test/ResetRedeemerStage.s.sol b/script/test/ResetRedeemerStage.s.sol new file mode 100644 index 0000000..e03e518 --- /dev/null +++ b/script/test/ResetRedeemerStage.s.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {TermRedeemerController} from "../../contracts/TermRedeemerController.sol"; +import {BaseTestScript} from "./BaseTestScript.s.sol"; +import {Contracts} from "./Contracts.sol"; +import {console2} from "forge-std/Script.sol"; + +contract ResetRedeemerStage is BaseTestScript { + function run() external { + address controllerAddress = _loadAddress(Contracts.TERM_CONTROLLER_KEY); + + vm.startBroadcast(); + TermRedeemerController(controllerAddress).resetToInitialStage(); + vm.stopBroadcast(); + + console2.log("Reset term vault to initial deposit stage."); + } +} diff --git a/test/integration/RedeemableTokenFork.t.sol b/test/integration/RedeemableTokenFork.t.sol index 7a4d187..c4065c7 100644 --- a/test/integration/RedeemableTokenFork.t.sol +++ b/test/integration/RedeemableTokenFork.t.sol @@ -148,6 +148,7 @@ contract RedeemableTokenForkTest is Test { decimals: 18, countNativeAsset: false, alwaysComputeTotalAssets: false, + unrestrictedController: false, defaultAssetIndex: 1, lockEnd: LOCK_END, redeemStart: REDEEM_START diff --git a/test/unit/TermRedeemer.t.sol b/test/unit/TermRedeemer.t.sol index b498375..f163b35 100644 --- a/test/unit/TermRedeemer.t.sol +++ b/test/unit/TermRedeemer.t.sol @@ -70,6 +70,7 @@ contract TermRedeemerTest is Test { decimals: 18, countNativeAsset: false, alwaysComputeTotalAssets: false, + unrestrictedController: false, defaultAssetIndex: 1, lockEnd: LOCK_END, redeemStart: REDEEM_START From c406f0d4d16fca8039ea20c06d79c5c1aa278fbe Mon Sep 17 00:00:00 2001 From: danoctavian Date: Tue, 19 May 2026 22:14:33 +0000 Subject: [PATCH 24/36] Restructure repo around src and script deployers --- README.md | 20 +- contracts | 1 + deployments/deploy/.gitkeep | 1 + deployments/test/.gitkeep | 1 + script/common/BaseDeploymentScript.s.sol | 40 ++++ .../common/RedeemableTokenDeployer.sol | 42 ++++- .../deploy/DeployRedeemableTokenFactory.s.sol | 27 +++ script/test/ActivateRedemption.s.sol | 10 +- script/test/BaseTestScript.s.sol | 28 +-- script/test/Contracts.sol | 21 ++- script/test/DeployMockYnRWAx.s.sol | 16 +- script/test/DeployTermRedeemer.s.sol | 42 +++-- .../DepositMockYnRWAxIntoTermRedeemer.s.sol | 4 +- script/test/DepositUsdcIntoMockYnRWAx.s.sol | 2 +- script/test/LockRedeemer.s.sol | 6 +- script/test/ResetRedeemerStage.s.sol | 6 +- script/test/utils/TestRateProvider.sol | 16 ++ .../test/utils/TestRedeemableTokenFactory.sol | 175 ++++++++++++++++++ .../test/utils/TestTermRedeemerController.sol | 95 ++++++++++ {contracts => src}/RedeemableToken.sol | 0 {contracts => src}/TermRedeemerController.sol | 76 +++----- test/integration/RedeemableTokenFork.t.sol | 7 +- test/unit/TermRedeemer.t.sol | 7 +- 23 files changed, 496 insertions(+), 147 deletions(-) create mode 120000 contracts create mode 100644 deployments/deploy/.gitkeep create mode 100644 deployments/test/.gitkeep create mode 100644 script/common/BaseDeploymentScript.s.sol rename contracts/RedeemableTokenFactory.sol => script/common/RedeemableTokenDeployer.sol (83%) create mode 100644 script/deploy/DeployRedeemableTokenFactory.s.sol create mode 100644 script/test/utils/TestRateProvider.sol create mode 100644 script/test/utils/TestRedeemableTokenFactory.sol create mode 100644 script/test/utils/TestTermRedeemerController.sol rename {contracts => src}/RedeemableToken.sol (100%) rename {contracts => src}/TermRedeemerController.sol (53%) diff --git a/README.md b/README.md index 8e4bdb1..74c54c5 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ flowchart LR ### Flow -1. The vault is initialized paused, then configured externally with roles, provider, assets, and hooks. +1. The vault is deployed through the factory, initialized paused, and then configured atomically with roles, provider, assets, and hooks. 2. Users deposit `ynRWAx` into `RedeemableToken` and receive `wynRWAx`. 3. When the lock period is over, `lock()` is called on the controller: - accounting is processed once @@ -95,9 +95,10 @@ sequenceDiagram ### Key Files -- `contracts/RedeemableToken.sol` -- `contracts/TermRedeemerController.sol` -- `test/TermRedeemer.t.sol` +- `src/RedeemableToken.sol` +- `src/TermRedeemerController.sol` +- `script/common/RedeemableTokenDeployer.sol` +- `test/unit/TermRedeemer.t.sol` ### Commands @@ -106,3 +107,14 @@ forge build forge test forge fmt ``` + +### Layout + +- `src/` + - production contracts +- `script/deploy` + - production deployment scripts +- `script/test` + - test harness deployment and lifecycle scripts +- `deployments/` + - per-chain JSON deployment artifacts written by scripts diff --git a/contracts b/contracts new file mode 120000 index 0000000..e831038 --- /dev/null +++ b/contracts @@ -0,0 +1 @@ +src \ No newline at end of file diff --git a/deployments/deploy/.gitkeep b/deployments/deploy/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/deployments/deploy/.gitkeep @@ -0,0 +1 @@ + diff --git a/deployments/test/.gitkeep b/deployments/test/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/deployments/test/.gitkeep @@ -0,0 +1 @@ + diff --git a/script/common/BaseDeploymentScript.s.sol b/script/common/BaseDeploymentScript.s.sol new file mode 100644 index 0000000..d3b9a88 --- /dev/null +++ b/script/common/BaseDeploymentScript.s.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Script} from "forge-std/Script.sol"; + +error MissingDeployment(string namespace, string path); +error InvalidDeploymentPayload(); + +abstract contract BaseDeploymentScript is Script { + function _broadcaster() internal view returns (address) { + return tx.origin; + } + + function _writeAddresses(string memory namespace, string[] memory keys, address[] memory values) internal { + if (keys.length != values.length) { + revert InvalidDeploymentPayload(); + } + + string memory objectKey = "deployment"; + for (uint256 i = 0; i < keys.length; ++i) { + vm.serializeAddress(objectKey, keys[i], values[i]); + } + string memory json = vm.serializeUint(objectKey, "chainId", block.chainid); + + vm.writeJson(json, _deploymentPath(namespace)); + } + + function _loadAddress(string memory namespace, string memory key) internal view returns (address value) { + string memory path = _deploymentPath(namespace); + if (!vm.isFile(path)) { + revert MissingDeployment(namespace, path); + } + + value = vm.parseJsonAddress(vm.readFile(path), string.concat(".", key)); + } + + function _deploymentPath(string memory namespace) internal view returns (string memory) { + return string.concat(vm.projectRoot(), "/deployments/", namespace, "/", vm.toString(block.chainid), ".json"); + } +} diff --git a/contracts/RedeemableTokenFactory.sol b/script/common/RedeemableTokenDeployer.sol similarity index 83% rename from contracts/RedeemableTokenFactory.sol rename to script/common/RedeemableTokenDeployer.sol index b8b4ca4..77902e1 100644 --- a/contracts/RedeemableTokenFactory.sol +++ b/script/common/RedeemableTokenDeployer.sol @@ -2,15 +2,14 @@ pragma solidity ^0.8.24; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; -import {IHooks} from "yieldnest-vault/src/interface/IHooks.sol"; import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; -import {RedeemableToken} from "./RedeemableToken.sol"; -import {TermRedeemerController} from "./TermRedeemerController.sol"; +import {RedeemableToken} from "contracts/RedeemableToken.sol"; +import {TermRedeemerController} from "contracts/TermRedeemerController.sol"; error ZeroAddress(); error InvalidRedemptionWindow(uint64 lockEnd, uint64 redeemStart); -contract RedeemableTokenFactory { +contract RedeemableTokenDeployer { address public immutable implementation; event Deployed(address indexed vault, address indexed hooks, address indexed controller, address admin); @@ -27,12 +26,24 @@ contract RedeemableTokenFactory { uint8 decimals; bool countNativeAsset; bool alwaysComputeTotalAssets; - bool unrestrictedController; uint256 defaultAssetIndex; uint64 lockEnd; uint64 redeemStart; } + struct HookConfig { + bool beforeDeposit; + bool afterDeposit; + bool beforeMint; + bool afterMint; + bool beforeRedeem; + bool afterRedeem; + bool beforeWithdraw; + bool afterWithdraw; + bool beforeProcessAccounting; + bool afterProcessAccounting; + } + constructor(address implementation_) { if (implementation_ == address(0)) { revert ZeroAddress(); @@ -70,7 +81,7 @@ contract RedeemableTokenFactory { ) ); - IHooks.Config memory config = IHooks.Config({ + HookConfig memory config = HookConfig({ beforeDeposit: false, afterDeposit: false, beforeMint: false, @@ -83,16 +94,14 @@ contract RedeemableTokenFactory { afterProcessAccounting: true }); - hooks = new FeeHooks(address(vault), address(this), 0, params.feeRecipient, config); + hooks = _deployFeeHooks(address(vault), params.feeRecipient, config); controller = new TermRedeemerController( address(vault), address(hooks), - params.admin, params.depositToken, params.redemptionAsset, params.lockEnd, - params.redeemStart, - params.unrestrictedController + params.redeemStart ); _grantTemporaryRoles(vault); @@ -167,4 +176,17 @@ contract RedeemableTokenFactory { vault.renounceRole(vault.UNPAUSER_ROLE(), address(this)); vault.renounceRole(vault.DEFAULT_ADMIN_ROLE(), address(this)); } + + function _deployFeeHooks(address vault, address feeRecipient, HookConfig memory config) internal returns (FeeHooks hooks) { + bytes memory initCode = + abi.encodePacked(type(FeeHooks).creationCode, abi.encode(vault, address(this), 0, feeRecipient, config)); + address deployed; + assembly { + deployed := create(0, add(initCode, 0x20), mload(initCode)) + } + if (deployed == address(0)) { + revert ZeroAddress(); + } + hooks = FeeHooks(deployed); + } } diff --git a/script/deploy/DeployRedeemableTokenFactory.s.sol b/script/deploy/DeployRedeemableTokenFactory.s.sol new file mode 100644 index 0000000..73445b8 --- /dev/null +++ b/script/deploy/DeployRedeemableTokenFactory.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; +import {RedeemableTokenDeployer} from "../common/RedeemableTokenDeployer.sol"; +import {BaseDeploymentScript} from "../common/BaseDeploymentScript.s.sol"; + +contract DeployRedeemableTokenFactory is BaseDeploymentScript { + string internal constant DEPLOY_NAMESPACE = "deploy"; + + function run() external { + vm.startBroadcast(); + + RedeemableToken implementation = new RedeemableToken(); + RedeemableTokenDeployer factory = new RedeemableTokenDeployer(address(implementation)); + + vm.stopBroadcast(); + + string[] memory keys = new string[](2); + address[] memory values = new address[](2); + keys[0] = "implementation"; + values[0] = address(implementation); + keys[1] = "factory"; + values[1] = address(factory); + _writeAddresses(DEPLOY_NAMESPACE, keys, values); + } +} diff --git a/script/test/ActivateRedemption.s.sol b/script/test/ActivateRedemption.s.sol index f4fffb0..bf24fb7 100644 --- a/script/test/ActivateRedemption.s.sol +++ b/script/test/ActivateRedemption.s.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.24; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; -import {TermRedeemerController} from "../../contracts/TermRedeemerController.sol"; +import {TestTermRedeemerController} from "./utils/TestTermRedeemerController.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; import {console2} from "forge-std/Script.sol"; @@ -19,13 +19,13 @@ error InsufficientMockYnRWAxLiquidity(uint256 requestedAssets, uint256 maxWithdr contract ActivateRedemption is BaseTestScript { function run() external { - address controllerAddress = _loadAddress(Contracts.TERM_CONTROLLER_KEY); - address vaultAddress = _loadAddress(Contracts.TERM_VAULT_KEY); - address mockYnRWAx = _loadAddress(Contracts.MOCK_YNRWAX_KEY); + address controllerAddress = _loadAddress(Contracts.TERM_NAMESPACE, Contracts.TERM_CONTROLLER_KEY); + address vaultAddress = _loadAddress(Contracts.TERM_NAMESPACE, Contracts.TERM_VAULT_KEY); + address mockYnRWAx = _loadAddress(Contracts.MOCK_YNRWAX_NAMESPACE, Contracts.MOCK_YNRWAX_KEY); vm.startBroadcast(); - TermRedeemerController controller = TermRedeemerController(controllerAddress); + TestTermRedeemerController controller = TestTermRedeemerController(controllerAddress); RedeemableToken vault = RedeemableToken(payable(vaultAddress)); uint256 requiredAssets = controller.activateRedemption(); diff --git a/script/test/BaseTestScript.s.sol b/script/test/BaseTestScript.s.sol index b2d2c28..97e6ef8 100644 --- a/script/test/BaseTestScript.s.sol +++ b/script/test/BaseTestScript.s.sol @@ -1,32 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {Script, console2} from "forge-std/Script.sol"; -import {Contracts} from "./Contracts.sol"; - -error MissingDeployment(string name, string path); - -abstract contract BaseTestScript is Script { - function _broadcaster() internal view returns (address) { - return tx.origin; - } - - function _recordAddress(string memory name, address value) internal { - vm.writeFile(_path(name), vm.toString(value)); - } - - function _loadAddress(string memory name) internal view returns (address value) { - string memory path = _path(name); - if (!vm.isFile(path)) { - revert MissingDeployment(name, path); - } - value = vm.parseAddress(vm.readFile(path)); - } - - function _path(string memory name) internal view returns (string memory) { - return string.concat(vm.projectRoot(), "/script/test/", name, "-", vm.toString(block.chainid), ".txt"); - } +import {console2} from "forge-std/Script.sol"; +import {BaseDeploymentScript} from "../common/BaseDeploymentScript.s.sol"; +abstract contract BaseTestScript is BaseDeploymentScript { function _logAddress(string memory label, address value) internal pure { console2.log(string.concat(label, ": "), value); } diff --git a/script/test/Contracts.sol b/script/test/Contracts.sol index 3230e71..adbf009 100644 --- a/script/test/Contracts.sol +++ b/script/test/Contracts.sol @@ -6,16 +6,18 @@ library Contracts { string internal constant AMOUNT_ENV = "AMOUNT"; - string internal constant MOCK_YNRWAX_PROVIDER_KEY = "mock-ynrwax-provider"; - string internal constant MOCK_YNRWAX_IMPLEMENTATION_KEY = "mock-ynrwax-implementation"; - string internal constant MOCK_YNRWAX_KEY = "mock-ynrwax"; + string internal constant MOCK_YNRWAX_NAMESPACE = "test-mock-ynrwax"; + string internal constant TERM_NAMESPACE = "test-term-redeemer"; + string internal constant MOCK_YNRWAX_PROVIDER_KEY = "mockYnRwaProvider"; + string internal constant MOCK_YNRWAX_IMPLEMENTATION_KEY = "mockYnRwaImplementation"; + string internal constant MOCK_YNRWAX_KEY = "mockYnRwa"; - string internal constant TERM_PROVIDER_KEY = "term-provider"; - string internal constant TERM_IMPLEMENTATION_KEY = "term-implementation"; - string internal constant TERM_FACTORY_KEY = "term-factory"; - string internal constant TERM_VAULT_KEY = "term-vault"; - string internal constant TERM_HOOKS_KEY = "term-hooks"; - string internal constant TERM_CONTROLLER_KEY = "term-controller"; + string internal constant TERM_PROVIDER_KEY = "termProvider"; + string internal constant TERM_IMPLEMENTATION_KEY = "termImplementation"; + string internal constant TERM_FACTORY_KEY = "termFactory"; + string internal constant TERM_VAULT_KEY = "termVault"; + string internal constant TERM_HOOKS_KEY = "termHooks"; + string internal constant TERM_CONTROLLER_KEY = "termController"; string internal constant MOCK_YNRWAX_NAME = "Mock ynRWAx"; string internal constant MOCK_YNRWAX_SYMBOL = "mynRWAx"; @@ -30,6 +32,5 @@ library Contracts { uint8 internal constant VAULT_DECIMALS = 18; uint256 internal constant ONE = 1e18; - uint256 internal constant TEST_STAGE_TIME = 0; uint256 internal constant DEFAULT_ASSET_INDEX = 0; } diff --git a/script/test/DeployMockYnRWAx.s.sol b/script/test/DeployMockYnRWAx.s.sol index 689a76e..be37d3b 100644 --- a/script/test/DeployMockYnRWAx.s.sol +++ b/script/test/DeployMockYnRWAx.s.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.24; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; -import {MockRateProvider} from "../../test/mocks/MockRateProvider.sol"; +import {TestRateProvider} from "./utils/TestRateProvider.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; @@ -12,7 +12,7 @@ contract DeployMockYnRWAx is BaseTestScript { vm.startBroadcast(); address admin = _broadcaster(); - MockRateProvider provider = new MockRateProvider(); + TestRateProvider provider = new TestRateProvider(); provider.setRate(Contracts.USDC, Contracts.ONE); RedeemableToken implementation = new RedeemableToken(); @@ -44,9 +44,15 @@ contract DeployMockYnRWAx is BaseTestScript { vm.stopBroadcast(); - _recordAddress(Contracts.MOCK_YNRWAX_PROVIDER_KEY, address(provider)); - _recordAddress(Contracts.MOCK_YNRWAX_IMPLEMENTATION_KEY, address(implementation)); - _recordAddress(Contracts.MOCK_YNRWAX_KEY, address(vault)); + string[] memory keys = new string[](3); + address[] memory values = new address[](3); + keys[0] = Contracts.MOCK_YNRWAX_PROVIDER_KEY; + values[0] = address(provider); + keys[1] = Contracts.MOCK_YNRWAX_IMPLEMENTATION_KEY; + values[1] = address(implementation); + keys[2] = Contracts.MOCK_YNRWAX_KEY; + values[2] = address(vault); + _writeAddresses(Contracts.MOCK_YNRWAX_NAMESPACE, keys, values); _logAddress(Contracts.MOCK_YNRWAX_LABEL, address(vault)); _logAddress(Contracts.MOCK_YNRWAX_PROVIDER_LABEL, address(provider)); diff --git a/script/test/DeployTermRedeemer.s.sol b/script/test/DeployTermRedeemer.s.sol index 91917ff..8d36ad4 100644 --- a/script/test/DeployTermRedeemer.s.sol +++ b/script/test/DeployTermRedeemer.s.sol @@ -2,12 +2,12 @@ pragma solidity ^0.8.24; import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; -import {RedeemableTokenFactory} from "../../contracts/RedeemableTokenFactory.sol"; -import {TermRedeemerController} from "../../contracts/TermRedeemerController.sol"; import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; -import {MockRateProvider} from "../../test/mocks/MockRateProvider.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; +import {TestRateProvider} from "./utils/TestRateProvider.sol"; +import {TestRedeemableTokenFactory} from "./utils/TestRedeemableTokenFactory.sol"; +import {TestTermRedeemerController} from "./utils/TestTermRedeemerController.sol"; error ProcessorRuleSetupFailed(); @@ -26,18 +26,18 @@ contract DeployTermRedeemer is BaseTestScript { function run() external { address admin = _broadcaster(); - address mockYnRWAx = _loadAddress(Contracts.MOCK_YNRWAX_KEY); + address mockYnRWAx = _loadAddress(Contracts.MOCK_YNRWAX_NAMESPACE, Contracts.MOCK_YNRWAX_KEY); vm.startBroadcast(); - MockRateProvider provider = new MockRateProvider(); + TestRateProvider provider = new TestRateProvider(); provider.setRate(Contracts.USDC, Contracts.ONE); provider.setRate(mockYnRWAx, Contracts.ONE); RedeemableToken implementation = new RedeemableToken(); - RedeemableTokenFactory factory = new RedeemableTokenFactory(address(implementation)); - (RedeemableToken vault, FeeHooks hooks, TermRedeemerController controller) = factory.deploy( - RedeemableTokenFactory.DeployParams({ + TestRedeemableTokenFactory factory = new TestRedeemableTokenFactory(address(implementation)); + (RedeemableToken vault, FeeHooks hooks, TestTermRedeemerController controller) = factory.deploy( + TestRedeemableTokenFactory.DeployParams({ admin: admin, provider: address(provider), wrappedAsset: address(0), @@ -49,10 +49,7 @@ contract DeployTermRedeemer is BaseTestScript { decimals: Contracts.VAULT_DECIMALS, countNativeAsset: false, alwaysComputeTotalAssets: false, - unrestrictedController: true, - defaultAssetIndex: Contracts.DEFAULT_ASSET_INDEX, - lockEnd: uint64(Contracts.TEST_STAGE_TIME), - redeemStart: uint64(Contracts.TEST_STAGE_TIME) + defaultAssetIndex: Contracts.DEFAULT_ASSET_INDEX }) ); @@ -60,12 +57,21 @@ contract DeployTermRedeemer is BaseTestScript { vm.stopBroadcast(); - _recordAddress(Contracts.TERM_PROVIDER_KEY, address(provider)); - _recordAddress(Contracts.TERM_IMPLEMENTATION_KEY, address(implementation)); - _recordAddress(Contracts.TERM_FACTORY_KEY, address(factory)); - _recordAddress(Contracts.TERM_VAULT_KEY, address(vault)); - _recordAddress(Contracts.TERM_HOOKS_KEY, address(hooks)); - _recordAddress(Contracts.TERM_CONTROLLER_KEY, address(controller)); + string[] memory keys = new string[](6); + address[] memory values = new address[](6); + keys[0] = Contracts.TERM_PROVIDER_KEY; + values[0] = address(provider); + keys[1] = Contracts.TERM_IMPLEMENTATION_KEY; + values[1] = address(implementation); + keys[2] = Contracts.TERM_FACTORY_KEY; + values[2] = address(factory); + keys[3] = Contracts.TERM_VAULT_KEY; + values[3] = address(vault); + keys[4] = Contracts.TERM_HOOKS_KEY; + values[4] = address(hooks); + keys[5] = Contracts.TERM_CONTROLLER_KEY; + values[5] = address(controller); + _writeAddresses(Contracts.TERM_NAMESPACE, keys, values); _logAddress(Contracts.TERM_VAULT_LABEL, address(vault)); _logAddress(Contracts.TERM_HOOKS_LABEL, address(hooks)); diff --git a/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol b/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol index aa4c9c0..4a1104a 100644 --- a/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol +++ b/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol @@ -10,8 +10,8 @@ import {console2} from "forge-std/Script.sol"; contract DepositMockYnRWAxIntoTermRedeemer is BaseTestScript { function run() external { uint256 assets = vm.envUint(Contracts.AMOUNT_ENV); - address vault = _loadAddress(Contracts.TERM_VAULT_KEY); - address depositToken = _loadAddress(Contracts.MOCK_YNRWAX_KEY); + address vault = _loadAddress(Contracts.TERM_NAMESPACE, Contracts.TERM_VAULT_KEY); + address depositToken = _loadAddress(Contracts.MOCK_YNRWAX_NAMESPACE, Contracts.MOCK_YNRWAX_KEY); address depositor = _broadcaster(); vm.startBroadcast(); diff --git a/script/test/DepositUsdcIntoMockYnRWAx.s.sol b/script/test/DepositUsdcIntoMockYnRWAx.s.sol index e7c36b3..500edb7 100644 --- a/script/test/DepositUsdcIntoMockYnRWAx.s.sol +++ b/script/test/DepositUsdcIntoMockYnRWAx.s.sol @@ -10,7 +10,7 @@ import {console2} from "forge-std/Script.sol"; contract DepositUsdcIntoMockYnRWAx is BaseTestScript { function run() external { uint256 assets = vm.envUint(Contracts.AMOUNT_ENV); - address vault = _loadAddress(Contracts.MOCK_YNRWAX_KEY); + address vault = _loadAddress(Contracts.MOCK_YNRWAX_NAMESPACE, Contracts.MOCK_YNRWAX_KEY); address depositor = _broadcaster(); vm.startBroadcast(); diff --git a/script/test/LockRedeemer.s.sol b/script/test/LockRedeemer.s.sol index 1156808..fbae4a5 100644 --- a/script/test/LockRedeemer.s.sol +++ b/script/test/LockRedeemer.s.sol @@ -1,17 +1,17 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {TermRedeemerController} from "../../contracts/TermRedeemerController.sol"; +import {TestTermRedeemerController} from "./utils/TestTermRedeemerController.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; import {console2} from "forge-std/Script.sol"; contract LockRedeemer is BaseTestScript { function run() external { - address controllerAddress = _loadAddress(Contracts.TERM_CONTROLLER_KEY); + address controllerAddress = _loadAddress(Contracts.TERM_NAMESPACE, Contracts.TERM_CONTROLLER_KEY); vm.startBroadcast(); - uint256 totalAssetsSnapshot = TermRedeemerController(controllerAddress).lock(); + uint256 totalAssetsSnapshot = TestTermRedeemerController(controllerAddress).lock(); vm.stopBroadcast(); console2.log("Locked term vault at total assets:", totalAssetsSnapshot); diff --git a/script/test/ResetRedeemerStage.s.sol b/script/test/ResetRedeemerStage.s.sol index e03e518..70e6c61 100644 --- a/script/test/ResetRedeemerStage.s.sol +++ b/script/test/ResetRedeemerStage.s.sol @@ -1,17 +1,17 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {TermRedeemerController} from "../../contracts/TermRedeemerController.sol"; +import {TestTermRedeemerController} from "./utils/TestTermRedeemerController.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; import {console2} from "forge-std/Script.sol"; contract ResetRedeemerStage is BaseTestScript { function run() external { - address controllerAddress = _loadAddress(Contracts.TERM_CONTROLLER_KEY); + address controllerAddress = _loadAddress(Contracts.TERM_NAMESPACE, Contracts.TERM_CONTROLLER_KEY); vm.startBroadcast(); - TermRedeemerController(controllerAddress).resetToInitialStage(); + TestTermRedeemerController(controllerAddress).resetToInitialStage(); vm.stopBroadcast(); console2.log("Reset term vault to initial deposit stage."); diff --git a/script/test/utils/TestRateProvider.sol b/script/test/utils/TestRateProvider.sol new file mode 100644 index 0000000..c4c8558 --- /dev/null +++ b/script/test/utils/TestRateProvider.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IProvider} from "yieldnest-vault/src/interface/IProvider.sol"; + +contract TestRateProvider is IProvider { + mapping(address asset => uint256 rate) public rates; + + function setRate(address asset, uint256 rate) external { + rates[asset] = rate; + } + + function getRate(address asset) external view returns (uint256) { + return rates[asset]; + } +} diff --git a/script/test/utils/TestRedeemableTokenFactory.sol b/script/test/utils/TestRedeemableTokenFactory.sol new file mode 100644 index 0000000..12defd6 --- /dev/null +++ b/script/test/utils/TestRedeemableTokenFactory.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; +import {RedeemableToken} from "../../../contracts/RedeemableToken.sol"; +import {TestTermRedeemerController} from "./TestTermRedeemerController.sol"; + +error ZeroAddress(); + +contract TestRedeemableTokenFactory { + address public immutable implementation; + + event Deployed(address indexed vault, address indexed hooks, address indexed controller, address admin); + + struct DeployParams { + address admin; + address provider; + address wrappedAsset; + address redemptionAsset; + address depositToken; + address feeRecipient; + string name; + string symbol; + uint8 decimals; + bool countNativeAsset; + bool alwaysComputeTotalAssets; + uint256 defaultAssetIndex; + } + + struct HookConfig { + bool beforeDeposit; + bool afterDeposit; + bool beforeMint; + bool afterMint; + bool beforeRedeem; + bool afterRedeem; + bool beforeWithdraw; + bool afterWithdraw; + bool beforeProcessAccounting; + bool afterProcessAccounting; + } + + constructor(address implementation_) { + if (implementation_ == address(0)) { + revert ZeroAddress(); + } + + implementation = implementation_; + } + + function deploy(DeployParams calldata params) + external + returns (RedeemableToken vault, FeeHooks hooks, TestTermRedeemerController controller) + { + if ( + params.admin == address(0) || params.provider == address(0) || params.redemptionAsset == address(0) + || params.depositToken == address(0) || params.feeRecipient == address(0) + ) { + revert ZeroAddress(); + } + + vault = RedeemableToken( + payable( + address( + new TransparentUpgradeableProxy( + implementation, + params.admin, + abi.encodeCall( + RedeemableToken.initialize, + (RedeemableToken.InitParams({ + admin: address(this), + name: params.name, + symbol: params.symbol, + decimals_: params.decimals, + countNativeAsset_: params.countNativeAsset, + alwaysComputeTotalAssets_: params.alwaysComputeTotalAssets, + defaultAssetIndex_: params.defaultAssetIndex + })) + ) + ) + ) + ) + ); + + HookConfig memory config = HookConfig({ + beforeDeposit: false, + afterDeposit: false, + beforeMint: false, + afterMint: false, + beforeRedeem: false, + afterRedeem: false, + beforeWithdraw: false, + afterWithdraw: false, + beforeProcessAccounting: false, + afterProcessAccounting: true + }); + + hooks = _deployFeeHooks(address(vault), params.feeRecipient, config); + controller = new TestTermRedeemerController(address(vault), address(hooks), params.depositToken, params.redemptionAsset); + + _grantTemporaryRoles(vault); + _configureVault(vault, hooks, controller, params); + _handoffRoles(vault, params.admin); + + emit Deployed(address(vault), address(hooks), address(controller), params.admin); + } + + function _grantTemporaryRoles(RedeemableToken vault) internal { + vault.grantRole(vault.PROCESSOR_ROLE(), address(this)); + vault.grantRole(vault.PROCESSOR_MANAGER_ROLE(), address(this)); + vault.grantRole(vault.PROVIDER_MANAGER_ROLE(), address(this)); + vault.grantRole(vault.ASSET_MANAGER_ROLE(), address(this)); + vault.grantRole(vault.HOOKS_MANAGER_ROLE(), address(this)); + vault.grantRole(vault.PAUSER_ROLE(), address(this)); + vault.grantRole(vault.UNPAUSER_ROLE(), address(this)); + } + + function _configureVault( + RedeemableToken vault, + FeeHooks hooks, + TestTermRedeemerController controller, + DeployParams calldata params + ) internal { + vault.setProvider(params.provider); + if ( + params.wrappedAsset != address(0) && params.wrappedAsset != params.redemptionAsset + && params.wrappedAsset != params.depositToken + ) { + vault.addAsset(params.wrappedAsset, false); + vault.setAssetWithdrawable(params.wrappedAsset, false); + } + vault.addAsset(params.redemptionAsset, false); + vault.setAssetWithdrawable(params.redemptionAsset, false); + vault.addAsset(params.depositToken, true); + vault.setAssetWithdrawable(params.depositToken, false); + vault.setHooks(address(hooks)); + vault.grantRole(vault.ASSET_MANAGER_ROLE(), address(controller)); + hooks.transferOwnership(address(controller)); + vault.unpause(); + } + + function _handoffRoles(RedeemableToken vault, address admin) internal { + vault.grantRole(vault.DEFAULT_ADMIN_ROLE(), admin); + vault.grantRole(vault.PROCESSOR_ROLE(), admin); + vault.grantRole(vault.PROCESSOR_MANAGER_ROLE(), admin); + vault.grantRole(vault.PROVIDER_MANAGER_ROLE(), admin); + vault.grantRole(vault.ASSET_MANAGER_ROLE(), admin); + vault.grantRole(vault.HOOKS_MANAGER_ROLE(), admin); + vault.grantRole(vault.PAUSER_ROLE(), admin); + vault.grantRole(vault.UNPAUSER_ROLE(), admin); + + vault.renounceRole(vault.PROCESSOR_ROLE(), address(this)); + vault.renounceRole(vault.PROCESSOR_MANAGER_ROLE(), address(this)); + vault.renounceRole(vault.PROVIDER_MANAGER_ROLE(), address(this)); + vault.renounceRole(vault.ASSET_MANAGER_ROLE(), address(this)); + vault.renounceRole(vault.HOOKS_MANAGER_ROLE(), address(this)); + vault.renounceRole(vault.PAUSER_ROLE(), address(this)); + vault.renounceRole(vault.UNPAUSER_ROLE(), address(this)); + vault.renounceRole(vault.DEFAULT_ADMIN_ROLE(), address(this)); + } + + function _deployFeeHooks(address vault, address feeRecipient, HookConfig memory config) internal returns (FeeHooks hooks) { + bytes memory initCode = + abi.encodePacked(type(FeeHooks).creationCode, abi.encode(vault, address(this), 0, feeRecipient, config)); + address deployed; + assembly { + deployed := create(0, add(initCode, 0x20), mload(initCode)) + } + if (deployed == address(0)) { + revert ZeroAddress(); + } + hooks = FeeHooks(deployed); + } +} diff --git a/script/test/utils/TestTermRedeemerController.sol b/script/test/utils/TestTermRedeemerController.sol new file mode 100644 index 0000000..8671af5 --- /dev/null +++ b/script/test/utils/TestTermRedeemerController.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IFeeHooks} from "yieldnest-vault/src/interface/IFeeHooks.sol"; +import {RedeemableToken} from "../../../contracts/RedeemableToken.sol"; + +error AlreadyLocked(); +error NotLocked(); +error RedemptionAlreadyActivated(); +error RedemptionNotActivated(); +error GetAssetFailed(); +error UpdateAssetFailed(); + +contract TestTermRedeemerController { + uint256 internal constant FEE_DENOMINATOR = 1 ether; + + RedeemableToken public immutable vault; + IFeeHooks public immutable hooks; + address public immutable depositToken; + address public immutable redemptionAsset; + + bool public locked; + bool public redemptionActivated; + + event Locked(uint256 totalAssetsSnapshot); + event RedemptionActivated(uint256 requiredAssets); + event ResetToInitialStage(); + + constructor(address vault_, address hooks_, address depositToken_, address redemptionAsset_) { + vault = RedeemableToken(payable(vault_)); + hooks = IFeeHooks(hooks_); + depositToken = depositToken_; + redemptionAsset = redemptionAsset_; + } + + function lock() external returns (uint256 totalAssetsSnapshot) { + if (locked) { + revert AlreadyLocked(); + } + + vault.processAccounting(); + + _setDepositAssetActive(false); + hooks.setPerformanceFee(FEE_DENOMINATOR); + + locked = true; + totalAssetsSnapshot = vault.totalAssets(); + + emit Locked(totalAssetsSnapshot); + } + + function activateRedemption() external returns (uint256 requiredAssets) { + if (redemptionActivated) { + revert RedemptionAlreadyActivated(); + } + if (!locked) { + revert NotLocked(); + } + + requiredAssets = vault.previewRedeem(vault.totalSupply()); + vault.setAssetWithdrawable(redemptionAsset, true); + redemptionActivated = true; + + emit RedemptionActivated(requiredAssets); + } + + function resetToInitialStage() external { + if (!redemptionActivated) { + revert RedemptionNotActivated(); + } + + vault.setAssetWithdrawable(redemptionAsset, false); + + _setDepositAssetActive(true); + hooks.setPerformanceFee(0); + + locked = false; + redemptionActivated = false; + + emit ResetToInitialStage(); + } + + function _setDepositAssetActive(bool isActive) internal { + (bool ok, bytes memory data) = address(vault).staticcall(abi.encodeWithSignature("getAsset(address)", depositToken)); + if (!ok) { + revert GetAssetFailed(); + } + + (uint256 index,,) = abi.decode(data, (uint256, bool, uint8)); + (ok,) = address(vault).call(abi.encodeWithSignature("updateAsset(uint256,(bool))", index, isActive)); + if (!ok) { + revert UpdateAssetFailed(); + } + } +} diff --git a/contracts/RedeemableToken.sol b/src/RedeemableToken.sol similarity index 100% rename from contracts/RedeemableToken.sol rename to src/RedeemableToken.sol diff --git a/contracts/TermRedeemerController.sol b/src/TermRedeemerController.sol similarity index 53% rename from contracts/TermRedeemerController.sol rename to src/TermRedeemerController.sol index 526e131..a29ad8d 100644 --- a/contracts/TermRedeemerController.sol +++ b/src/TermRedeemerController.sol @@ -1,106 +1,60 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; import {IFeeHooks} from "yieldnest-vault/src/interface/IFeeHooks.sol"; -import {RedeemableToken} from "./RedeemableToken.sol"; +import {RedeemableToken} from "contracts/RedeemableToken.sol"; error LockNotReady(uint256 timestamp, uint64 lockEnd); error RedeemNotReady(uint256 timestamp, uint64 redeemStart); error AlreadyLocked(); error NotLocked(); error RedemptionAlreadyActivated(); -error TransitionAdminOnly(address caller); -error RestrictedController(); -error RedemptionNotActivated(); +error GetAssetFailed(); +error UpdateAssetFailed(); contract TermRedeemerController { uint256 internal constant FEE_DENOMINATOR = 1 ether; RedeemableToken public immutable vault; IFeeHooks public immutable hooks; - address public immutable transitionAdmin; address public immutable depositToken; address public immutable redemptionAsset; uint64 public immutable lockEnd; uint64 public immutable redeemStart; - bool public immutable unrestrictedTransitions; bool public locked; bool public redemptionActivated; event Locked(uint256 totalAssetsSnapshot); event RedemptionActivated(uint256 requiredAssets); - event ResetToInitialStage(); constructor( address vault_, address hooks_, - address transitionAdmin_, address depositToken_, address redemptionAsset_, uint64 lockEnd_, - uint64 redeemStart_, - bool unrestrictedTransitions_ + uint64 redeemStart_ ) { vault = RedeemableToken(payable(vault_)); hooks = IFeeHooks(hooks_); - transitionAdmin = transitionAdmin_; depositToken = depositToken_; redemptionAsset = redemptionAsset_; lockEnd = lockEnd_; redeemStart = redeemStart_; - unrestrictedTransitions = unrestrictedTransitions_; } function lock() external returns (uint256 totalAssetsSnapshot) { - if (!unrestrictedTransitions && block.timestamp < lockEnd) { + if (block.timestamp < lockEnd) { revert LockNotReady(block.timestamp, lockEnd); } - totalAssetsSnapshot = _lock(); - } - - function activateRedemption() external returns (uint256 requiredAssets) { - if (!unrestrictedTransitions && block.timestamp < redeemStart) { - revert RedeemNotReady(block.timestamp, redeemStart); - } - requiredAssets = _activateRedemption(); - } - - function resetToInitialStage() external { - if (!unrestrictedTransitions) { - revert RestrictedController(); - } - if (msg.sender != transitionAdmin) { - revert TransitionAdminOnly(msg.sender); - } - if (!redemptionActivated) { - revert RedemptionNotActivated(); - } - - vault.setAssetWithdrawable(redemptionAsset, false); - - IVault.AssetParams memory params = vault.getAsset(depositToken); - IVault.AssetUpdateFields memory fields = IVault.AssetUpdateFields({active: true}); - vault.updateAsset(params.index, fields); - hooks.setPerformanceFee(0); - - locked = false; - redemptionActivated = false; - - emit ResetToInitialStage(); - } - - function _lock() internal returns (uint256 totalAssetsSnapshot) { if (locked) { revert AlreadyLocked(); } vault.processAccounting(); - IVault.AssetParams memory params = vault.getAsset(depositToken); - IVault.AssetUpdateFields memory fields = IVault.AssetUpdateFields({active: false}); - vault.updateAsset(params.index, fields); + _setDepositAssetActive(false); hooks.setPerformanceFee(FEE_DENOMINATOR); locked = true; @@ -109,7 +63,10 @@ contract TermRedeemerController { emit Locked(totalAssetsSnapshot); } - function _activateRedemption() internal returns (uint256 requiredAssets) { + function activateRedemption() external returns (uint256 requiredAssets) { + if (block.timestamp < redeemStart) { + revert RedeemNotReady(block.timestamp, redeemStart); + } if (redemptionActivated) { revert RedemptionAlreadyActivated(); } @@ -123,4 +80,17 @@ contract TermRedeemerController { emit RedemptionActivated(requiredAssets); } + + function _setDepositAssetActive(bool isActive) internal { + (bool ok, bytes memory data) = address(vault).staticcall(abi.encodeWithSignature("getAsset(address)", depositToken)); + if (!ok) { + revert GetAssetFailed(); + } + + (uint256 index,,) = abi.decode(data, (uint256, bool, uint8)); + (ok,) = address(vault).call(abi.encodeWithSignature("updateAsset(uint256,(bool))", index, isActive)); + if (!ok) { + revert UpdateAssetFailed(); + } + } } diff --git a/test/integration/RedeemableTokenFork.t.sol b/test/integration/RedeemableTokenFork.t.sol index c4065c7..284fb99 100644 --- a/test/integration/RedeemableTokenFork.t.sol +++ b/test/integration/RedeemableTokenFork.t.sol @@ -6,7 +6,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; -import {RedeemableTokenFactory} from "../../contracts/RedeemableTokenFactory.sol"; +import {RedeemableTokenDeployer} from "../../script/common/RedeemableTokenDeployer.sol"; import {TermRedeemerController} from "../../contracts/TermRedeemerController.sol"; import {MockERC20} from "../mocks/MockERC20.sol"; import {MockRateProvider} from "../mocks/MockRateProvider.sol"; @@ -134,9 +134,9 @@ contract RedeemableTokenForkTest is Test { function _deployRedeemer() internal { RedeemableToken implementation = new RedeemableToken(); - RedeemableTokenFactory factory = new RedeemableTokenFactory(address(implementation)); + RedeemableTokenDeployer factory = new RedeemableTokenDeployer(address(implementation)); (redeemer, feeHooks, controller) = factory.deploy( - RedeemableTokenFactory.DeployParams({ + RedeemableTokenDeployer.DeployParams({ admin: ADMIN, provider: address(provider), wrappedAsset: address(wrappedUsdc), @@ -148,7 +148,6 @@ contract RedeemableTokenForkTest is Test { decimals: 18, countNativeAsset: false, alwaysComputeTotalAssets: false, - unrestrictedController: false, defaultAssetIndex: 1, lockEnd: LOCK_END, redeemStart: REDEEM_START diff --git a/test/unit/TermRedeemer.t.sol b/test/unit/TermRedeemer.t.sol index f163b35..e66820f 100644 --- a/test/unit/TermRedeemer.t.sol +++ b/test/unit/TermRedeemer.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.24; import {Test} from "forge-std/Test.sol"; import {IVault} from "yieldnest-vault/src/interface/IVault.sol"; import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; -import {RedeemableTokenFactory} from "../../contracts/RedeemableTokenFactory.sol"; +import {RedeemableTokenDeployer} from "../../script/common/RedeemableTokenDeployer.sol"; import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; import { AlreadyLocked, @@ -56,9 +56,9 @@ contract TermRedeemerTest is Test { provider.setRate(address(ynRwa), 11e17); RedeemableToken implementation = new RedeemableToken(); - RedeemableTokenFactory factory = new RedeemableTokenFactory(address(implementation)); + RedeemableTokenDeployer factory = new RedeemableTokenDeployer(address(implementation)); (redeemer, feeHooks, controller) = factory.deploy( - RedeemableTokenFactory.DeployParams({ + RedeemableTokenDeployer.DeployParams({ admin: ADMIN, provider: address(provider), wrappedAsset: address(wrappedUsdc), @@ -70,7 +70,6 @@ contract TermRedeemerTest is Test { decimals: 18, countNativeAsset: false, alwaysComputeTotalAssets: false, - unrestrictedController: false, defaultAssetIndex: 1, lockEnd: LOCK_END, redeemStart: REDEEM_START From 0e5f21036ceee9ddfd6f2fbd0bad60df147ab714 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Tue, 19 May 2026 22:23:24 +0000 Subject: [PATCH 25/36] Reuse mock rate provider in scripts --- script/test/DeployMockYnRWAx.s.sol | 4 ++-- script/test/DeployTermRedeemer.s.sol | 4 ++-- script/test/utils/TestRateProvider.sol | 16 ---------------- 3 files changed, 4 insertions(+), 20 deletions(-) delete mode 100644 script/test/utils/TestRateProvider.sol diff --git a/script/test/DeployMockYnRWAx.s.sol b/script/test/DeployMockYnRWAx.s.sol index be37d3b..7ed8ddb 100644 --- a/script/test/DeployMockYnRWAx.s.sol +++ b/script/test/DeployMockYnRWAx.s.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.24; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; -import {TestRateProvider} from "./utils/TestRateProvider.sol"; +import {MockRateProvider} from "../../test/mocks/MockRateProvider.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; @@ -12,7 +12,7 @@ contract DeployMockYnRWAx is BaseTestScript { vm.startBroadcast(); address admin = _broadcaster(); - TestRateProvider provider = new TestRateProvider(); + MockRateProvider provider = new MockRateProvider(); provider.setRate(Contracts.USDC, Contracts.ONE); RedeemableToken implementation = new RedeemableToken(); diff --git a/script/test/DeployTermRedeemer.s.sol b/script/test/DeployTermRedeemer.s.sol index 8d36ad4..2f050cc 100644 --- a/script/test/DeployTermRedeemer.s.sol +++ b/script/test/DeployTermRedeemer.s.sol @@ -5,7 +5,7 @@ import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; -import {TestRateProvider} from "./utils/TestRateProvider.sol"; +import {MockRateProvider} from "../../test/mocks/MockRateProvider.sol"; import {TestRedeemableTokenFactory} from "./utils/TestRedeemableTokenFactory.sol"; import {TestTermRedeemerController} from "./utils/TestTermRedeemerController.sol"; @@ -30,7 +30,7 @@ contract DeployTermRedeemer is BaseTestScript { vm.startBroadcast(); - TestRateProvider provider = new TestRateProvider(); + MockRateProvider provider = new MockRateProvider(); provider.setRate(Contracts.USDC, Contracts.ONE); provider.setRate(mockYnRWAx, Contracts.ONE); diff --git a/script/test/utils/TestRateProvider.sol b/script/test/utils/TestRateProvider.sol deleted file mode 100644 index c4c8558..0000000 --- a/script/test/utils/TestRateProvider.sol +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import {IProvider} from "yieldnest-vault/src/interface/IProvider.sol"; - -contract TestRateProvider is IProvider { - mapping(address asset => uint256 rate) public rates; - - function setRate(address asset, uint256 rate) external { - rates[asset] = rate; - } - - function getRate(address asset) external view returns (uint256) { - return rates[asset]; - } -} From d312ceb642b1831dbd9f04318a8a8caea605e5a2 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Wed, 20 May 2026 00:14:48 +0000 Subject: [PATCH 26/36] Split script constants from addresses --- script/test/ActivateRedemption.s.sol | 7 ++-- script/test/Constants.sol | 34 ++++++++++++++++++ script/test/Contracts.sol | 30 ---------------- script/test/DeployMockYnRWAx.s.sol | 23 ++++++------ script/test/DeployTermRedeemer.s.sol | 35 ++++++++++--------- .../DepositMockYnRWAxIntoTermRedeemer.s.sol | 8 ++--- script/test/DepositUsdcIntoMockYnRWAx.s.sol | 5 +-- script/test/LockRedeemer.s.sol | 4 +-- script/test/ResetRedeemerStage.s.sol | 4 +-- 9 files changed, 79 insertions(+), 71 deletions(-) create mode 100644 script/test/Constants.sol diff --git a/script/test/ActivateRedemption.s.sol b/script/test/ActivateRedemption.s.sol index bf24fb7..94824c3 100644 --- a/script/test/ActivateRedemption.s.sol +++ b/script/test/ActivateRedemption.s.sol @@ -6,6 +6,7 @@ import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; import {TestTermRedeemerController} from "./utils/TestTermRedeemerController.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; +import {Constants} from "./Constants.sol"; import {console2} from "forge-std/Script.sol"; interface IWithdrawableVault is IERC20 { @@ -19,9 +20,9 @@ error InsufficientMockYnRWAxLiquidity(uint256 requestedAssets, uint256 maxWithdr contract ActivateRedemption is BaseTestScript { function run() external { - address controllerAddress = _loadAddress(Contracts.TERM_NAMESPACE, Contracts.TERM_CONTROLLER_KEY); - address vaultAddress = _loadAddress(Contracts.TERM_NAMESPACE, Contracts.TERM_VAULT_KEY); - address mockYnRWAx = _loadAddress(Contracts.MOCK_YNRWAX_NAMESPACE, Contracts.MOCK_YNRWAX_KEY); + address controllerAddress = _loadAddress(Constants.TERM_NAMESPACE, Constants.TERM_CONTROLLER_KEY); + address vaultAddress = _loadAddress(Constants.TERM_NAMESPACE, Constants.TERM_VAULT_KEY); + address mockYnRWAx = _loadAddress(Constants.MOCK_YNRWAX_NAMESPACE, Constants.MOCK_YNRWAX_KEY); vm.startBroadcast(); diff --git a/script/test/Constants.sol b/script/test/Constants.sol new file mode 100644 index 0000000..d6e8380 --- /dev/null +++ b/script/test/Constants.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +library Constants { + string internal constant AMOUNT_ENV = "AMOUNT"; + + string internal constant MOCK_YNRWAX_NAMESPACE = "test-mock-ynrwax"; + string internal constant TERM_NAMESPACE = "test-term-redeemer"; + string internal constant MOCK_YNRWAX_PROVIDER_KEY = "mockYnRwaProvider"; + string internal constant MOCK_YNRWAX_IMPLEMENTATION_KEY = "mockYnRwaImplementation"; + string internal constant MOCK_YNRWAX_KEY = "mockYnRwa"; + + string internal constant TERM_PROVIDER_KEY = "termProvider"; + string internal constant TERM_IMPLEMENTATION_KEY = "termImplementation"; + string internal constant TERM_FACTORY_KEY = "termFactory"; + string internal constant TERM_VAULT_KEY = "termVault"; + string internal constant TERM_HOOKS_KEY = "termHooks"; + string internal constant TERM_CONTROLLER_KEY = "termController"; + + string internal constant MOCK_YNRWAX_NAME = "Mock ynRWAx"; + string internal constant MOCK_YNRWAX_SYMBOL = "mynRWAx"; + string internal constant TERM_VAULT_NAME = "Withdrawable Mock ynRWAx"; + string internal constant TERM_VAULT_SYMBOL = "wmynRWAx"; + + string internal constant MOCK_YNRWAX_LABEL = "Mock ynRWAx"; + string internal constant MOCK_YNRWAX_PROVIDER_LABEL = "Mock ynRWAx provider"; + string internal constant TERM_VAULT_LABEL = "Term vault"; + string internal constant TERM_HOOKS_LABEL = "Term hooks"; + string internal constant TERM_CONTROLLER_LABEL = "Term controller"; + + uint8 internal constant VAULT_DECIMALS = 18; + uint256 internal constant ONE = 1e18; + uint256 internal constant DEFAULT_ASSET_INDEX = 0; +} diff --git a/script/test/Contracts.sol b/script/test/Contracts.sol index adbf009..9ef2252 100644 --- a/script/test/Contracts.sol +++ b/script/test/Contracts.sol @@ -3,34 +3,4 @@ pragma solidity ^0.8.24; library Contracts { address internal constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - - string internal constant AMOUNT_ENV = "AMOUNT"; - - string internal constant MOCK_YNRWAX_NAMESPACE = "test-mock-ynrwax"; - string internal constant TERM_NAMESPACE = "test-term-redeemer"; - string internal constant MOCK_YNRWAX_PROVIDER_KEY = "mockYnRwaProvider"; - string internal constant MOCK_YNRWAX_IMPLEMENTATION_KEY = "mockYnRwaImplementation"; - string internal constant MOCK_YNRWAX_KEY = "mockYnRwa"; - - string internal constant TERM_PROVIDER_KEY = "termProvider"; - string internal constant TERM_IMPLEMENTATION_KEY = "termImplementation"; - string internal constant TERM_FACTORY_KEY = "termFactory"; - string internal constant TERM_VAULT_KEY = "termVault"; - string internal constant TERM_HOOKS_KEY = "termHooks"; - string internal constant TERM_CONTROLLER_KEY = "termController"; - - string internal constant MOCK_YNRWAX_NAME = "Mock ynRWAx"; - string internal constant MOCK_YNRWAX_SYMBOL = "mynRWAx"; - string internal constant TERM_VAULT_NAME = "Withdrawable Mock ynRWAx"; - string internal constant TERM_VAULT_SYMBOL = "wmynRWAx"; - - string internal constant MOCK_YNRWAX_LABEL = "Mock ynRWAx"; - string internal constant MOCK_YNRWAX_PROVIDER_LABEL = "Mock ynRWAx provider"; - string internal constant TERM_VAULT_LABEL = "Term vault"; - string internal constant TERM_HOOKS_LABEL = "Term hooks"; - string internal constant TERM_CONTROLLER_LABEL = "Term controller"; - - uint8 internal constant VAULT_DECIMALS = 18; - uint256 internal constant ONE = 1e18; - uint256 internal constant DEFAULT_ASSET_INDEX = 0; } diff --git a/script/test/DeployMockYnRWAx.s.sol b/script/test/DeployMockYnRWAx.s.sol index 7ed8ddb..4a9d2cf 100644 --- a/script/test/DeployMockYnRWAx.s.sol +++ b/script/test/DeployMockYnRWAx.s.sol @@ -6,6 +6,7 @@ import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; import {MockRateProvider} from "../../test/mocks/MockRateProvider.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; +import {Constants} from "./Constants.sol"; contract DeployMockYnRWAx is BaseTestScript { function run() external { @@ -13,7 +14,7 @@ contract DeployMockYnRWAx is BaseTestScript { address admin = _broadcaster(); MockRateProvider provider = new MockRateProvider(); - provider.setRate(Contracts.USDC, Contracts.ONE); + provider.setRate(Contracts.USDC, Constants.ONE); RedeemableToken implementation = new RedeemableToken(); TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( @@ -23,12 +24,12 @@ contract DeployMockYnRWAx is BaseTestScript { RedeemableToken.initialize, (RedeemableToken.InitParams({ admin: admin, - name: Contracts.MOCK_YNRWAX_NAME, - symbol: Contracts.MOCK_YNRWAX_SYMBOL, - decimals_: Contracts.VAULT_DECIMALS, + name: Constants.MOCK_YNRWAX_NAME, + symbol: Constants.MOCK_YNRWAX_SYMBOL, + decimals_: Constants.VAULT_DECIMALS, countNativeAsset_: false, alwaysComputeTotalAssets_: false, - defaultAssetIndex_: Contracts.DEFAULT_ASSET_INDEX + defaultAssetIndex_: Constants.DEFAULT_ASSET_INDEX })) ) ); @@ -46,15 +47,15 @@ contract DeployMockYnRWAx is BaseTestScript { string[] memory keys = new string[](3); address[] memory values = new address[](3); - keys[0] = Contracts.MOCK_YNRWAX_PROVIDER_KEY; + keys[0] = Constants.MOCK_YNRWAX_PROVIDER_KEY; values[0] = address(provider); - keys[1] = Contracts.MOCK_YNRWAX_IMPLEMENTATION_KEY; + keys[1] = Constants.MOCK_YNRWAX_IMPLEMENTATION_KEY; values[1] = address(implementation); - keys[2] = Contracts.MOCK_YNRWAX_KEY; + keys[2] = Constants.MOCK_YNRWAX_KEY; values[2] = address(vault); - _writeAddresses(Contracts.MOCK_YNRWAX_NAMESPACE, keys, values); + _writeAddresses(Constants.MOCK_YNRWAX_NAMESPACE, keys, values); - _logAddress(Contracts.MOCK_YNRWAX_LABEL, address(vault)); - _logAddress(Contracts.MOCK_YNRWAX_PROVIDER_LABEL, address(provider)); + _logAddress(Constants.MOCK_YNRWAX_LABEL, address(vault)); + _logAddress(Constants.MOCK_YNRWAX_PROVIDER_LABEL, address(provider)); } } diff --git a/script/test/DeployTermRedeemer.s.sol b/script/test/DeployTermRedeemer.s.sol index 2f050cc..b606bd2 100644 --- a/script/test/DeployTermRedeemer.s.sol +++ b/script/test/DeployTermRedeemer.s.sol @@ -5,6 +5,7 @@ import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; +import {Constants} from "./Constants.sol"; import {MockRateProvider} from "../../test/mocks/MockRateProvider.sol"; import {TestRedeemableTokenFactory} from "./utils/TestRedeemableTokenFactory.sol"; import {TestTermRedeemerController} from "./utils/TestTermRedeemerController.sol"; @@ -26,13 +27,13 @@ contract DeployTermRedeemer is BaseTestScript { function run() external { address admin = _broadcaster(); - address mockYnRWAx = _loadAddress(Contracts.MOCK_YNRWAX_NAMESPACE, Contracts.MOCK_YNRWAX_KEY); + address mockYnRWAx = _loadAddress(Constants.MOCK_YNRWAX_NAMESPACE, Constants.MOCK_YNRWAX_KEY); vm.startBroadcast(); MockRateProvider provider = new MockRateProvider(); - provider.setRate(Contracts.USDC, Contracts.ONE); - provider.setRate(mockYnRWAx, Contracts.ONE); + provider.setRate(Contracts.USDC, Constants.ONE); + provider.setRate(mockYnRWAx, Constants.ONE); RedeemableToken implementation = new RedeemableToken(); TestRedeemableTokenFactory factory = new TestRedeemableTokenFactory(address(implementation)); @@ -44,12 +45,12 @@ contract DeployTermRedeemer is BaseTestScript { redemptionAsset: Contracts.USDC, depositToken: mockYnRWAx, feeRecipient: admin, - name: Contracts.TERM_VAULT_NAME, - symbol: Contracts.TERM_VAULT_SYMBOL, - decimals: Contracts.VAULT_DECIMALS, + name: Constants.TERM_VAULT_NAME, + symbol: Constants.TERM_VAULT_SYMBOL, + decimals: Constants.VAULT_DECIMALS, countNativeAsset: false, alwaysComputeTotalAssets: false, - defaultAssetIndex: Contracts.DEFAULT_ASSET_INDEX + defaultAssetIndex: Constants.DEFAULT_ASSET_INDEX }) ); @@ -59,23 +60,23 @@ contract DeployTermRedeemer is BaseTestScript { string[] memory keys = new string[](6); address[] memory values = new address[](6); - keys[0] = Contracts.TERM_PROVIDER_KEY; + keys[0] = Constants.TERM_PROVIDER_KEY; values[0] = address(provider); - keys[1] = Contracts.TERM_IMPLEMENTATION_KEY; + keys[1] = Constants.TERM_IMPLEMENTATION_KEY; values[1] = address(implementation); - keys[2] = Contracts.TERM_FACTORY_KEY; + keys[2] = Constants.TERM_FACTORY_KEY; values[2] = address(factory); - keys[3] = Contracts.TERM_VAULT_KEY; + keys[3] = Constants.TERM_VAULT_KEY; values[3] = address(vault); - keys[4] = Contracts.TERM_HOOKS_KEY; + keys[4] = Constants.TERM_HOOKS_KEY; values[4] = address(hooks); - keys[5] = Contracts.TERM_CONTROLLER_KEY; + keys[5] = Constants.TERM_CONTROLLER_KEY; values[5] = address(controller); - _writeAddresses(Contracts.TERM_NAMESPACE, keys, values); + _writeAddresses(Constants.TERM_NAMESPACE, keys, values); - _logAddress(Contracts.TERM_VAULT_LABEL, address(vault)); - _logAddress(Contracts.TERM_HOOKS_LABEL, address(hooks)); - _logAddress(Contracts.TERM_CONTROLLER_LABEL, address(controller)); + _logAddress(Constants.TERM_VAULT_LABEL, address(vault)); + _logAddress(Constants.TERM_HOOKS_LABEL, address(hooks)); + _logAddress(Constants.TERM_CONTROLLER_LABEL, address(controller)); } function _allowProcessorWithdrawAsset(RedeemableToken vault, address target, address redemptionAsset) internal { diff --git a/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol b/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol index 4a1104a..30a5214 100644 --- a/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol +++ b/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol @@ -4,14 +4,14 @@ pragma solidity ^0.8.24; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; -import {Contracts} from "./Contracts.sol"; +import {Constants} from "./Constants.sol"; import {console2} from "forge-std/Script.sol"; contract DepositMockYnRWAxIntoTermRedeemer is BaseTestScript { function run() external { - uint256 assets = vm.envUint(Contracts.AMOUNT_ENV); - address vault = _loadAddress(Contracts.TERM_NAMESPACE, Contracts.TERM_VAULT_KEY); - address depositToken = _loadAddress(Contracts.MOCK_YNRWAX_NAMESPACE, Contracts.MOCK_YNRWAX_KEY); + uint256 assets = vm.envUint(Constants.AMOUNT_ENV); + address vault = _loadAddress(Constants.TERM_NAMESPACE, Constants.TERM_VAULT_KEY); + address depositToken = _loadAddress(Constants.MOCK_YNRWAX_NAMESPACE, Constants.MOCK_YNRWAX_KEY); address depositor = _broadcaster(); vm.startBroadcast(); diff --git a/script/test/DepositUsdcIntoMockYnRWAx.s.sol b/script/test/DepositUsdcIntoMockYnRWAx.s.sol index 500edb7..540b13c 100644 --- a/script/test/DepositUsdcIntoMockYnRWAx.s.sol +++ b/script/test/DepositUsdcIntoMockYnRWAx.s.sol @@ -5,12 +5,13 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; +import {Constants} from "./Constants.sol"; import {console2} from "forge-std/Script.sol"; contract DepositUsdcIntoMockYnRWAx is BaseTestScript { function run() external { - uint256 assets = vm.envUint(Contracts.AMOUNT_ENV); - address vault = _loadAddress(Contracts.MOCK_YNRWAX_NAMESPACE, Contracts.MOCK_YNRWAX_KEY); + uint256 assets = vm.envUint(Constants.AMOUNT_ENV); + address vault = _loadAddress(Constants.MOCK_YNRWAX_NAMESPACE, Constants.MOCK_YNRWAX_KEY); address depositor = _broadcaster(); vm.startBroadcast(); diff --git a/script/test/LockRedeemer.s.sol b/script/test/LockRedeemer.s.sol index fbae4a5..a52547f 100644 --- a/script/test/LockRedeemer.s.sol +++ b/script/test/LockRedeemer.s.sol @@ -3,12 +3,12 @@ pragma solidity ^0.8.24; import {TestTermRedeemerController} from "./utils/TestTermRedeemerController.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; -import {Contracts} from "./Contracts.sol"; +import {Constants} from "./Constants.sol"; import {console2} from "forge-std/Script.sol"; contract LockRedeemer is BaseTestScript { function run() external { - address controllerAddress = _loadAddress(Contracts.TERM_NAMESPACE, Contracts.TERM_CONTROLLER_KEY); + address controllerAddress = _loadAddress(Constants.TERM_NAMESPACE, Constants.TERM_CONTROLLER_KEY); vm.startBroadcast(); uint256 totalAssetsSnapshot = TestTermRedeemerController(controllerAddress).lock(); diff --git a/script/test/ResetRedeemerStage.s.sol b/script/test/ResetRedeemerStage.s.sol index 70e6c61..030df5b 100644 --- a/script/test/ResetRedeemerStage.s.sol +++ b/script/test/ResetRedeemerStage.s.sol @@ -3,12 +3,12 @@ pragma solidity ^0.8.24; import {TestTermRedeemerController} from "./utils/TestTermRedeemerController.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; -import {Contracts} from "./Contracts.sol"; +import {Constants} from "./Constants.sol"; import {console2} from "forge-std/Script.sol"; contract ResetRedeemerStage is BaseTestScript { function run() external { - address controllerAddress = _loadAddress(Contracts.TERM_NAMESPACE, Contracts.TERM_CONTROLLER_KEY); + address controllerAddress = _loadAddress(Constants.TERM_NAMESPACE, Constants.TERM_CONTROLLER_KEY); vm.startBroadcast(); TestTermRedeemerController(controllerAddress).resetToInitialStage(); From d0769f996cdc940de4cfdd2477176d7b66aee8b3 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Wed, 20 May 2026 00:39:02 +0000 Subject: [PATCH 27/36] Update script command examples --- script/deploy/DeployRedeemableTokenFactory.s.sol | 2 ++ script/test/ActivateRedemption.s.sol | 2 ++ script/test/DeployMockYnRWAx.s.sol | 2 ++ script/test/DeployTermRedeemer.s.sol | 2 ++ script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol | 2 ++ script/test/DepositUsdcIntoMockYnRWAx.s.sol | 2 ++ script/test/LockRedeemer.s.sol | 2 ++ script/test/ResetRedeemerStage.s.sol | 2 ++ 8 files changed, 16 insertions(+) diff --git a/script/deploy/DeployRedeemableTokenFactory.s.sol b/script/deploy/DeployRedeemableTokenFactory.s.sol index 73445b8..3caa3ce 100644 --- a/script/deploy/DeployRedeemableTokenFactory.s.sol +++ b/script/deploy/DeployRedeemableTokenFactory.s.sol @@ -5,6 +5,8 @@ import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; import {RedeemableTokenDeployer} from "../common/RedeemableTokenDeployer.sol"; import {BaseDeploymentScript} from "../common/BaseDeploymentScript.s.sol"; +/// @notice Example: +/// SENDER=0xYourAddress ACCOUNT=your-keystore-name forge script script/deploy/DeployRedeemableTokenFactory.s.sol:DeployRedeemableTokenFactory --rpc-url $ETH_MAINNET_RPC_URL --account $ACCOUNT --sender $SENDER --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY contract DeployRedeemableTokenFactory is BaseDeploymentScript { string internal constant DEPLOY_NAMESPACE = "deploy"; diff --git a/script/test/ActivateRedemption.s.sol b/script/test/ActivateRedemption.s.sol index 94824c3..01f62c6 100644 --- a/script/test/ActivateRedemption.s.sol +++ b/script/test/ActivateRedemption.s.sol @@ -18,6 +18,8 @@ interface IWithdrawableVault is IERC20 { error InsufficientMockYnRWAxLiquidity(uint256 requestedAssets, uint256 maxWithdrawableAssets); +/// @notice Example: +/// SENDER=0xYourAddress ACCOUNT=your-keystore-name forge script script/test/ActivateRedemption.s.sol:ActivateRedemption --rpc-url $ETH_MAINNET_RPC_URL --account $ACCOUNT --sender $SENDER --broadcast contract ActivateRedemption is BaseTestScript { function run() external { address controllerAddress = _loadAddress(Constants.TERM_NAMESPACE, Constants.TERM_CONTROLLER_KEY); diff --git a/script/test/DeployMockYnRWAx.s.sol b/script/test/DeployMockYnRWAx.s.sol index 4a9d2cf..7a5bf37 100644 --- a/script/test/DeployMockYnRWAx.s.sol +++ b/script/test/DeployMockYnRWAx.s.sol @@ -8,6 +8,8 @@ import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; import {Constants} from "./Constants.sol"; +/// @notice Example: +/// SENDER=0xYourAddress ACCOUNT=your-keystore-name forge script script/test/DeployMockYnRWAx.s.sol:DeployMockYnRWAx --rpc-url $ETH_MAINNET_RPC_URL --account $ACCOUNT --sender $SENDER --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY contract DeployMockYnRWAx is BaseTestScript { function run() external { vm.startBroadcast(); diff --git a/script/test/DeployTermRedeemer.s.sol b/script/test/DeployTermRedeemer.s.sol index b606bd2..f13d79c 100644 --- a/script/test/DeployTermRedeemer.s.sol +++ b/script/test/DeployTermRedeemer.s.sol @@ -12,6 +12,8 @@ import {TestTermRedeemerController} from "./utils/TestTermRedeemerController.sol error ProcessorRuleSetupFailed(); +/// @notice Example: +/// SENDER=0xYourAddress ACCOUNT=your-keystore-name forge script script/test/DeployTermRedeemer.s.sol:DeployTermRedeemer --rpc-url $ETH_MAINNET_RPC_URL --account $ACCOUNT --sender $SENDER --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY contract DeployTermRedeemer is BaseTestScript { struct ParamRule { uint8 paramType; diff --git a/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol b/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol index 30a5214..7a70803 100644 --- a/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol +++ b/script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol @@ -7,6 +7,8 @@ import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Constants} from "./Constants.sol"; import {console2} from "forge-std/Script.sol"; +/// @notice Example: +/// SENDER=0xYourAddress ACCOUNT=your-keystore-name AMOUNT=1000000000000000000 forge script script/test/DepositMockYnRWAxIntoTermRedeemer.s.sol:DepositMockYnRWAxIntoTermRedeemer --rpc-url $ETH_MAINNET_RPC_URL --account $ACCOUNT --sender $SENDER --broadcast contract DepositMockYnRWAxIntoTermRedeemer is BaseTestScript { function run() external { uint256 assets = vm.envUint(Constants.AMOUNT_ENV); diff --git a/script/test/DepositUsdcIntoMockYnRWAx.s.sol b/script/test/DepositUsdcIntoMockYnRWAx.s.sol index 540b13c..83a6ff3 100644 --- a/script/test/DepositUsdcIntoMockYnRWAx.s.sol +++ b/script/test/DepositUsdcIntoMockYnRWAx.s.sol @@ -8,6 +8,8 @@ import {Contracts} from "./Contracts.sol"; import {Constants} from "./Constants.sol"; import {console2} from "forge-std/Script.sol"; +/// @notice Example: +/// SENDER=0xYourAddress ACCOUNT=your-keystore-name AMOUNT=1000000 forge script script/test/DepositUsdcIntoMockYnRWAx.s.sol:DepositUsdcIntoMockYnRWAx --rpc-url $ETH_MAINNET_RPC_URL --account $ACCOUNT --sender $SENDER --broadcast contract DepositUsdcIntoMockYnRWAx is BaseTestScript { function run() external { uint256 assets = vm.envUint(Constants.AMOUNT_ENV); diff --git a/script/test/LockRedeemer.s.sol b/script/test/LockRedeemer.s.sol index a52547f..1770173 100644 --- a/script/test/LockRedeemer.s.sol +++ b/script/test/LockRedeemer.s.sol @@ -6,6 +6,8 @@ import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Constants} from "./Constants.sol"; import {console2} from "forge-std/Script.sol"; +/// @notice Example: +/// SENDER=0xYourAddress ACCOUNT=your-keystore-name forge script script/test/LockRedeemer.s.sol:LockRedeemer --rpc-url $ETH_MAINNET_RPC_URL --account $ACCOUNT --sender $SENDER --broadcast contract LockRedeemer is BaseTestScript { function run() external { address controllerAddress = _loadAddress(Constants.TERM_NAMESPACE, Constants.TERM_CONTROLLER_KEY); diff --git a/script/test/ResetRedeemerStage.s.sol b/script/test/ResetRedeemerStage.s.sol index 030df5b..17ad7ad 100644 --- a/script/test/ResetRedeemerStage.s.sol +++ b/script/test/ResetRedeemerStage.s.sol @@ -6,6 +6,8 @@ import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Constants} from "./Constants.sol"; import {console2} from "forge-std/Script.sol"; +/// @notice Example: +/// SENDER=0xYourAddress ACCOUNT=your-keystore-name forge script script/test/ResetRedeemerStage.s.sol:ResetRedeemerStage --rpc-url $ETH_MAINNET_RPC_URL --account $ACCOUNT --sender $SENDER --broadcast contract ResetRedeemerStage is BaseTestScript { function run() external { address controllerAddress = _loadAddress(Constants.TERM_NAMESPACE, Constants.TERM_CONTROLLER_KEY); From 284f1e89a40cce07f0f574723338a0d480a1d9b3 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Wed, 20 May 2026 01:04:32 +0000 Subject: [PATCH 28/36] Align test vault assets with BaseVault ordering --- script/test/Constants.sol | 5 ++++- script/test/DeployMockYnRWAx.s.sol | 12 ++++++++++++ script/test/DeployTermRedeemer.s.sol | 4 +++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/script/test/Constants.sol b/script/test/Constants.sol index d6e8380..797a15e 100644 --- a/script/test/Constants.sol +++ b/script/test/Constants.sol @@ -5,6 +5,8 @@ library Constants { string internal constant AMOUNT_ENV = "AMOUNT"; string internal constant MOCK_YNRWAX_NAMESPACE = "test-mock-ynrwax"; + string internal constant SHARED_NAMESPACE = "test-shared"; + string internal constant WRAPPED_USDC_KEY = "wrappedUsdc"; string internal constant TERM_NAMESPACE = "test-term-redeemer"; string internal constant MOCK_YNRWAX_PROVIDER_KEY = "mockYnRwaProvider"; string internal constant MOCK_YNRWAX_IMPLEMENTATION_KEY = "mockYnRwaImplementation"; @@ -24,11 +26,12 @@ library Constants { string internal constant MOCK_YNRWAX_LABEL = "Mock ynRWAx"; string internal constant MOCK_YNRWAX_PROVIDER_LABEL = "Mock ynRWAx provider"; + string internal constant WRAPPED_USDC_LABEL = "Wrapped USDC"; string internal constant TERM_VAULT_LABEL = "Term vault"; string internal constant TERM_HOOKS_LABEL = "Term hooks"; string internal constant TERM_CONTROLLER_LABEL = "Term controller"; uint8 internal constant VAULT_DECIMALS = 18; uint256 internal constant ONE = 1e18; - uint256 internal constant DEFAULT_ASSET_INDEX = 0; + uint256 internal constant DEFAULT_ASSET_INDEX = 1; } diff --git a/script/test/DeployMockYnRWAx.s.sol b/script/test/DeployMockYnRWAx.s.sol index 7a5bf37..15e5a28 100644 --- a/script/test/DeployMockYnRWAx.s.sol +++ b/script/test/DeployMockYnRWAx.s.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.24; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; import {MockRateProvider} from "../../test/mocks/MockRateProvider.sol"; +import {MockERC20} from "../../test/mocks/MockERC20.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; import {Constants} from "./Constants.sol"; @@ -16,6 +17,8 @@ contract DeployMockYnRWAx is BaseTestScript { address admin = _broadcaster(); MockRateProvider provider = new MockRateProvider(); + MockERC20 wrappedUsdc = new MockERC20("Wrapped USDC", "wUSDC", 18); + provider.setRate(address(wrappedUsdc), Constants.ONE); provider.setRate(Contracts.USDC, Constants.ONE); RedeemableToken implementation = new RedeemableToken(); @@ -41,6 +44,8 @@ contract DeployMockYnRWAx is BaseTestScript { vault.grantRole(vault.ASSET_MANAGER_ROLE(), admin); vault.grantRole(vault.UNPAUSER_ROLE(), admin); vault.setProvider(address(provider)); + vault.addAsset(address(wrappedUsdc), false); + vault.setAssetWithdrawable(address(wrappedUsdc), false); vault.addAsset(Contracts.USDC, true); vault.setAssetWithdrawable(Contracts.USDC, true); vault.unpause(); @@ -57,7 +62,14 @@ contract DeployMockYnRWAx is BaseTestScript { values[2] = address(vault); _writeAddresses(Constants.MOCK_YNRWAX_NAMESPACE, keys, values); + string[] memory sharedKeys = new string[](1); + address[] memory sharedValues = new address[](1); + sharedKeys[0] = Constants.WRAPPED_USDC_KEY; + sharedValues[0] = address(wrappedUsdc); + _writeAddresses(Constants.SHARED_NAMESPACE, sharedKeys, sharedValues); + _logAddress(Constants.MOCK_YNRWAX_LABEL, address(vault)); _logAddress(Constants.MOCK_YNRWAX_PROVIDER_LABEL, address(provider)); + _logAddress(Constants.WRAPPED_USDC_LABEL, address(wrappedUsdc)); } } diff --git a/script/test/DeployTermRedeemer.s.sol b/script/test/DeployTermRedeemer.s.sol index f13d79c..c455565 100644 --- a/script/test/DeployTermRedeemer.s.sol +++ b/script/test/DeployTermRedeemer.s.sol @@ -30,10 +30,12 @@ contract DeployTermRedeemer is BaseTestScript { function run() external { address admin = _broadcaster(); address mockYnRWAx = _loadAddress(Constants.MOCK_YNRWAX_NAMESPACE, Constants.MOCK_YNRWAX_KEY); + address wrappedUsdc = _loadAddress(Constants.SHARED_NAMESPACE, Constants.WRAPPED_USDC_KEY); vm.startBroadcast(); MockRateProvider provider = new MockRateProvider(); + provider.setRate(wrappedUsdc, Constants.ONE); provider.setRate(Contracts.USDC, Constants.ONE); provider.setRate(mockYnRWAx, Constants.ONE); @@ -43,7 +45,7 @@ contract DeployTermRedeemer is BaseTestScript { TestRedeemableTokenFactory.DeployParams({ admin: admin, provider: address(provider), - wrappedAsset: address(0), + wrappedAsset: wrappedUsdc, redemptionAsset: Contracts.USDC, depositToken: mockYnRWAx, feeRecipient: admin, From d1a34e1056da931e358446df5e8e10e66f734c77 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Wed, 20 May 2026 01:17:05 +0000 Subject: [PATCH 29/36] Fix script deployment paths and wrapped USDC config --- foundry.toml | 2 ++ script/test/Constants.sol | 3 --- script/test/Contracts.sol | 1 + script/test/DeployMockYnRWAx.s.sol | 15 +++------------ script/test/DeployTermRedeemer.s.sol | 5 ++--- 5 files changed, 8 insertions(+), 18 deletions(-) diff --git a/foundry.toml b/foundry.toml index 964c8ae..34c87ff 100644 --- a/foundry.toml +++ b/foundry.toml @@ -14,6 +14,7 @@ remappings = [ via_ir = true optimizer = true optimizer_runs = 200 +fs_permissions = [{ access = "read-write", path = "./deployments" }] [profile.mainnet] src = "contracts" @@ -32,6 +33,7 @@ eth_rpc_url = "eth_mainnet" via_ir = true optimizer = true optimizer_runs = 200 +fs_permissions = [{ access = "read-write", path = "./deployments" }] [rpc_endpoints] eth_mainnet = "${ETH_MAINNET_RPC_URL}" diff --git a/script/test/Constants.sol b/script/test/Constants.sol index 797a15e..12b6e0e 100644 --- a/script/test/Constants.sol +++ b/script/test/Constants.sol @@ -5,8 +5,6 @@ library Constants { string internal constant AMOUNT_ENV = "AMOUNT"; string internal constant MOCK_YNRWAX_NAMESPACE = "test-mock-ynrwax"; - string internal constant SHARED_NAMESPACE = "test-shared"; - string internal constant WRAPPED_USDC_KEY = "wrappedUsdc"; string internal constant TERM_NAMESPACE = "test-term-redeemer"; string internal constant MOCK_YNRWAX_PROVIDER_KEY = "mockYnRwaProvider"; string internal constant MOCK_YNRWAX_IMPLEMENTATION_KEY = "mockYnRwaImplementation"; @@ -26,7 +24,6 @@ library Constants { string internal constant MOCK_YNRWAX_LABEL = "Mock ynRWAx"; string internal constant MOCK_YNRWAX_PROVIDER_LABEL = "Mock ynRWAx provider"; - string internal constant WRAPPED_USDC_LABEL = "Wrapped USDC"; string internal constant TERM_VAULT_LABEL = "Term vault"; string internal constant TERM_HOOKS_LABEL = "Term hooks"; string internal constant TERM_CONTROLLER_LABEL = "Term controller"; diff --git a/script/test/Contracts.sol b/script/test/Contracts.sol index 9ef2252..fed7828 100644 --- a/script/test/Contracts.sol +++ b/script/test/Contracts.sol @@ -3,4 +3,5 @@ pragma solidity ^0.8.24; library Contracts { address internal constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address internal constant WRAPPED_USDC = 0xdA7d2025c7f1f1A1d34AB3F4dF01102d0428E574; } diff --git a/script/test/DeployMockYnRWAx.s.sol b/script/test/DeployMockYnRWAx.s.sol index 15e5a28..c820a74 100644 --- a/script/test/DeployMockYnRWAx.s.sol +++ b/script/test/DeployMockYnRWAx.s.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.24; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; import {MockRateProvider} from "../../test/mocks/MockRateProvider.sol"; -import {MockERC20} from "../../test/mocks/MockERC20.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; import {Constants} from "./Constants.sol"; @@ -17,8 +16,7 @@ contract DeployMockYnRWAx is BaseTestScript { address admin = _broadcaster(); MockRateProvider provider = new MockRateProvider(); - MockERC20 wrappedUsdc = new MockERC20("Wrapped USDC", "wUSDC", 18); - provider.setRate(address(wrappedUsdc), Constants.ONE); + provider.setRate(Contracts.WRAPPED_USDC, Constants.ONE); provider.setRate(Contracts.USDC, Constants.ONE); RedeemableToken implementation = new RedeemableToken(); @@ -44,8 +42,8 @@ contract DeployMockYnRWAx is BaseTestScript { vault.grantRole(vault.ASSET_MANAGER_ROLE(), admin); vault.grantRole(vault.UNPAUSER_ROLE(), admin); vault.setProvider(address(provider)); - vault.addAsset(address(wrappedUsdc), false); - vault.setAssetWithdrawable(address(wrappedUsdc), false); + vault.addAsset(Contracts.WRAPPED_USDC, false); + vault.setAssetWithdrawable(Contracts.WRAPPED_USDC, false); vault.addAsset(Contracts.USDC, true); vault.setAssetWithdrawable(Contracts.USDC, true); vault.unpause(); @@ -62,14 +60,7 @@ contract DeployMockYnRWAx is BaseTestScript { values[2] = address(vault); _writeAddresses(Constants.MOCK_YNRWAX_NAMESPACE, keys, values); - string[] memory sharedKeys = new string[](1); - address[] memory sharedValues = new address[](1); - sharedKeys[0] = Constants.WRAPPED_USDC_KEY; - sharedValues[0] = address(wrappedUsdc); - _writeAddresses(Constants.SHARED_NAMESPACE, sharedKeys, sharedValues); - _logAddress(Constants.MOCK_YNRWAX_LABEL, address(vault)); _logAddress(Constants.MOCK_YNRWAX_PROVIDER_LABEL, address(provider)); - _logAddress(Constants.WRAPPED_USDC_LABEL, address(wrappedUsdc)); } } diff --git a/script/test/DeployTermRedeemer.s.sol b/script/test/DeployTermRedeemer.s.sol index c455565..64821bc 100644 --- a/script/test/DeployTermRedeemer.s.sol +++ b/script/test/DeployTermRedeemer.s.sol @@ -30,12 +30,11 @@ contract DeployTermRedeemer is BaseTestScript { function run() external { address admin = _broadcaster(); address mockYnRWAx = _loadAddress(Constants.MOCK_YNRWAX_NAMESPACE, Constants.MOCK_YNRWAX_KEY); - address wrappedUsdc = _loadAddress(Constants.SHARED_NAMESPACE, Constants.WRAPPED_USDC_KEY); vm.startBroadcast(); MockRateProvider provider = new MockRateProvider(); - provider.setRate(wrappedUsdc, Constants.ONE); + provider.setRate(Contracts.WRAPPED_USDC, Constants.ONE); provider.setRate(Contracts.USDC, Constants.ONE); provider.setRate(mockYnRWAx, Constants.ONE); @@ -45,7 +44,7 @@ contract DeployTermRedeemer is BaseTestScript { TestRedeemableTokenFactory.DeployParams({ admin: admin, provider: address(provider), - wrappedAsset: wrappedUsdc, + wrappedAsset: Contracts.WRAPPED_USDC, redemptionAsset: Contracts.USDC, depositToken: mockYnRWAx, feeRecipient: admin, From 77b12bc5e8e7032f25974c98d6e84d17a63115d3 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Wed, 20 May 2026 01:26:26 +0000 Subject: [PATCH 30/36] Create deployment directories for scripts --- script/common/BaseDeploymentScript.s.sol | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/script/common/BaseDeploymentScript.s.sol b/script/common/BaseDeploymentScript.s.sol index d3b9a88..b7ac2b6 100644 --- a/script/common/BaseDeploymentScript.s.sol +++ b/script/common/BaseDeploymentScript.s.sol @@ -16,6 +16,8 @@ abstract contract BaseDeploymentScript is Script { revert InvalidDeploymentPayload(); } + vm.createDir(_deploymentDirectory(namespace), true); + string memory objectKey = "deployment"; for (uint256 i = 0; i < keys.length; ++i) { vm.serializeAddress(objectKey, keys[i], values[i]); @@ -35,6 +37,10 @@ abstract contract BaseDeploymentScript is Script { } function _deploymentPath(string memory namespace) internal view returns (string memory) { - return string.concat(vm.projectRoot(), "/deployments/", namespace, "/", vm.toString(block.chainid), ".json"); + return string.concat(_deploymentDirectory(namespace), "/", vm.toString(block.chainid), ".json"); + } + + function _deploymentDirectory(string memory namespace) internal view returns (string memory) { + return string.concat(vm.projectRoot(), "/deployments/", namespace); } } From ed20b525c9d27878f06cc5fe1443bbda9ff51604 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Wed, 20 May 2026 01:47:37 +0000 Subject: [PATCH 31/36] Add mock ynRWAx deployer --- script/common/MockYnRwaDeployer.sol | 99 +++++++++++++++++++++++++++++ script/test/DeployMockYnRWAx.s.sol | 48 ++++++-------- 2 files changed, 118 insertions(+), 29 deletions(-) create mode 100644 script/common/MockYnRwaDeployer.sol diff --git a/script/common/MockYnRwaDeployer.sol b/script/common/MockYnRwaDeployer.sol new file mode 100644 index 0000000..6bc1a04 --- /dev/null +++ b/script/common/MockYnRwaDeployer.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {RedeemableToken} from "contracts/RedeemableToken.sol"; + +error ZeroAddress(); + +contract MockYnRwaDeployer { + address public immutable implementation; + + event Deployed(address indexed vault, address admin); + + struct DeployParams { + address admin; + address provider; + address wrappedAsset; + address redemptionAsset; + string name; + string symbol; + uint8 decimals; + bool countNativeAsset; + bool alwaysComputeTotalAssets; + uint256 defaultAssetIndex; + } + + constructor(address implementation_) { + if (implementation_ == address(0)) { + revert ZeroAddress(); + } + + implementation = implementation_; + } + + function deploy(DeployParams calldata params) external returns (RedeemableToken vault) { + if ( + params.admin == address(0) || params.provider == address(0) || params.wrappedAsset == address(0) + || params.redemptionAsset == address(0) + ) { + revert ZeroAddress(); + } + + vault = RedeemableToken( + payable( + address( + new TransparentUpgradeableProxy( + implementation, + params.admin, + abi.encodeCall( + RedeemableToken.initialize, + (RedeemableToken.InitParams({ + admin: address(this), + name: params.name, + symbol: params.symbol, + decimals_: params.decimals, + countNativeAsset_: params.countNativeAsset, + alwaysComputeTotalAssets_: params.alwaysComputeTotalAssets, + defaultAssetIndex_: params.defaultAssetIndex + })) + ) + ) + ) + ) + ); + + _grantTemporaryRoles(vault); + _configureVault(vault, params); + _handoffRoles(vault, params.admin); + + emit Deployed(address(vault), params.admin); + } + + function _grantTemporaryRoles(RedeemableToken vault) internal { + vault.grantRole(vault.PROVIDER_MANAGER_ROLE(), address(this)); + vault.grantRole(vault.ASSET_MANAGER_ROLE(), address(this)); + vault.grantRole(vault.UNPAUSER_ROLE(), address(this)); + } + + function _configureVault(RedeemableToken vault, DeployParams calldata params) internal { + vault.setProvider(params.provider); + vault.addAsset(params.wrappedAsset, false); + vault.setAssetWithdrawable(params.wrappedAsset, false); + vault.addAsset(params.redemptionAsset, true); + vault.setAssetWithdrawable(params.redemptionAsset, true); + vault.unpause(); + } + + function _handoffRoles(RedeemableToken vault, address admin) internal { + vault.grantRole(vault.DEFAULT_ADMIN_ROLE(), admin); + vault.grantRole(vault.PROVIDER_MANAGER_ROLE(), admin); + vault.grantRole(vault.ASSET_MANAGER_ROLE(), admin); + vault.grantRole(vault.UNPAUSER_ROLE(), admin); + + vault.renounceRole(vault.PROVIDER_MANAGER_ROLE(), address(this)); + vault.renounceRole(vault.ASSET_MANAGER_ROLE(), address(this)); + vault.renounceRole(vault.UNPAUSER_ROLE(), address(this)); + vault.renounceRole(vault.DEFAULT_ADMIN_ROLE(), address(this)); + } +} diff --git a/script/test/DeployMockYnRWAx.s.sol b/script/test/DeployMockYnRWAx.s.sol index c820a74..f61eed8 100644 --- a/script/test/DeployMockYnRWAx.s.sol +++ b/script/test/DeployMockYnRWAx.s.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; +import {MockYnRwaDeployer} from "../common/MockYnRwaDeployer.sol"; import {MockRateProvider} from "../../test/mocks/MockRateProvider.sol"; import {BaseTestScript} from "./BaseTestScript.s.sol"; import {Contracts} from "./Contracts.sol"; @@ -20,44 +20,34 @@ contract DeployMockYnRWAx is BaseTestScript { provider.setRate(Contracts.USDC, Constants.ONE); RedeemableToken implementation = new RedeemableToken(); - TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( - address(implementation), - admin, - abi.encodeCall( - RedeemableToken.initialize, - (RedeemableToken.InitParams({ - admin: admin, - name: Constants.MOCK_YNRWAX_NAME, - symbol: Constants.MOCK_YNRWAX_SYMBOL, - decimals_: Constants.VAULT_DECIMALS, - countNativeAsset_: false, - alwaysComputeTotalAssets_: false, - defaultAssetIndex_: Constants.DEFAULT_ASSET_INDEX - })) - ) + MockYnRwaDeployer deployer = new MockYnRwaDeployer(address(implementation)); + RedeemableToken vault = deployer.deploy( + MockYnRwaDeployer.DeployParams({ + admin: admin, + provider: address(provider), + wrappedAsset: Contracts.WRAPPED_USDC, + redemptionAsset: Contracts.USDC, + name: Constants.MOCK_YNRWAX_NAME, + symbol: Constants.MOCK_YNRWAX_SYMBOL, + decimals: Constants.VAULT_DECIMALS, + countNativeAsset: false, + alwaysComputeTotalAssets: false, + defaultAssetIndex: Constants.DEFAULT_ASSET_INDEX + }) ); - RedeemableToken vault = RedeemableToken(payable(address(proxy))); - vault.grantRole(vault.PROVIDER_MANAGER_ROLE(), admin); - vault.grantRole(vault.ASSET_MANAGER_ROLE(), admin); - vault.grantRole(vault.UNPAUSER_ROLE(), admin); - vault.setProvider(address(provider)); - vault.addAsset(Contracts.WRAPPED_USDC, false); - vault.setAssetWithdrawable(Contracts.WRAPPED_USDC, false); - vault.addAsset(Contracts.USDC, true); - vault.setAssetWithdrawable(Contracts.USDC, true); - vault.unpause(); - vm.stopBroadcast(); - string[] memory keys = new string[](3); - address[] memory values = new address[](3); + string[] memory keys = new string[](4); + address[] memory values = new address[](4); keys[0] = Constants.MOCK_YNRWAX_PROVIDER_KEY; values[0] = address(provider); keys[1] = Constants.MOCK_YNRWAX_IMPLEMENTATION_KEY; values[1] = address(implementation); keys[2] = Constants.MOCK_YNRWAX_KEY; values[2] = address(vault); + keys[3] = "mockYnRwaFactory"; + values[3] = address(deployer); _writeAddresses(Constants.MOCK_YNRWAX_NAMESPACE, keys, values); _logAddress(Constants.MOCK_YNRWAX_LABEL, address(vault)); From b0471f5962af1d69c03e5e6fb8bacdc5cf6fe23e Mon Sep 17 00:00:00 2001 From: danoctavian Date: Wed, 20 May 2026 20:54:24 +0300 Subject: [PATCH 32/36] add mock rwa deployment --- deployments/test-mock-ynrwax/1.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 deployments/test-mock-ynrwax/1.json diff --git a/deployments/test-mock-ynrwax/1.json b/deployments/test-mock-ynrwax/1.json new file mode 100644 index 0000000..821ebde --- /dev/null +++ b/deployments/test-mock-ynrwax/1.json @@ -0,0 +1,7 @@ +{ + "chainId": 1, + "mockYnRwa": "0xB25dfEDAe1673057bbc23C15318c1Efd0d17D70e", + "mockYnRwaFactory": "0xe05b8d05EEb88d03cEB494d24E0Be3A9EF5Cd423", + "mockYnRwaImplementation": "0xa2997E1C82137B242e1aF9E7E2D8f5ec42E12cec", + "mockYnRwaProvider": "0xE56d6cBb40c7aa6676dA00c4BDA4715f21398F72" +} \ No newline at end of file From 6f9eb39db25718267d830b25c0d47d3a6350e6bc Mon Sep 17 00:00:00 2001 From: danoctavian Date: Wed, 20 May 2026 21:55:15 +0300 Subject: [PATCH 33/36] add term redeemer deployment --- deployments/test-term-redeemer/1.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 deployments/test-term-redeemer/1.json diff --git a/deployments/test-term-redeemer/1.json b/deployments/test-term-redeemer/1.json new file mode 100644 index 0000000..244d494 --- /dev/null +++ b/deployments/test-term-redeemer/1.json @@ -0,0 +1,9 @@ +{ + "chainId": 1, + "termController": "0x02E5D54af6B5d3A5959F686Dbd17b573234D00bE", + "termFactory": "0xC691B1Da9c90E64280ef0d5Fd8AD207020d04338", + "termHooks": "0x049aEC31E49eeB2408552A04E36C22B5b36006F0", + "termImplementation": "0x8F98AD7E65Fc022be23C1D83A2058293A0043568", + "termProvider": "0xBD4391Fd6846F5a9e19bBAEd98E4B67C0ecE9a9d", + "termVault": "0x0a2Bdf3fb6c8a369a6FC603Aa3885B39618Bfd97" +} \ No newline at end of file From 184aa8db2f8bfa82f9fc36e6d65d876dc00af3cb Mon Sep 17 00:00:00 2001 From: danoctavian Date: Fri, 29 May 2026 04:17:46 +0000 Subject: [PATCH 34/36] Add live ynRWAx rate provider --- .../test/DeployLiveYnRWAxTermRedeemer.s.sol | 108 ++++++++++++++++++ src/YnRwaRateProvider.sol | 42 +++++++ 2 files changed, 150 insertions(+) create mode 100644 script/test/DeployLiveYnRWAxTermRedeemer.s.sol create mode 100644 src/YnRwaRateProvider.sol diff --git a/script/test/DeployLiveYnRWAxTermRedeemer.s.sol b/script/test/DeployLiveYnRWAxTermRedeemer.s.sol new file mode 100644 index 0000000..d9f5136 --- /dev/null +++ b/script/test/DeployLiveYnRWAxTermRedeemer.s.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {RedeemableToken} from "../../contracts/RedeemableToken.sol"; +import {YnRwaRateProvider} from "../../contracts/YnRwaRateProvider.sol"; +import {FeeHooks} from "yieldnest-vault/src/hooks/FeeHooks.sol"; +import {BaseTestScript} from "./BaseTestScript.s.sol"; +import {Contracts} from "./Contracts.sol"; +import {Constants} from "./Constants.sol"; +import {TestRedeemableTokenFactory} from "./utils/TestRedeemableTokenFactory.sol"; +import {TestTermRedeemerController} from "./utils/TestTermRedeemerController.sol"; + +error ProcessorRuleSetupFailed(); + +/// @notice Example: +/// SENDER=0xYourAddress ACCOUNT=your-keystore-name forge script script/test/DeployLiveYnRWAxTermRedeemer.s.sol:DeployLiveYnRWAxTermRedeemer --rpc-url $ETH_MAINNET_RPC_URL --account $ACCOUNT --sender $SENDER --broadcast --slow --verify --etherscan-api-key $ETHERSCAN_API_KEY +contract DeployLiveYnRWAxTermRedeemer is BaseTestScript { + struct ParamRule { + uint8 paramType; + bool isArray; + address[] allowList; + } + + struct FunctionRule { + bool isActive; + ParamRule[] paramRules; + address validator; + } + + function run() external { + address admin = _broadcaster(); + address liveYnRWAx = Contracts.YNRWAX; + + vm.startBroadcast(); + + YnRwaRateProvider provider = new YnRwaRateProvider(Contracts.WRAPPED_USDC, Contracts.USDC, liveYnRWAx); + + RedeemableToken implementation = new RedeemableToken(); + TestRedeemableTokenFactory factory = new TestRedeemableTokenFactory(address(implementation)); + (RedeemableToken vault, FeeHooks hooks, TestTermRedeemerController controller) = factory.deploy( + TestRedeemableTokenFactory.DeployParams({ + admin: admin, + provider: address(provider), + wrappedAsset: Contracts.WRAPPED_USDC, + redemptionAsset: Contracts.USDC, + depositToken: liveYnRWAx, + feeRecipient: admin, + name: Constants.LIVE_TERM_VAULT_NAME, + symbol: Constants.LIVE_TERM_VAULT_SYMBOL, + decimals: Constants.VAULT_DECIMALS, + countNativeAsset: false, + alwaysComputeTotalAssets: false, + defaultAssetIndex: Constants.DEFAULT_ASSET_INDEX + }) + ); + + _allowProcessorWithdrawAsset(vault, liveYnRWAx, Contracts.USDC); + + vm.stopBroadcast(); + + string[] memory keys = new string[](7); + address[] memory values = new address[](7); + keys[0] = Constants.TERM_PROVIDER_KEY; + values[0] = address(provider); + keys[1] = Constants.TERM_IMPLEMENTATION_KEY; + values[1] = address(implementation); + keys[2] = Constants.TERM_FACTORY_KEY; + values[2] = address(factory); + keys[3] = Constants.TERM_VAULT_KEY; + values[3] = address(vault); + keys[4] = Constants.TERM_HOOKS_KEY; + values[4] = address(hooks); + keys[5] = Constants.TERM_CONTROLLER_KEY; + values[5] = address(controller); + keys[6] = "depositToken"; + values[6] = liveYnRWAx; + _writeAddresses(Constants.LIVE_TERM_NAMESPACE, keys, values); + + _logAddress(Constants.TERM_VAULT_LABEL, address(vault)); + _logAddress(Constants.TERM_HOOKS_LABEL, address(hooks)); + _logAddress(Constants.TERM_CONTROLLER_LABEL, address(controller)); + } + + function _allowProcessorWithdrawAsset(RedeemableToken vault, address target, address redemptionAsset) internal { + address[] memory vaultAllowList = new address[](1); + vaultAllowList[0] = address(vault); + address[] memory assetAllowList = new address[](1); + assetAllowList[0] = redemptionAsset; + ParamRule[] memory paramRules = new ParamRule[](4); + paramRules[0] = ParamRule({paramType: 1, isArray: false, allowList: assetAllowList}); + paramRules[2] = ParamRule({paramType: 1, isArray: false, allowList: vaultAllowList}); + paramRules[3] = ParamRule({paramType: 1, isArray: false, allowList: vaultAllowList}); + FunctionRule memory rule = FunctionRule({isActive: true, paramRules: paramRules, validator: address(0)}); + + (bool ok,) = address(vault).call( + abi.encodeWithSignature( + "setProcessorRule(address,bytes4,(bool,(uint8,bool,address[])[],address))", + target, + bytes4(keccak256("withdrawAsset(address,uint256,address,address)")), + rule + ) + ); + if (!ok) { + revert ProcessorRuleSetupFailed(); + } + } +} diff --git a/src/YnRwaRateProvider.sol b/src/YnRwaRateProvider.sol new file mode 100644 index 0000000..af549f1 --- /dev/null +++ b/src/YnRwaRateProvider.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IProvider} from "yieldnest-vault/src/interface/IProvider.sol"; + +error UnsupportedAsset(address asset); +error ZeroAddress(); + +interface IYnRwaPreviewRedeem { + function previewRedeem(uint256 shares) external view returns (uint256 assets); +} + +contract YnRwaRateProvider is IProvider { + uint256 internal constant SHARE_UNIT = 1 ether; + uint256 internal constant USDC_BASE_SCALE = 1e12; + + address public immutable wrappedUsdc; + address public immutable usdc; + address public immutable ynRwa; + + constructor(address wrappedUsdc_, address usdc_, address ynRwa_) { + if (wrappedUsdc_ == address(0) || usdc_ == address(0) || ynRwa_ == address(0)) { + revert ZeroAddress(); + } + + wrappedUsdc = wrappedUsdc_; + usdc = usdc_; + ynRwa = ynRwa_; + } + + function getRate(address asset) external view returns (uint256) { + if (asset == wrappedUsdc || asset == usdc) { + return 1e18; + } + + if (asset == ynRwa) { + return IYnRwaPreviewRedeem(ynRwa).previewRedeem(SHARE_UNIT) * USDC_BASE_SCALE; + } + + revert UnsupportedAsset(asset); + } +} From b83c04474f1dc91659e6640c25a860f59fed6d6f Mon Sep 17 00:00:00 2001 From: danoctavian Date: Fri, 29 May 2026 04:18:56 +0000 Subject: [PATCH 35/36] Add live ynRWAx script constants --- script/test/Constants.sol | 3 +++ script/test/Contracts.sol | 1 + 2 files changed, 4 insertions(+) diff --git a/script/test/Constants.sol b/script/test/Constants.sol index 12b6e0e..aff7e65 100644 --- a/script/test/Constants.sol +++ b/script/test/Constants.sol @@ -6,6 +6,7 @@ library Constants { string internal constant MOCK_YNRWAX_NAMESPACE = "test-mock-ynrwax"; string internal constant TERM_NAMESPACE = "test-term-redeemer"; + string internal constant LIVE_TERM_NAMESPACE = "test-term-redeemer-live-ynrwax"; string internal constant MOCK_YNRWAX_PROVIDER_KEY = "mockYnRwaProvider"; string internal constant MOCK_YNRWAX_IMPLEMENTATION_KEY = "mockYnRwaImplementation"; string internal constant MOCK_YNRWAX_KEY = "mockYnRwa"; @@ -21,6 +22,8 @@ library Constants { string internal constant MOCK_YNRWAX_SYMBOL = "mynRWAx"; string internal constant TERM_VAULT_NAME = "Withdrawable Mock ynRWAx"; string internal constant TERM_VAULT_SYMBOL = "wmynRWAx"; + string internal constant LIVE_TERM_VAULT_NAME = "Withdrawable ynRWAx"; + string internal constant LIVE_TERM_VAULT_SYMBOL = "wynRWAx"; string internal constant MOCK_YNRWAX_LABEL = "Mock ynRWAx"; string internal constant MOCK_YNRWAX_PROVIDER_LABEL = "Mock ynRWAx provider"; diff --git a/script/test/Contracts.sol b/script/test/Contracts.sol index fed7828..d1b1230 100644 --- a/script/test/Contracts.sol +++ b/script/test/Contracts.sol @@ -4,4 +4,5 @@ pragma solidity ^0.8.24; library Contracts { address internal constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; address internal constant WRAPPED_USDC = 0xdA7d2025c7f1f1A1d34AB3F4dF01102d0428E574; + address internal constant YNRWAX = 0x01Ba69727E2860b37bc1a2bd56999c1aFb4C15D8; } From d596541119403420e4a48c45419216f86f097259 Mon Sep 17 00:00:00 2001 From: danoctavian Date: Fri, 29 May 2026 07:59:09 +0300 Subject: [PATCH 36/36] add vault with real ynRWAx --- deployments/test-term-redeemer-live-ynrwax/1.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 deployments/test-term-redeemer-live-ynrwax/1.json diff --git a/deployments/test-term-redeemer-live-ynrwax/1.json b/deployments/test-term-redeemer-live-ynrwax/1.json new file mode 100644 index 0000000..06822c0 --- /dev/null +++ b/deployments/test-term-redeemer-live-ynrwax/1.json @@ -0,0 +1,10 @@ +{ + "chainId": 1, + "depositToken": "0x01Ba69727E2860b37bc1a2bd56999c1aFb4C15D8", + "termController": "0x368214F6e87d29904D0d710CC10606B838386736", + "termFactory": "0x6e0d9E0c98736E7F0F95F8C69191f5c0743Fbd8A", + "termHooks": "0x4a7Bcc6E2aea3edae95598897D91fb5DA6261c75", + "termImplementation": "0x8e599a9FFC4ae558b38247DfdAbFcc50D73bce9E", + "termProvider": "0xF38613b1Ff836286E0044f230BEE0F8058aaf2BC", + "termVault": "0x87376F00bB483BFa0D4B9dEfA13a5F3754e2A280" +} \ No newline at end of file