From 711d55ced48099a19dce911e2cf095a93bfb8c82 Mon Sep 17 00:00:00 2001 From: giantcoconut Date: Sat, 2 May 2026 08:28:18 +0100 Subject: [PATCH 1/4] Validate fee proxy receivers (2A) --- src/IntuitionFeeProxy.sol | 16 +++++++ src/libraries/Errors.sol | 3 ++ test/IntuitionFeeProxy.test.ts | 86 ++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/src/IntuitionFeeProxy.sol b/src/IntuitionFeeProxy.sol index 35993d7..c3a00cb 100644 --- a/src/IntuitionFeeProxy.sol +++ b/src/IntuitionFeeProxy.sol @@ -208,6 +208,8 @@ contract IntuitionFeeProxy { uint256[] calldata assets, uint256 curveId ) external payable returns (bytes32[] memory atomIds) { + _validateReceiver(receiver); + if (data.length != assets.length) { revert Errors.IntuitionFeeProxy_WrongArrayLengths(); } @@ -271,6 +273,8 @@ contract IntuitionFeeProxy { uint256[] calldata assets, uint256 curveId ) external payable returns (bytes32[] memory tripleIds) { + _validateReceiver(receiver); + if (subjectIds.length != predicateIds.length || predicateIds.length != objectIds.length || objectIds.length != assets.length) { @@ -337,6 +341,8 @@ contract IntuitionFeeProxy { uint256 curveId, uint256 minShares ) external payable returns (uint256 shares) { + _validateReceiver(receiver); + // Must send more than just the fixed fee if (msg.value <= depositFixedFee) { revert Errors.IntuitionFeeProxy_InsufficientValue(); @@ -384,6 +390,8 @@ contract IntuitionFeeProxy { uint256[] calldata assets, uint256[] calldata minShares ) external payable returns (uint256[] memory shares) { + _validateReceiver(receiver); + if (termIds.length != curveIds.length || curveIds.length != assets.length || assets.length != minShares.length) { @@ -472,6 +480,14 @@ contract IntuitionFeeProxy { // ============ Internal Functions ============ + /// @notice Ensure shares can only be minted to the caller in user-facing flows + /// @param receiver Address to receive shares + function _validateReceiver(address receiver) internal view { + if (receiver != msg.sender) { + revert Errors.IntuitionFeeProxy_InvalidReceiver(); + } + } + /// @notice Transfer collected fees to recipient /// @param amount Amount to transfer function _transferFee(uint256 amount) internal { diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index 839d842..6f76bab 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -27,4 +27,7 @@ library Errors { /// @notice Fee percentage exceeds maximum allowed (100%) error IntuitionFeeProxy_FeePercentageTooHigh(); + + /// @notice Receiver must match the caller for user-facing proxy flows + error IntuitionFeeProxy_InvalidReceiver(); } diff --git a/test/IntuitionFeeProxy.test.ts b/test/IntuitionFeeProxy.test.ts index 9365c5e..85fc675 100644 --- a/test/IntuitionFeeProxy.test.ts +++ b/test/IntuitionFeeProxy.test.ts @@ -210,6 +210,92 @@ describe("IntuitionFeeProxy", function () { }); }); + describe("Receiver Validation", function () { + it("Should revert when createAtoms receiver is not msg.sender", async function () { + const { proxy, user, nonAdmin } = await loadFixture(deployFixture); + + const data = [ethers.toUtf8Bytes("ipfs://atom1")]; + const assets = [0n]; + + await expect( + proxy.connect(user).createAtoms(nonAdmin.address, data, assets, 1n) + ).to.be.revertedWithCustomError(proxy, "IntuitionFeeProxy_InvalidReceiver"); + }); + + it("Should revert when createTriples receiver is not msg.sender", async function () { + const { proxy, user, nonAdmin } = await loadFixture(deployFixture); + + const subjectIds = [ethers.zeroPadValue("0x01", 32)]; + const predicateIds = [ethers.zeroPadValue("0x02", 32)]; + const objectIds = [ethers.zeroPadValue("0x03", 32)]; + const assets = [0n]; + + await expect( + proxy.connect(user).createTriples(nonAdmin.address, subjectIds, predicateIds, objectIds, assets, 1n) + ).to.be.revertedWithCustomError(proxy, "IntuitionFeeProxy_InvalidReceiver"); + }); + + it("Should revert when deposit receiver is not msg.sender", async function () { + const { proxy, user, nonAdmin } = await loadFixture(deployFixture); + + const termId = ethers.zeroPadValue("0x01", 32); + + await expect( + proxy.connect(user).deposit(nonAdmin.address, termId, 1n, 0n) + ).to.be.revertedWithCustomError(proxy, "IntuitionFeeProxy_InvalidReceiver"); + }); + + it("Should revert when depositBatch receiver is not msg.sender", async function () { + const { proxy, user, nonAdmin } = await loadFixture(deployFixture); + + const termIds = [ethers.zeroPadValue("0x01", 32)]; + const curveIds = [1n]; + const assets = [ethers.parseEther("1")]; + const minShares = [0n]; + + await expect( + proxy.connect(user).depositBatch(nonAdmin.address, termIds, curveIds, assets, minShares) + ).to.be.revertedWithCustomError(proxy, "IntuitionFeeProxy_InvalidReceiver"); + }); + + it("Should allow msg.sender as receiver for all user-facing flows", async function () { + const { proxy, mockMultiVault, user } = await loadFixture(deployFixture); + + const atomData = [ethers.toUtf8Bytes("ipfs://atom1")]; + const atomAssets = [0n]; + const atomCost = await mockMultiVault.getAtomCost(); + await expect( + proxy.connect(user).createAtoms(user.address, atomData, atomAssets, 1n, { value: atomCost }) + ).to.not.be.reverted; + + const subjectIds = [ethers.zeroPadValue("0x01", 32)]; + const predicateIds = [ethers.zeroPadValue("0x02", 32)]; + const objectIds = [ethers.zeroPadValue("0x03", 32)]; + const tripleAssets = [0n]; + const tripleCost = await mockMultiVault.getTripleCost(); + await expect( + proxy.connect(user).createTriples(user.address, subjectIds, predicateIds, objectIds, tripleAssets, 1n, { value: tripleCost }) + ).to.not.be.reverted; + + const termId = ethers.zeroPadValue("0x04", 32); + const depositAmount = ethers.parseEther("1"); + const depositCost = await proxy.getTotalDepositCost(depositAmount); + await expect( + proxy.connect(user).deposit(user.address, termId, 1n, 0n, { value: depositCost }) + ).to.not.be.reverted; + + const termIds = [ethers.zeroPadValue("0x05", 32), ethers.zeroPadValue("0x06", 32)]; + const curveIds = [1n, 1n]; + const batchAssets = [ethers.parseEther("0.5"), ethers.parseEther("0.75")]; + const minShares = [0n, 0n]; + const totalDeposit = batchAssets[0] + batchAssets[1]; + const batchFee = await proxy.calculateDepositFee(2n, totalDeposit); + await expect( + proxy.connect(user).depositBatch(user.address, termIds, curveIds, batchAssets, minShares, { value: totalDeposit + batchFee }) + ).to.not.be.reverted; + }); + }); + describe("Proxy Functions - createAtoms", function () { it("Should collect fees on createAtoms (fees based on deposits)", async function () { const { proxy, mockMultiVault, user } = await loadFixture(deployFixture); From 5ed797cc41bd2de7d8e1835e11e8982c26189df5 Mon Sep 17 00:00:00 2001 From: giantcoconut Date: Sat, 2 May 2026 10:25:48 +0100 Subject: [PATCH 2/4] Track and withdraw accrued fees (2C) --- src/IntuitionFeeProxy.sol | 111 ++++++++++++++-- src/libraries/Errors.sol | 16 ++- src/test/RejectNativeTransfer.sol | 10 ++ test/IntuitionFeeProxy.test.ts | 211 +++++++++++++++++++++++++++--- 4 files changed, 316 insertions(+), 32 deletions(-) create mode 100644 src/test/RejectNativeTransfer.sol diff --git a/src/IntuitionFeeProxy.sol b/src/IntuitionFeeProxy.sol index c3a00cb..40a5611 100644 --- a/src/IntuitionFeeProxy.sol +++ b/src/IntuitionFeeProxy.sol @@ -6,7 +6,7 @@ import {Errors} from "./libraries/Errors.sol"; /// @title IntuitionFeeProxy /// @notice Proxy contract for Intuition MultiVault with fee collection -/// @dev Collects fees on deposits and forwards them to a configurable recipient +/// @dev Collects fees on deposits and accrues them for withdrawal to a configurable recipient contract IntuitionFeeProxy { // ============ Constants ============ @@ -34,6 +34,9 @@ contract IntuitionFeeProxy { /// @dev Default: 500 = 5% uint256 public depositPercentageFee; + /// @notice Total collected proxy fees awaiting withdrawal + uint256 public accruedFees; + /// @notice Mapping of whitelisted admin addresses mapping(address => bool) public whitelistedAdmins; @@ -58,6 +61,21 @@ contract IntuitionFeeProxy { string operation ); + /// @notice Emitted when accrued fees are withdrawn to the fee recipient + event FeesWithdrawn( + address indexed caller, + address indexed recipient, + uint256 amount, + uint256 remainingAccruedFees + ); + + /// @notice Emitted when non-fee native token balance is swept by an admin + event NonFeeBalanceSwept( + address indexed admin, + address indexed recipient, + uint256 amount + ); + /// @notice Emitted when a transaction is forwarded to MultiVault (debug) event TransactionForwarded( string operation, @@ -83,6 +101,14 @@ contract IntuitionFeeProxy { _; } + /// @notice Restricts fee withdrawals to the fee recipient or whitelisted admins + modifier onlyFeeWithdrawer() { + if (msg.sender != feeRecipient && !whitelistedAdmins[msg.sender]) { + revert Errors.IntuitionFeeProxy_NotFeeWithdrawer(); + } + _; + } + // ============ Constructor ============ /// @notice Initializes the IntuitionFeeProxy contract @@ -193,6 +219,45 @@ contract IntuitionFeeProxy { emit AdminWhitelistUpdated(admin, status); } + /// @notice Withdraw accrued proxy fees to the configured fee recipient + /// @param amount Amount of accrued fees to withdraw + function withdrawFees(uint256 amount) public onlyFeeWithdrawer { + _withdrawFees(amount); + } + + /// @notice Withdraw all accrued proxy fees to the configured fee recipient + function withdrawAllFees() external onlyFeeWithdrawer { + _withdrawFees(accruedFees); + } + + /// @notice Returns native token balance not accounted for as accrued proxy fees + /// @dev This may include direct transfers, accidental sends, or future refunds. + function getNonFeeBalance() public view returns (uint256) { + uint256 currentBalance = address(this).balance; + if (currentBalance <= accruedFees) { + return 0; + } + return currentBalance - accruedFees; + } + + /// @notice Sweep native token balance that was not accrued as proxy fees + /// @param recipient Address that receives the swept non-fee balance + /// @param amount Amount of non-fee native token to sweep + function sweepNonFeeBalance(address recipient, uint256 amount) external onlyWhitelistedAdmin { + if (recipient == address(0)) { + revert Errors.IntuitionFeeProxy_ZeroAddress(); + } + if (amount == 0) { + revert Errors.IntuitionFeeProxy_ZeroAmount(); + } + if (amount > getNonFeeBalance()) { + revert Errors.IntuitionFeeProxy_InsufficientNonFeeBalance(); + } + + _sendNative(recipient, amount); + emit NonFeeBalanceSwept(msg.sender, recipient, amount); + } + // ============ Proxy Functions (Payable) ============ /// @notice Create atoms with fee collection and deposit to receiver @@ -229,7 +294,7 @@ contract IntuitionFeeProxy { revert Errors.IntuitionFeeProxy_InsufficientValue(); } - _transferFee(fee); + _collectFee(fee); emit FeesCollected(msg.sender, fee, "createAtoms"); emit TransactionForwarded("createAtoms", msg.sender, fee, multiVaultCost, msg.value); @@ -296,7 +361,7 @@ contract IntuitionFeeProxy { revert Errors.IntuitionFeeProxy_InsufficientValue(); } - _transferFee(fee); + _collectFee(fee); emit FeesCollected(msg.sender, fee, "createTriples"); emit TransactionForwarded("createTriples", msg.sender, fee, multiVaultCost, msg.value); @@ -354,7 +419,7 @@ contract IntuitionFeeProxy { / (FEE_DENOMINATOR + depositPercentageFee); uint256 fee = msg.value - multiVaultAmount; - _transferFee(fee); + _collectFee(fee); emit FeesCollected(msg.sender, fee, "deposit"); emit TransactionForwarded("deposit", msg.sender, fee, multiVaultAmount, msg.value); @@ -407,7 +472,7 @@ contract IntuitionFeeProxy { revert Errors.IntuitionFeeProxy_InsufficientValue(); } - _transferFee(fee); + _collectFee(fee); emit FeesCollected(msg.sender, fee, "depositBatch"); emit TransactionForwarded("depositBatch", msg.sender, fee, totalDeposit, msg.value); @@ -488,11 +553,39 @@ contract IntuitionFeeProxy { } } - /// @notice Transfer collected fees to recipient + /// @notice Track collected proxy fees for later withdrawal + /// @param amount Amount to accrue + function _collectFee(uint256 amount) internal { + if (amount > 0) { + accruedFees += amount; + } + } + + /// @notice Withdraw accrued fees to the configured fee recipient + /// @param amount Amount to withdraw + function _withdrawFees(uint256 amount) internal { + if (feeRecipient == address(0)) { + revert Errors.IntuitionFeeProxy_ZeroAddress(); + } + if (amount == 0) { + revert Errors.IntuitionFeeProxy_ZeroAmount(); + } + if (amount > accruedFees) { + revert Errors.IntuitionFeeProxy_InsufficientAccruedFees(); + } + + accruedFees -= amount; + _sendNative(feeRecipient, amount); + + emit FeesWithdrawn(msg.sender, feeRecipient, amount, accruedFees); + } + + /// @notice Transfer native token to a recipient + /// @param recipient Address that receives the native token /// @param amount Amount to transfer - function _transferFee(uint256 amount) internal { + function _sendNative(address recipient, uint256 amount) internal { if (amount > 0) { - (bool success, ) = feeRecipient.call{value: amount}(""); + (bool success, ) = recipient.call{value: amount}(""); if (!success) { revert Errors.IntuitionFeeProxy_TransferFailed(); } @@ -519,6 +612,6 @@ contract IntuitionFeeProxy { } } - /// @notice Receive function to accept ETH (for refunds) + /// @notice Receive function to accept native TRUST/tTRUST (for refunds or direct transfers) receive() external payable {} } diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index 6f76bab..9f15cf6 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -7,7 +7,7 @@ library Errors { /// @notice Caller is not a whitelisted admin error IntuitionFeeProxy_NotWhitelistedAdmin(); - /// @notice Insufficient ETH value sent with transaction + /// @notice Insufficient native token value sent with transaction error IntuitionFeeProxy_InsufficientValue(); /// @notice Invalid multisig address (zero address) @@ -16,7 +16,7 @@ library Errors { /// @notice Invalid MultiVault address (zero address) error IntuitionFeeProxy_InvalidMultiVaultAddress(); - /// @notice ETH transfer to fee recipient failed + /// @notice Native token transfer failed error IntuitionFeeProxy_TransferFailed(); /// @notice Array lengths do not match @@ -30,4 +30,16 @@ library Errors { /// @notice Receiver must match the caller for user-facing proxy flows error IntuitionFeeProxy_InvalidReceiver(); + + /// @notice Caller cannot withdraw accrued fees + error IntuitionFeeProxy_NotFeeWithdrawer(); + + /// @notice Withdrawal or sweep amount cannot be zero + error IntuitionFeeProxy_ZeroAmount(); + + /// @notice Requested fee withdrawal exceeds accrued fees + error IntuitionFeeProxy_InsufficientAccruedFees(); + + /// @notice Requested sweep exceeds non-fee native token balance + error IntuitionFeeProxy_InsufficientNonFeeBalance(); } diff --git a/src/test/RejectNativeTransfer.sol b/src/test/RejectNativeTransfer.sol new file mode 100644 index 0000000..abd83bf --- /dev/null +++ b/src/test/RejectNativeTransfer.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +/// @title RejectNativeTransfer +/// @notice Test helper that rejects native token transfers. +contract RejectNativeTransfer { + receive() external payable { + revert("NATIVE_TRANSFER_REJECTED"); + } +} diff --git a/test/IntuitionFeeProxy.test.ts b/test/IntuitionFeeProxy.test.ts index 85fc675..4b652cb 100644 --- a/test/IntuitionFeeProxy.test.ts +++ b/test/IntuitionFeeProxy.test.ts @@ -210,6 +210,191 @@ describe("IntuitionFeeProxy", function () { }); }); + describe("Fee Withdrawal", function () { + it("Should accrue fees instead of forwarding them immediately", async function () { + const { proxy, user } = await loadFixture(deployFixture); + + const desiredDepositAmount = ethers.parseEther("10"); + const totalToSend = await proxy.getTotalDepositCost(desiredDepositAmount); + const expectedFee = await proxy.calculateDepositFee(1n, desiredDepositAmount); + const termId = ethers.zeroPadValue("0x01", 32); + + const initialFeeRecipientBalance = await ethers.provider.getBalance(FEE_RECIPIENT); + + await expect(proxy.connect(user).deposit(user.address, termId, 1n, 0n, { value: totalToSend })) + .to.emit(proxy, "FeesCollected") + .withArgs(user.address, expectedFee, "deposit"); + + expect(await ethers.provider.getBalance(FEE_RECIPIENT)).to.equal(initialFeeRecipientBalance); + expect(await proxy.accruedFees()).to.equal(expectedFee); + expect(await ethers.provider.getBalance(await proxy.getAddress())).to.equal(expectedFee); + }); + + it("Should allow a whitelisted admin to partially withdraw accrued fees to feeRecipient", async function () { + const { proxy, user, admin1 } = await loadFixture(deployFixture); + + const desiredDepositAmount = ethers.parseEther("10"); + const totalToSend = await proxy.getTotalDepositCost(desiredDepositAmount); + const expectedFee = await proxy.calculateDepositFee(1n, desiredDepositAmount); + const withdrawalAmount = expectedFee / 2n; + const termId = ethers.zeroPadValue("0x01", 32); + + await proxy.connect(user).deposit(user.address, termId, 1n, 0n, { value: totalToSend }); + + const initialFeeRecipientBalance = await ethers.provider.getBalance(FEE_RECIPIENT); + + await expect(proxy.connect(admin1).withdrawFees(withdrawalAmount)) + .to.emit(proxy, "FeesWithdrawn") + .withArgs(admin1.address, FEE_RECIPIENT, withdrawalAmount, expectedFee - withdrawalAmount); + + expect(await ethers.provider.getBalance(FEE_RECIPIENT)).to.equal(initialFeeRecipientBalance + withdrawalAmount); + expect(await proxy.accruedFees()).to.equal(expectedFee - withdrawalAmount); + }); + + it("Should allow a whitelisted admin to withdraw all accrued fees to feeRecipient", async function () { + const { proxy, user, admin1 } = await loadFixture(deployFixture); + + const desiredDepositAmount = ethers.parseEther("2"); + const totalToSend = await proxy.getTotalDepositCost(desiredDepositAmount); + const expectedFee = await proxy.calculateDepositFee(1n, desiredDepositAmount); + const termId = ethers.zeroPadValue("0x01", 32); + + await proxy.connect(user).deposit(user.address, termId, 1n, 0n, { value: totalToSend }); + + const initialFeeRecipientBalance = await ethers.provider.getBalance(FEE_RECIPIENT); + + await expect(proxy.connect(admin1).withdrawAllFees()) + .to.emit(proxy, "FeesWithdrawn") + .withArgs(admin1.address, FEE_RECIPIENT, expectedFee, 0n); + + expect(await ethers.provider.getBalance(FEE_RECIPIENT)).to.equal(initialFeeRecipientBalance + expectedFee); + expect(await proxy.accruedFees()).to.equal(0n); + }); + + it("Should allow the fee recipient to pull accrued fees to itself", async function () { + const { proxy, user, admin1 } = await loadFixture(deployFixture); + + await proxy.connect(admin1).setFeeRecipient(user.address); + + const desiredDepositAmount = ethers.parseEther("3"); + const totalToSend = await proxy.getTotalDepositCost(desiredDepositAmount); + const expectedFee = await proxy.calculateDepositFee(1n, desiredDepositAmount); + const termId = ethers.zeroPadValue("0x01", 32); + + await proxy.connect(user).deposit(user.address, termId, 1n, 0n, { value: totalToSend }); + + await expect(proxy.connect(user).withdrawAllFees()) + .to.emit(proxy, "FeesWithdrawn") + .withArgs(user.address, user.address, expectedFee, 0n); + + expect(await proxy.accruedFees()).to.equal(0n); + }); + + it("Should prevent unauthorized callers from withdrawing fees", async function () { + const { proxy, user, nonAdmin } = await loadFixture(deployFixture); + + const desiredDepositAmount = ethers.parseEther("1"); + const totalToSend = await proxy.getTotalDepositCost(desiredDepositAmount); + const termId = ethers.zeroPadValue("0x01", 32); + + await proxy.connect(user).deposit(user.address, termId, 1n, 0n, { value: totalToSend }); + + await expect(proxy.connect(nonAdmin).withdrawFees(1n)) + .to.be.revertedWithCustomError(proxy, "IntuitionFeeProxy_NotFeeWithdrawer"); + }); + + it("Should revert when withdrawing zero or more than accrued fees", async function () { + const { proxy, user, admin1 } = await loadFixture(deployFixture); + + const desiredDepositAmount = ethers.parseEther("1"); + const totalToSend = await proxy.getTotalDepositCost(desiredDepositAmount); + const expectedFee = await proxy.calculateDepositFee(1n, desiredDepositAmount); + const termId = ethers.zeroPadValue("0x01", 32); + + await proxy.connect(user).deposit(user.address, termId, 1n, 0n, { value: totalToSend }); + + await expect(proxy.connect(admin1).withdrawFees(0n)) + .to.be.revertedWithCustomError(proxy, "IntuitionFeeProxy_ZeroAmount"); + + await expect(proxy.connect(admin1).withdrawFees(expectedFee + 1n)) + .to.be.revertedWithCustomError(proxy, "IntuitionFeeProxy_InsufficientAccruedFees"); + }); + + it("Should revert when fee recipient rejects native token transfers", async function () { + const { proxy, user, admin1 } = await loadFixture(deployFixture); + + const RejectNativeTransferFactory = await ethers.getContractFactory("RejectNativeTransfer"); + const rejectingRecipient = await RejectNativeTransferFactory.deploy(); + await rejectingRecipient.waitForDeployment(); + + await proxy.connect(admin1).setFeeRecipient(await rejectingRecipient.getAddress()); + + const desiredDepositAmount = ethers.parseEther("1"); + const totalToSend = await proxy.getTotalDepositCost(desiredDepositAmount); + const termId = ethers.zeroPadValue("0x01", 32); + + await proxy.connect(user).deposit(user.address, termId, 1n, 0n, { value: totalToSend }); + + await expect(proxy.connect(admin1).withdrawAllFees()) + .to.be.revertedWithCustomError(proxy, "IntuitionFeeProxy_TransferFailed"); + }); + + it("Should not allow non-fee native token balance to be withdrawn as fees", async function () { + const { proxy, user, admin1 } = await loadFixture(deployFixture); + + const nonFeeAmount = ethers.parseEther("1"); + await user.sendTransaction({ + to: await proxy.getAddress(), + value: nonFeeAmount, + }); + + expect(await proxy.accruedFees()).to.equal(0n); + expect(await proxy.getNonFeeBalance()).to.equal(nonFeeAmount); + + await expect(proxy.connect(admin1).withdrawFees(nonFeeAmount)) + .to.be.revertedWithCustomError(proxy, "IntuitionFeeProxy_InsufficientAccruedFees"); + }); + + it("Should allow admins to sweep only non-fee native token balance", async function () { + const { proxy, user, admin1, nonAdmin } = await loadFixture(deployFixture); + + const desiredDepositAmount = ethers.parseEther("1"); + const totalToSend = await proxy.getTotalDepositCost(desiredDepositAmount); + const accruedFee = await proxy.calculateDepositFee(1n, desiredDepositAmount); + const termId = ethers.zeroPadValue("0x01", 32); + + await proxy.connect(user).deposit(user.address, termId, 1n, 0n, { value: totalToSend }); + + const nonFeeAmount = ethers.parseEther("0.25"); + await user.sendTransaction({ + to: await proxy.getAddress(), + value: nonFeeAmount, + }); + + await expect(proxy.connect(nonAdmin).sweepNonFeeBalance(nonAdmin.address, 1n)) + .to.be.revertedWithCustomError(proxy, "IntuitionFeeProxy_NotWhitelistedAdmin"); + + await expect(proxy.connect(admin1).sweepNonFeeBalance(ethers.ZeroAddress, 1n)) + .to.be.revertedWithCustomError(proxy, "IntuitionFeeProxy_ZeroAddress"); + + await expect(proxy.connect(admin1).sweepNonFeeBalance(nonAdmin.address, 0n)) + .to.be.revertedWithCustomError(proxy, "IntuitionFeeProxy_ZeroAmount"); + + await expect(proxy.connect(admin1).sweepNonFeeBalance(nonAdmin.address, nonFeeAmount + 1n)) + .to.be.revertedWithCustomError(proxy, "IntuitionFeeProxy_InsufficientNonFeeBalance"); + + const initialRecipientBalance = await ethers.provider.getBalance(nonAdmin.address); + + await expect(proxy.connect(admin1).sweepNonFeeBalance(nonAdmin.address, nonFeeAmount)) + .to.emit(proxy, "NonFeeBalanceSwept") + .withArgs(admin1.address, nonAdmin.address, nonFeeAmount); + + expect(await ethers.provider.getBalance(nonAdmin.address)).to.equal(initialRecipientBalance + nonFeeAmount); + expect(await proxy.accruedFees()).to.equal(accruedFee); + expect(await proxy.getNonFeeBalance()).to.equal(0n); + }); + }); + describe("Receiver Validation", function () { it("Should revert when createAtoms receiver is not msg.sender", async function () { const { proxy, user, nonAdmin } = await loadFixture(deployFixture); @@ -311,14 +496,11 @@ describe("IntuitionFeeProxy", function () { const multiVaultCost = (atomCost * 2n) + totalDeposit; const totalRequired = fee + multiVaultCost; - const initialBalance = await ethers.provider.getBalance(FEE_RECIPIENT); - await expect(proxy.connect(user).createAtoms(user.address, data, assets, curveId, { value: totalRequired })) .to.emit(proxy, "FeesCollected") .withArgs(user.address, fee, "createAtoms"); - const finalBalance = await ethers.provider.getBalance(FEE_RECIPIENT); - expect(finalBalance - initialBalance).to.equal(fee); + expect(await proxy.accruedFees()).to.equal(fee); }); it("Should not charge fees for zero deposits in createAtoms", async function () { @@ -333,12 +515,9 @@ describe("IntuitionFeeProxy", function () { const multiVaultCost = atomCost * 2n; const totalRequired = fee + multiVaultCost; - const initialBalance = await ethers.provider.getBalance(FEE_RECIPIENT); - await proxy.connect(user).createAtoms(user.address, data, assets, curveId, { value: totalRequired }); - const finalBalance = await ethers.provider.getBalance(FEE_RECIPIENT); - expect(finalBalance - initialBalance).to.equal(0n); + expect(await proxy.accruedFees()).to.equal(0n); }); it("Should revert on insufficient value for createAtoms", async function () { @@ -371,14 +550,11 @@ describe("IntuitionFeeProxy", function () { const multiVaultCost = tripleCost + totalDeposit; const totalRequired = fee + multiVaultCost; - const initialBalance = await ethers.provider.getBalance(FEE_RECIPIENT); - await expect(proxy.connect(user).createTriples(user.address, subjectIds, predicateIds, objectIds, assets, curveId, { value: totalRequired })) .to.emit(proxy, "FeesCollected") .withArgs(user.address, fee, "createTriples"); - const finalBalance = await ethers.provider.getBalance(FEE_RECIPIENT); - expect(finalBalance - initialBalance).to.equal(fee); + expect(await proxy.accruedFees()).to.equal(fee); }); it("Should revert on wrong array lengths", async function () { @@ -403,17 +579,13 @@ describe("IntuitionFeeProxy", function () { const desiredDepositAmount = ethers.parseEther("10"); const totalToSend = await proxy.getTotalDepositCost(desiredDepositAmount); - const initialBalance = await ethers.provider.getBalance(FEE_RECIPIENT); - const termId = ethers.zeroPadValue("0x01", 32); await expect(proxy.connect(user).deposit(user.address, termId, 1n, 0n, { value: totalToSend })) .to.emit(proxy, "FeesCollected"); - const finalBalance = await ethers.provider.getBalance(FEE_RECIPIENT); - const collectedFee = finalBalance - initialBalance; const expectedFee = await proxy.calculateDepositFee(1n, desiredDepositAmount); - expect(collectedFee).to.be.closeTo(expectedFee, 1); + expect(await proxy.accruedFees()).to.be.closeTo(expectedFee, 1); }); it("Should calculate multiVaultAmount correctly", async function () { @@ -460,14 +632,11 @@ describe("IntuitionFeeProxy", function () { const fee = await proxy.calculateDepositFee(2n, totalDeposit); const totalRequired = totalDeposit + fee; - const initialBalance = await ethers.provider.getBalance(FEE_RECIPIENT); - await expect(proxy.connect(user).depositBatch(user.address, termIds, curveIds, assets, minShares, { value: totalRequired })) .to.emit(proxy, "FeesCollected") .withArgs(user.address, fee, "depositBatch"); - const finalBalance = await ethers.provider.getBalance(FEE_RECIPIENT); - expect(finalBalance - initialBalance).to.equal(fee); + expect(await proxy.accruedFees()).to.equal(fee); }); it("Should revert on wrong array lengths in depositBatch", async function () { From 14c82572186f92723c725271e2034de7824f2078 Mon Sep 17 00:00:00 2001 From: giantcoconut Date: Sat, 2 May 2026 11:59:16 +0100 Subject: [PATCH 3/4] Add versioned upgradeable fee proxy (2B) --- README.md | 35 +++- scripts/deploy.ts | 54 ++++--- src/ERC7936Proxy.sol | 268 +++++++++++++++++++++++++++++++ src/IntuitionFeeProxy.sol | 44 ++++- src/libraries/Errors.sol | 24 +++ src/test/IntuitionFeeProxyV2.sol | 12 ++ test/IntuitionFeeProxy.test.ts | 235 +++++++++++++++++++++++++-- 7 files changed, 629 insertions(+), 43 deletions(-) create mode 100644 src/ERC7936Proxy.sol create mode 100644 src/test/IntuitionFeeProxyV2.sol diff --git a/README.md b/README.md index b3fe5e1..e1c609f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A customizable proxy contract for the [Intuition](https://intuition.systems) Mul - **Deposit-based fees**: Fixed fee per deposit + percentage fee on deposit amounts - **Admin system**: Whitelisted admins can update fees and settings +- **Versioned upgrades**: ERC-7936-style proxy with registered implementations and a default version - **Receiver pattern**: Shares are deposited directly to users (requires approval) - **Full MultiVault compatibility**: All view functions pass through to MultiVault @@ -85,6 +86,9 @@ npm test ### 4. Deploy +The deploy script creates an `IntuitionFeeProxy` implementation and an `ERC7936Proxy`. +Use the proxy address in frontend integrations. + **Testnet (recommended first):** ```bash npx hardhat run scripts/deploy.ts --network intuition-testnet @@ -159,6 +163,22 @@ setDepositFixedFee(newFee) setDepositPercentageFee(newFee) setFeeRecipient(newRecipient) setWhitelistedAdmin(admin, status) +withdrawFees(amount) +withdrawAllFees() +sweepNonFeeBalance(recipient, amount) +``` + +### Versioned Proxy Functions + +```solidity +registerVersion(version, implementation) +removeVersion(version) +setDefaultVersion(version) +getImplementation(version) +getDefaultVersion() +getVersions() +executeAtVersion(version, data) +upgradeToVersion(version, implementation, migrationData) ``` ### View Functions @@ -214,7 +234,20 @@ await proxy.createTriples( 1. **Fee recipient chain**: Ensure `FEE_RECIPIENT` is an address you control on Intuition Network 2. **Admin keys**: Securely store admin private keys 3. **Fee limits**: Consider implementing maximum fee limits for user trust -4. **Upgrades**: This contract is not upgradeable - deploy a new version if needed +4. **Upgrades**: Upgrade through the ERC-7936 proxy by registering a new implementation version and setting it as the default +5. **Fee accounting**: Fee withdrawals use explicit accrued-fee accounting; direct native TRUST/tTRUST sent to the proxy is separate non-fee balance + +## Migration From V1 + +Existing V1 deployments were standalone contracts, not proxy deployments, so they cannot be upgraded in place with `upgradeToAndCall`. + +Recommended migration path: + +1. Read the existing V1 configuration: MultiVault, fee recipient, fixed fee, percentage fee, and admins. +2. Deploy the V2 `IntuitionFeeProxy` implementation. +3. Deploy a new `ERC7936Proxy` with version `v1` and initializer data matching the desired configuration. +4. Update frontend/app configuration to use the new proxy address. +5. For future changes, deploy a new implementation, register a new version, and set it as the default version through the ERC-7936 proxy. ## License diff --git a/scripts/deploy.ts b/scripts/deploy.ts index a995c1a..701a14c 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -56,23 +56,41 @@ async function main() { console.log("- Deposit fixed fee:", ethers.formatEther(DEPOSIT_FIXED_FEE), "TRUST"); console.log("- Deposit percentage:", Number(DEPOSIT_PERCENTAGE) / 100, "%"); - // Deploy IntuitionFeeProxy - console.log("\nDeploying IntuitionFeeProxy..."); + // Deploy implementation and ERC-7936 proxy + console.log("\nDeploying IntuitionFeeProxy implementation..."); const IntuitionFeeProxy = await ethers.getContractFactory("IntuitionFeeProxy"); - const proxy = await IntuitionFeeProxy.deploy( + const implementation = await IntuitionFeeProxy.deploy(); + await implementation.waitForDeployment(); + const implementationAddress = await implementation.getAddress(); + + const initializationData = IntuitionFeeProxy.interface.encodeFunctionData("initialize", [ multiVault, FEE_RECIPIENT, DEPOSIT_FIXED_FEE, DEPOSIT_PERCENTAGE, - admins + admins, + ]); + + const initialVersion = ethers.encodeBytes32String("v1"); + console.log("Implementation address:", implementationAddress); + console.log("\nDeploying ERC7936Proxy..."); + + const ERC7936Proxy = await ethers.getContractFactory("ERC7936Proxy"); + const proxy = await ERC7936Proxy.deploy( + admin1, + initialVersion, + implementationAddress, + initializationData ); await proxy.waitForDeployment(); const proxyAddress = await proxy.getAddress(); console.log("\n========================================"); - console.log("IntuitionFeeProxy deployed successfully!"); - console.log("Contract address:", proxyAddress); + console.log("IntuitionFeeProxy V2 deployed successfully!"); + console.log("Implementation address:", implementationAddress); + console.log("ERC-7936 proxy address:", proxyAddress); + console.log("Initial version:", ethers.decodeBytes32String(initialVersion)); console.log("========================================"); // Verify contract on explorer (if not local) @@ -83,20 +101,18 @@ async function main() { await deployTx.wait(5); } - console.log("Verifying contract on explorer..."); + console.log("Verifying contracts on explorer..."); try { const { run } = await import("hardhat"); + await run("verify:verify", { + address: implementationAddress, + constructorArguments: [], + }); await run("verify:verify", { address: proxyAddress, - constructorArguments: [ - multiVault, - FEE_RECIPIENT, - DEPOSIT_FIXED_FEE, - DEPOSIT_PERCENTAGE, - admins, - ], + constructorArguments: [admin1, initialVersion, implementationAddress, initializationData], }); - console.log("Contract verified successfully!"); + console.log("Contracts verified successfully!"); } catch (error: any) { if (error.message.includes("Already Verified")) { console.log("Contract already verified"); @@ -107,16 +123,16 @@ async function main() { } console.log("\nNext steps:"); - console.log("1. Save this contract address in your frontend config"); + console.log("1. Save the ERC-7936 proxy address in your frontend config"); console.log("2. Users must approve the proxy on MultiVault before using it:"); console.log(` multiVault.approve("${proxyAddress}", 1) // 1 = DEPOSIT approval`); - console.log("3. Update your frontend to call proxy functions instead of MultiVault directly"); + console.log("3. Future upgrades should register a new version and set it as default through ERC7936Proxy"); - return proxyAddress; + return { implementationAddress, proxyAddress }; } main() - .then((address) => { + .then(({ proxyAddress }) => { console.log("\nDeployment complete!"); process.exit(0); }) diff --git a/src/ERC7936Proxy.sol b/src/ERC7936Proxy.sol new file mode 100644 index 0000000..733b3c6 --- /dev/null +++ b/src/ERC7936Proxy.sol @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +import {Errors} from "./libraries/Errors.sol"; + +interface IERC1822Proxiable { + function proxiableUUID() external view returns (bytes32); +} + +contract ERC7936Proxy { + bytes32 private constant ERC1967_IMPLEMENTATION_SLOT = + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + bytes32 private constant ERC7936_STORAGE_SLOT = + 0xb51a3a74895f87adda83c43536dba88c918805589a16018c9c4a81c2ec341d55; + + struct ERC7936Storage { + address admin; + bytes32 defaultVersion; + bytes32[] versions; + mapping(bytes32 => address) implementations; + mapping(bytes32 => uint256) versionIndexes; + } + + event VersionedProxyAdminTransferred(address indexed oldAdmin, address indexed newAdmin); + + event VersionRegistered(bytes32 indexed version, address indexed implementation); + + event VersionRemoved(bytes32 indexed version); + + event DefaultVersionChanged(bytes32 indexed oldVersion, bytes32 indexed newVersion); + + event Upgraded(address indexed implementation); + + modifier onlyVersionedProxyAdmin() { + if (msg.sender != _getERC7936Storage().admin) { + revert Errors.IntuitionFeeProxy_NotVersionedProxyAdmin(); + } + _; + } + + constructor( + address initialAdmin, + bytes32 initialVersion, + address initialImplementation, + bytes memory initializationData + ) payable { + if (initialAdmin == address(0)) { + revert Errors.IntuitionFeeProxy_ZeroAddress(); + } + + ERC7936Storage storage $ = _getERC7936Storage(); + $.admin = initialAdmin; + emit VersionedProxyAdminTransferred(address(0), initialAdmin); + + _registerVersion($, initialVersion, initialImplementation); + _setDefaultVersion($, initialVersion, initializationData); + } + + function versionedProxyAdmin() external view returns (address) { + return _getERC7936Storage().admin; + } + + function transferVersionedProxyAdmin(address newAdmin) external onlyVersionedProxyAdmin { + if (newAdmin == address(0)) { + revert Errors.IntuitionFeeProxy_ZeroAddress(); + } + + ERC7936Storage storage $ = _getERC7936Storage(); + address oldAdmin = $.admin; + $.admin = newAdmin; + emit VersionedProxyAdminTransferred(oldAdmin, newAdmin); + } + + function registerVersion(bytes32 version, address implementation) external onlyVersionedProxyAdmin { + _registerVersion(_getERC7936Storage(), version, implementation); + } + + function removeVersion(bytes32 version) external onlyVersionedProxyAdmin { + ERC7936Storage storage $ = _getERC7936Storage(); + if ($.versionIndexes[version] == 0) { + revert Errors.IntuitionFeeProxy_VersionNotRegistered(); + } + if (version == $.defaultVersion) { + revert Errors.IntuitionFeeProxy_CannotRemoveDefaultVersion(); + } + + uint256 index = $.versionIndexes[version] - 1; + uint256 lastIndex = $.versions.length - 1; + if (index != lastIndex) { + bytes32 lastVersion = $.versions[lastIndex]; + $.versions[index] = lastVersion; + $.versionIndexes[lastVersion] = index + 1; + } + + $.versions.pop(); + delete $.versionIndexes[version]; + delete $.implementations[version]; + + emit VersionRemoved(version); + } + + function setDefaultVersion(bytes32 version) external onlyVersionedProxyAdmin { + _setDefaultVersion(_getERC7936Storage(), version, ""); + } + + function upgradeToVersion( + bytes32 version, + address implementation, + bytes calldata migrationData + ) external payable onlyVersionedProxyAdmin { + ERC7936Storage storage $ = _getERC7936Storage(); + + if ($.versionIndexes[version] == 0) { + _registerVersion($, version, implementation); + } else if ($.implementations[version] != implementation) { + revert Errors.IntuitionFeeProxy_VersionAlreadyRegistered(); + } + + _setDefaultVersion($, version, migrationData); + } + + function getImplementation(bytes32 version) external view returns (address) { + return _requireRegisteredVersion(_getERC7936Storage(), version); + } + + function getActiveImplementation() external view returns (address) { + return _getImplementation(); + } + + function getDefaultVersion() external view returns (bytes32) { + return _getERC7936Storage().defaultVersion; + } + + function getVersions() external view returns (bytes32[] memory) { + return _getERC7936Storage().versions; + } + + function executeAtVersion(bytes32 version, bytes calldata data) external payable returns (bytes memory) { + address implementation = _requireRegisteredVersion(_getERC7936Storage(), version); + + (bool success, bytes memory returndata) = implementation.delegatecall(data); + if (!success) { + assembly ("memory-safe") { + revert(add(returndata, 0x20), mload(returndata)) + } + } + + return returndata; + } + + fallback() external payable { + _delegate(_getImplementation()); + } + + receive() external payable { + _delegate(_getImplementation()); + } + + function _registerVersion( + ERC7936Storage storage $, + bytes32 version, + address implementation + ) internal { + if (version == bytes32(0)) { + revert Errors.IntuitionFeeProxy_InvalidVersion(); + } + if (implementation.code.length == 0) { + revert Errors.IntuitionFeeProxy_InvalidImplementation(); + } + if ($.versionIndexes[version] != 0) { + revert Errors.IntuitionFeeProxy_VersionAlreadyRegistered(); + } + + try IERC1822Proxiable(implementation).proxiableUUID() returns (bytes32 slot) { + if (slot != ERC1967_IMPLEMENTATION_SLOT) { + revert Errors.IntuitionFeeProxy_UnsupportedProxiableUUID(slot); + } + } catch { + revert Errors.IntuitionFeeProxy_InvalidImplementation(); + } + + $.implementations[version] = implementation; + $.versions.push(version); + $.versionIndexes[version] = $.versions.length; + + emit VersionRegistered(version, implementation); + } + + function _setDefaultVersion( + ERC7936Storage storage $, + bytes32 version, + bytes memory data + ) internal { + address implementation = _requireRegisteredVersion($, version); + bytes32 oldVersion = $.defaultVersion; + $.defaultVersion = version; + + _upgradeToAndCall(implementation, data); + emit DefaultVersionChanged(oldVersion, version); + } + + function _requireRegisteredVersion( + ERC7936Storage storage $, + bytes32 version + ) internal view returns (address implementation) { + implementation = $.implementations[version]; + if (implementation == address(0) || $.versionIndexes[version] == 0) { + revert Errors.IntuitionFeeProxy_VersionNotRegistered(); + } + } + + function _getERC7936Storage() private pure returns (ERC7936Storage storage $) { + assembly ("memory-safe") { + $.slot := ERC7936_STORAGE_SLOT + } + } + + function _getImplementation() private view returns (address implementation) { + bytes32 slot = ERC1967_IMPLEMENTATION_SLOT; + assembly ("memory-safe") { + implementation := sload(slot) + } + } + + function _setImplementation(address implementation) private { + if (implementation.code.length == 0) { + revert Errors.IntuitionFeeProxy_InvalidImplementation(); + } + + bytes32 slot = ERC1967_IMPLEMENTATION_SLOT; + assembly ("memory-safe") { + sstore(slot, implementation) + } + } + + function _upgradeToAndCall(address implementation, bytes memory data) private { + _setImplementation(implementation); + emit Upgraded(implementation); + + if (data.length > 0) { + (bool success, bytes memory returndata) = implementation.delegatecall(data); + if (!success) { + assembly ("memory-safe") { + revert(add(returndata, 0x20), mload(returndata)) + } + } + } else if (msg.value > 0) { + revert Errors.IntuitionFeeProxy_InsufficientValue(); + } + } + + function _delegate(address implementation) private { + assembly { + calldatacopy(0x00, 0x00, calldatasize()) + let result := delegatecall(gas(), implementation, 0x00, calldatasize(), 0x00, 0x00) + returndatacopy(0x00, 0x00, returndatasize()) + + switch result + case 0 { + revert(0x00, returndatasize()) + } + default { + return(0x00, returndatasize()) + } + } + } +} diff --git a/src/IntuitionFeeProxy.sol b/src/IntuitionFeeProxy.sol index 40a5611..b7fa4ff 100644 --- a/src/IntuitionFeeProxy.sol +++ b/src/IntuitionFeeProxy.sol @@ -16,10 +16,16 @@ contract IntuitionFeeProxy { /// @notice Maximum allowed fee percentage (100%) uint256 public constant MAX_FEE_PERCENTAGE = 10000; - // ============ Immutables ============ + /// @notice ERC-1822 UUID for the ERC-1967 implementation slot + bytes32 public constant PROXIABLE_UUID = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; /// @notice Reference to the Intuition MultiVault contract - IEthMultiVault public immutable ethMultiVault; + IEthMultiVault public ethMultiVault; + + // ============ Immutables ============ + + /// @notice Implementation self-address for ERC-1822 proxy-context protection + address private immutable __self = address(this); // ============ State Variables ============ @@ -40,6 +46,9 @@ contract IntuitionFeeProxy { /// @notice Mapping of whitelisted admin addresses mapping(address => bool) public whitelistedAdmins; + /// @notice Initialization guard for proxy deployments + bool private _initialized; + // ============ Events ============ /// @notice Emitted when fee recipient is updated @@ -109,21 +118,35 @@ contract IntuitionFeeProxy { _; } - // ============ Constructor ============ + /// @notice Allows a function to run only once + modifier initializer() { + if (_initialized) { + revert Errors.IntuitionFeeProxy_AlreadyInitialized(); + } + _initialized = true; + _; + } - /// @notice Initializes the IntuitionFeeProxy contract + // ============ Constructor & Initializer ============ + + /// @notice Disables initialization on the implementation contract + constructor() { + _initialized = true; + } + + /// @notice Initializes the IntuitionFeeProxy contract through a proxy /// @param _ethMultiVault Address of the Intuition MultiVault contract /// @param _feeRecipient Address to receive collected fees /// @param _depositFixedFee Initial fixed fee per deposit (in wei) /// @param _depositPercentageFee Initial percentage fee for deposits (base 10000) /// @param _initialAdmins Array of initial admin addresses to whitelist - constructor( + function initialize( address _ethMultiVault, address _feeRecipient, uint256 _depositFixedFee, uint256 _depositPercentageFee, address[] memory _initialAdmins - ) { + ) external initializer { if (_ethMultiVault == address(0)) { revert Errors.IntuitionFeeProxy_InvalidMultiVaultAddress(); } @@ -273,7 +296,7 @@ contract IntuitionFeeProxy { uint256[] calldata assets, uint256 curveId ) external payable returns (bytes32[] memory atomIds) { - _validateReceiver(receiver); + _validateReceiver(receiver); if (data.length != assets.length) { revert Errors.IntuitionFeeProxy_WrongArrayLengths(); @@ -553,6 +576,13 @@ contract IntuitionFeeProxy { } } + function proxiableUUID() external view returns (bytes32) { + if (address(this) != __self) { + revert Errors.IntuitionFeeProxy_InvalidImplementation(); + } + return PROXIABLE_UUID; + } + /// @notice Track collected proxy fees for later withdrawal /// @param amount Amount to accrue function _collectFee(uint256 amount) internal { diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index 9f15cf6..68d6023 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -42,4 +42,28 @@ library Errors { /// @notice Requested sweep exceeds non-fee native token balance error IntuitionFeeProxy_InsufficientNonFeeBalance(); + + /// @notice Caller cannot manage ERC-7936 proxy versions + error IntuitionFeeProxy_NotVersionedProxyAdmin(); + + /// @notice Version identifier cannot be zero + error IntuitionFeeProxy_InvalidVersion(); + + /// @notice Implementation address is invalid + error IntuitionFeeProxy_InvalidImplementation(); + + /// @notice Version is already registered + error IntuitionFeeProxy_VersionAlreadyRegistered(); + + /// @notice Version is not registered + error IntuitionFeeProxy_VersionNotRegistered(); + + /// @notice Default version cannot be removed + error IntuitionFeeProxy_CannotRemoveDefaultVersion(); + + /// @notice Implementation does not use the ERC-1967 implementation slot + error IntuitionFeeProxy_UnsupportedProxiableUUID(bytes32 slot); + + /// @notice Contract has already been initialized + error IntuitionFeeProxy_AlreadyInitialized(); } diff --git a/src/test/IntuitionFeeProxyV2.sol b/src/test/IntuitionFeeProxyV2.sol new file mode 100644 index 0000000..c329685 --- /dev/null +++ b/src/test/IntuitionFeeProxyV2.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +import {IntuitionFeeProxy} from "../IntuitionFeeProxy.sol"; + +/// @title IntuitionFeeProxyV2 +/// @notice Test implementation used to prove versioned upgrades preserve state and expose new behavior. +contract IntuitionFeeProxyV2 is IntuitionFeeProxy { + function versionLabel() external pure returns (string memory) { + return "v2"; + } +} diff --git a/test/IntuitionFeeProxy.test.ts b/test/IntuitionFeeProxy.test.ts index 4b652cb..a02874c 100644 --- a/test/IntuitionFeeProxy.test.ts +++ b/test/IntuitionFeeProxy.test.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import { ethers } from "hardhat"; import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; -import { IntuitionFeeProxy, MockMultiVault } from "../typechain-types"; +import { ERC7936Proxy, IntuitionFeeProxy, MockMultiVault } from "../typechain-types"; import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; describe("IntuitionFeeProxy", function () { @@ -10,6 +10,8 @@ describe("IntuitionFeeProxy", function () { const DEPOSIT_FEE = ethers.parseEther("0.1"); // 0.1 TRUST per deposit const DEPOSIT_PERCENTAGE = 500n; // 5% const FEE_DENOMINATOR = 10000n; + const VERSION_V1 = ethers.encodeBytes32String("v1"); + const VERSION_V2 = ethers.encodeBytes32String("v2"); // Fixture to deploy contracts async function deployFixture() { @@ -22,16 +24,30 @@ describe("IntuitionFeeProxy", function () { // Deploy IntuitionFeeProxy const IntuitionFeeProxyFactory = await ethers.getContractFactory("IntuitionFeeProxy"); - const proxy = await IntuitionFeeProxyFactory.deploy( + const implementation = await IntuitionFeeProxyFactory.deploy(); + await implementation.waitForDeployment(); + + const initializationData = IntuitionFeeProxyFactory.interface.encodeFunctionData("initialize", [ await mockMultiVault.getAddress(), FEE_RECIPIENT, DEPOSIT_FEE, DEPOSIT_PERCENTAGE, - [admin1.address, admin2.address, admin3.address] + [admin1.address, admin2.address, admin3.address], + ]); + + const ERC7936ProxyFactory = await ethers.getContractFactory("ERC7936Proxy"); + const deployedVersionedProxy = await ERC7936ProxyFactory.deploy( + admin1.address, + VERSION_V1, + await implementation.getAddress(), + initializationData ); - await proxy.waitForDeployment(); + await deployedVersionedProxy.waitForDeployment(); + + const versionedProxy = await ethers.getContractAt("ERC7936Proxy", await deployedVersionedProxy.getAddress()) as unknown as ERC7936Proxy; + const proxy = await ethers.getContractAt("IntuitionFeeProxy", await deployedVersionedProxy.getAddress()) as unknown as IntuitionFeeProxy; - return { proxy, mockMultiVault, owner, admin1, admin2, admin3, user, nonAdmin }; + return { proxy, versionedProxy, implementation, mockMultiVault, owner, admin1, admin2, admin3, user, nonAdmin }; } describe("Initialization", function () { @@ -66,15 +82,19 @@ describe("IntuitionFeeProxy", function () { it("Should revert on zero MultiVault address", async function () { const [admin] = await ethers.getSigners(); const IntuitionFeeProxyFactory = await ethers.getContractFactory("IntuitionFeeProxy"); + const implementation = await IntuitionFeeProxyFactory.deploy(); + await implementation.waitForDeployment(); + const ERC7936ProxyFactory = await ethers.getContractFactory("ERC7936Proxy"); + const initializationData = IntuitionFeeProxyFactory.interface.encodeFunctionData("initialize", [ + ethers.ZeroAddress, + FEE_RECIPIENT, + DEPOSIT_FEE, + DEPOSIT_PERCENTAGE, + [admin.address], + ]); await expect( - IntuitionFeeProxyFactory.deploy( - ethers.ZeroAddress, - FEE_RECIPIENT, - DEPOSIT_FEE, - DEPOSIT_PERCENTAGE, - [admin.address] - ) + ERC7936ProxyFactory.deploy(admin.address, VERSION_V1, await implementation.getAddress(), initializationData) ).to.be.revertedWithCustomError(IntuitionFeeProxyFactory, "IntuitionFeeProxy_InvalidMultiVaultAddress"); }); @@ -82,16 +102,199 @@ describe("IntuitionFeeProxy", function () { const { mockMultiVault } = await loadFixture(deployFixture); const [admin] = await ethers.getSigners(); const IntuitionFeeProxyFactory = await ethers.getContractFactory("IntuitionFeeProxy"); + const implementation = await IntuitionFeeProxyFactory.deploy(); + await implementation.waitForDeployment(); + const ERC7936ProxyFactory = await ethers.getContractFactory("ERC7936Proxy"); + const initializationData = IntuitionFeeProxyFactory.interface.encodeFunctionData("initialize", [ + await mockMultiVault.getAddress(), + ethers.ZeroAddress, + DEPOSIT_FEE, + DEPOSIT_PERCENTAGE, + [admin.address], + ]); await expect( - IntuitionFeeProxyFactory.deploy( + ERC7936ProxyFactory.deploy(admin.address, VERSION_V1, await implementation.getAddress(), initializationData) + ).to.be.revertedWithCustomError(IntuitionFeeProxyFactory, "IntuitionFeeProxy_InvalidMultisigAddress"); + }); + + it("Should disable the implementation initializer", async function () { + const { implementation, mockMultiVault, admin1 } = await loadFixture(deployFixture); + + await expect( + implementation.initialize( await mockMultiVault.getAddress(), - ethers.ZeroAddress, + FEE_RECIPIENT, DEPOSIT_FEE, DEPOSIT_PERCENTAGE, - [admin.address] + [admin1.address] ) - ).to.be.revertedWithCustomError(IntuitionFeeProxyFactory, "IntuitionFeeProxy_InvalidMultisigAddress"); + ).to.be.reverted; + }); + + it("Should prevent proxy reinitialization", async function () { + const { proxy, mockMultiVault, admin1 } = await loadFixture(deployFixture); + + await expect( + proxy.initialize( + await mockMultiVault.getAddress(), + FEE_RECIPIENT, + DEPOSIT_FEE, + DEPOSIT_PERCENTAGE, + [admin1.address] + ) + ).to.be.reverted; + }); + }); + + describe("ERC-7936 Versioned Proxy", function () { + it("Should initialize the default version and active implementation consistently", async function () { + const { versionedProxy, implementation, admin1 } = await loadFixture(deployFixture); + + expect(await versionedProxy.versionedProxyAdmin()).to.equal(admin1.address); + expect(await versionedProxy.getDefaultVersion()).to.equal(VERSION_V1); + expect(await versionedProxy.getImplementation(VERSION_V1)).to.equal(await implementation.getAddress()); + expect(await versionedProxy.getActiveImplementation()).to.equal(await implementation.getAddress()); + expect(await versionedProxy.getVersions()).to.deep.equal([VERSION_V1]); + }); + + it("Should reject invalid proxy construction parameters", async function () { + const { implementation } = await loadFixture(deployFixture); + const [admin] = await ethers.getSigners(); + const ERC7936ProxyFactory = await ethers.getContractFactory("ERC7936Proxy"); + + await expect( + ERC7936ProxyFactory.deploy(ethers.ZeroAddress, VERSION_V1, await implementation.getAddress(), "0x") + ).to.be.revertedWithCustomError(ERC7936ProxyFactory, "IntuitionFeeProxy_ZeroAddress"); + + await expect( + ERC7936ProxyFactory.deploy(admin.address, ethers.ZeroHash, await implementation.getAddress(), "0x") + ).to.be.revertedWithCustomError(ERC7936ProxyFactory, "IntuitionFeeProxy_InvalidVersion"); + + await expect( + ERC7936ProxyFactory.deploy(admin.address, VERSION_V1, admin.address, "0x") + ).to.be.revertedWithCustomError(ERC7936ProxyFactory, "IntuitionFeeProxy_InvalidImplementation"); + }); + + it("Should restrict version management to the versioned proxy admin", async function () { + const { versionedProxy, implementation, nonAdmin } = await loadFixture(deployFixture); + + await expect(versionedProxy.connect(nonAdmin).registerVersion(VERSION_V2, await implementation.getAddress())) + .to.be.revertedWithCustomError(versionedProxy, "IntuitionFeeProxy_NotVersionedProxyAdmin"); + + await expect(versionedProxy.connect(nonAdmin).setDefaultVersion(VERSION_V1)) + .to.be.revertedWithCustomError(versionedProxy, "IntuitionFeeProxy_NotVersionedProxyAdmin"); + + await expect(versionedProxy.connect(nonAdmin).upgradeToVersion(VERSION_V2, await implementation.getAddress(), "0x")) + .to.be.revertedWithCustomError(versionedProxy, "IntuitionFeeProxy_NotVersionedProxyAdmin"); + }); + + it("Should register, remove, and protect registered versions", async function () { + const { versionedProxy, admin1 } = await loadFixture(deployFixture); + const IntuitionFeeProxyV2Factory = await ethers.getContractFactory("IntuitionFeeProxyV2"); + const implementationV2 = await IntuitionFeeProxyV2Factory.deploy(); + await implementationV2.waitForDeployment(); + + await expect(versionedProxy.connect(admin1).registerVersion(VERSION_V2, await implementationV2.getAddress())) + .to.emit(versionedProxy, "VersionRegistered") + .withArgs(VERSION_V2, await implementationV2.getAddress()); + + await expect(versionedProxy.connect(admin1).registerVersion(VERSION_V2, await implementationV2.getAddress())) + .to.be.revertedWithCustomError(versionedProxy, "IntuitionFeeProxy_VersionAlreadyRegistered"); + + await expect(versionedProxy.connect(admin1).removeVersion(VERSION_V1)) + .to.be.revertedWithCustomError(versionedProxy, "IntuitionFeeProxy_CannotRemoveDefaultVersion"); + + await expect(versionedProxy.connect(admin1).removeVersion(VERSION_V2)) + .to.emit(versionedProxy, "VersionRemoved") + .withArgs(VERSION_V2); + + await expect(versionedProxy.getImplementation(VERSION_V2)) + .to.be.revertedWithCustomError(versionedProxy, "IntuitionFeeProxy_VersionNotRegistered"); + }); + + it("Should execute a registered non-default version explicitly", async function () { + const { versionedProxy, admin1 } = await loadFixture(deployFixture); + const IntuitionFeeProxyV2Factory = await ethers.getContractFactory("IntuitionFeeProxyV2"); + const implementationV2 = await IntuitionFeeProxyV2Factory.deploy(); + await implementationV2.waitForDeployment(); + + await versionedProxy.connect(admin1).registerVersion(VERSION_V2, await implementationV2.getAddress()); + + const data = IntuitionFeeProxyV2Factory.interface.encodeFunctionData("versionLabel"); + const result = await versionedProxy.executeAtVersion.staticCall(VERSION_V2, data); + const [label] = IntuitionFeeProxyV2Factory.interface.decodeFunctionResult("versionLabel", result); + + expect(label).to.equal("v2"); + expect(await versionedProxy.getDefaultVersion()).to.equal(VERSION_V1); + }); + + it("Should upgrade default version and preserve fee proxy state", async function () { + const { proxy, versionedProxy, implementation, mockMultiVault, admin1, admin2, user } = await loadFixture(deployFixture); + + const desiredDepositAmount = ethers.parseEther("4"); + const totalToSend = await proxy.getTotalDepositCost(desiredDepositAmount); + const expectedFee = await proxy.calculateDepositFee(1n, desiredDepositAmount); + const termId = ethers.zeroPadValue("0x77", 32); + + await proxy.connect(user).deposit(user.address, termId, 1n, 0n, { value: totalToSend }); + await proxy.connect(admin1).setDepositFixedFee(ethers.parseEther("0.2")); + + const IntuitionFeeProxyV2Factory = await ethers.getContractFactory("IntuitionFeeProxyV2"); + const implementationV2 = await IntuitionFeeProxyV2Factory.deploy(); + await implementationV2.waitForDeployment(); + + await expect(versionedProxy.connect(admin1).upgradeToVersion(VERSION_V2, await implementationV2.getAddress(), "0x")) + .to.emit(versionedProxy, "VersionRegistered") + .withArgs(VERSION_V2, await implementationV2.getAddress()) + .and.to.emit(versionedProxy, "DefaultVersionChanged") + .withArgs(VERSION_V1, VERSION_V2); + + const proxyAsV2 = await ethers.getContractAt("IntuitionFeeProxyV2", await versionedProxy.getAddress()); + + expect(await versionedProxy.getImplementation(VERSION_V1)).to.equal(await implementation.getAddress()); + expect(await versionedProxy.getImplementation(VERSION_V2)).to.equal(await implementationV2.getAddress()); + expect(await versionedProxy.getActiveImplementation()).to.equal(await implementationV2.getAddress()); + expect(await versionedProxy.getDefaultVersion()).to.equal(VERSION_V2); + expect(await proxyAsV2.versionLabel()).to.equal("v2"); + + expect(await proxyAsV2.ethMultiVault()).to.equal(await mockMultiVault.getAddress()); + expect(await proxyAsV2.feeRecipient()).to.equal(FEE_RECIPIENT); + expect(await proxyAsV2.depositFixedFee()).to.equal(ethers.parseEther("0.2")); + expect(await proxyAsV2.depositPercentageFee()).to.equal(DEPOSIT_PERCENTAGE); + expect(await proxyAsV2.accruedFees()).to.equal(expectedFee); + expect(await proxyAsV2.whitelistedAdmins(admin1.address)).to.be.true; + expect(await proxyAsV2.whitelistedAdmins(admin2.address)).to.be.true; + }); + + it("Should reject unsupported or conflicting upgrades", async function () { + const { versionedProxy, admin1, user } = await loadFixture(deployFixture); + const IntuitionFeeProxyV2Factory = await ethers.getContractFactory("IntuitionFeeProxyV2"); + const implementationV2 = await IntuitionFeeProxyV2Factory.deploy(); + await implementationV2.waitForDeployment(); + + await expect(versionedProxy.connect(admin1).setDefaultVersion(VERSION_V2)) + .to.be.revertedWithCustomError(versionedProxy, "IntuitionFeeProxy_VersionNotRegistered"); + + await versionedProxy.connect(admin1).registerVersion(VERSION_V2, await implementationV2.getAddress()); + + await expect(versionedProxy.connect(admin1).upgradeToVersion(VERSION_V2, user.address, "0x")) + .to.be.revertedWithCustomError(versionedProxy, "IntuitionFeeProxy_VersionAlreadyRegistered"); + }); + + it("Should transfer versioned proxy admin", async function () { + const { versionedProxy, admin1, admin2 } = await loadFixture(deployFixture); + + await expect(versionedProxy.connect(admin1).transferVersionedProxyAdmin(admin2.address)) + .to.emit(versionedProxy, "VersionedProxyAdminTransferred") + .withArgs(admin1.address, admin2.address); + + expect(await versionedProxy.versionedProxyAdmin()).to.equal(admin2.address); + await expect(versionedProxy.connect(admin1).setDefaultVersion(VERSION_V1)) + .to.be.revertedWithCustomError(versionedProxy, "IntuitionFeeProxy_NotVersionedProxyAdmin"); + await expect(versionedProxy.connect(admin2).setDefaultVersion(VERSION_V1)) + .to.emit(versionedProxy, "DefaultVersionChanged") + .withArgs(VERSION_V1, VERSION_V1); }); }); From c33295b777074424c10a7030083a1cdb258ffb3d Mon Sep 17 00:00:00 2001 From: giantcoconut Date: Sat, 2 May 2026 13:03:03 +0100 Subject: [PATCH 4/4] Update README for proposed V2 coverage --- README.md | 260 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 151 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index e1c609f..9a01b1d 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,63 @@ -# Intuition Fee Proxy +# Intuition Fee Proxy V2 -A customizable proxy contract for the [Intuition](https://intuition.systems) MultiVault that allows you to collect fees on deposits. +A customizable fee proxy for the [Intuition](https://intuition.systems) MultiVault. It wraps user-facing MultiVault create/deposit flows, charges configurable deposit-based fees, and routes calls through a versioned proxy deployment. + +## Proposed Mission 02 Coverage + +This PR proposes the following contract-side V2 work: + +| Bounty | Status | Notes | +| --- | --- | --- | +| 2A | Implemented | Enforces `receiver == msg.sender` across user-facing create/deposit flows. | +| 2B | Implemented | Adds ERC-7936-style version routing with upgrade and state-preservation tests. | +| 2C | Implemented | Tracks accrued fees in-contract and supports controlled withdrawal. | +| 2D | Planned next | Factory/webapp work planned as the next slice. | +| 2E | Follow-up | Article/social write-up after implementation stabilizes. | ## Features -- **Deposit-based fees**: Fixed fee per deposit + percentage fee on deposit amounts -- **Admin system**: Whitelisted admins can update fees and settings -- **Versioned upgrades**: ERC-7936-style proxy with registered implementations and a default version -- **Receiver pattern**: Shares are deposited directly to users (requires approval) -- **Full MultiVault compatibility**: All view functions pass through to MultiVault +- **Receiver validation**: `deposit`, `depositBatch`, `createAtoms`, and `createTriples` require the receiver to be the caller. +- **Deposit-based fees**: Fixed fee per deposit plus percentage fee on deposited amounts. +- **Explicit fee accounting**: Collected fees accrue inside the proxy until withdrawn by the fee recipient or a whitelisted admin. +- **Versioned upgrades**: ERC-7936-style proxy with registered implementations, default-version routing, explicit version execution, and ERC-1967 implementation-slot synchronization. +- **Admin controls**: Whitelisted admins can update fee settings, fee recipient, admin permissions, versions, and non-fee balance sweeps. +- **MultiVault passthrough**: View helpers expose relevant MultiVault costs and vault state. ## Fee Structure -All fees are applied **per deposit** (added on top of the deposit amount): +Fees are charged only on deposit amounts. Atom and triple creation costs from MultiVault are passed through without an additional proxy fee. | Fee Type | Default | Description | -|----------|---------|-------------| -| Fixed fee | 0.1 TRUST | Applied per deposit operation | -| Percentage fee | 5% | Applied on deposit amounts | +| --- | --- | --- | +| Fixed fee | 0.1 TRUST | Applied per non-zero deposit. | +| Percentage fee | 5% | Applied to the total deposited amount. | -Fees apply to: -- `deposit()` - direct deposits -- `createAtoms()` - deposits made during atom creation -- `createTriples()` - deposits made during triple creation -- `depositBatch()` - batch deposits +Fees apply to deposits inside: + +- `deposit()` +- `depositBatch()` +- `createAtoms()` +- `createTriples()` ### Example For a 10 TRUST deposit: -- Fixed fee: 0.1 TRUST -- Percentage fee: 0.5 TRUST (5% of 10) -- **Total fee: 0.6 TRUST** -- **User sends: 10.6 TRUST** -- **Deposited to MultiVault: 10 TRUST** - -Note: MultiVault may apply its own internal fees on deposits. -## Prerequisites +- Fixed fee: 0.1 TRUST +- Percentage fee: 0.5 TRUST +- Total fee: 0.6 TRUST +- User sends: 10.6 TRUST +- Deposited to MultiVault: 10 TRUST -- Node.js >= 18 -- npm or yarn -- A wallet with TRUST tokens for deployment +MultiVault may still apply its own internal fees. ## Quick Start -### 1. Clone and Install +### 1. Install ```bash -git clone https://github.com/YOUR_USERNAME/intuition-fee-proxy.git -cd intuition-fee-proxy +git clone https://github.com/YOUR_USERNAME/Fee-Proxy-Template.git +cd Fee-Proxy-Template npm install ``` @@ -58,25 +67,24 @@ npm install cp .env.example .env ``` -Edit `.env` with your configuration: +Set the deployment variables: ```env -# Deployer private key (with TRUST tokens) PRIVATE_KEY=0x... -# Fee recipient address (receives all collected fees) -# IMPORTANT: Use an address on Intuition Network, NOT another chain +# Address controlled on Intuition Network. FEE_RECIPIENT=0x... -# Admin addresses (can modify fees and settings) ADMIN_1=0x... -ADMIN_2=0x... # Optional +ADMIN_2=0x... -# Fee configuration - ONLY APPLIED ON DEPOSITS -DEPOSIT_FEE=0.1 # Fixed fee per deposit (in TRUST) -DEPOSIT_PERCENTAGE=500 # Percentage fee (500 = 5%, base 10000) +# Deposit fee configuration. +DEPOSIT_FEE=0.1 +DEPOSIT_PERCENTAGE=500 ``` +`DEPOSIT_PERCENTAGE` uses a 10,000 basis-point denominator, so `500` equals 5%. + ### 3. Compile and Test ```bash @@ -84,79 +92,86 @@ npm run compile npm test ``` +For TypeScript checks: + +```bash +node ./node_modules/typescript/bin/tsc --noEmit --pretty false +``` + ### 4. Deploy -The deploy script creates an `IntuitionFeeProxy` implementation and an `ERC7936Proxy`. -Use the proxy address in frontend integrations. +The deploy script creates an `IntuitionFeeProxy` implementation and an `ERC7936Proxy`. Frontends and integrations should use the proxy address. -**Testnet (recommended first):** ```bash -npx hardhat run scripts/deploy.ts --network intuition-testnet +npm run deploy:testnet ``` -**Mainnet:** ```bash -npx hardhat run scripts/deploy.ts --network intuition +npm run deploy:mainnet ``` -## Fee Calculation +## Contract Flow + +1. User approves the proxy on MultiVault for deposits. +2. User calls a proxy create/deposit function with themselves as `receiver`. +3. Proxy validates `receiver == msg.sender`. +4. Proxy calculates the deposit fee and forwards only the MultiVault-required value to MultiVault. +5. Proxy records the fee in `accruedFees`. +6. Fee recipient or a whitelisted admin withdraws accrued fees to `feeRecipient`. + +## User Approval +Users must approve the proxy on MultiVault before using deposit flows: + +```solidity +multiVault.approve(proxyAddress, 1); ``` + +## Fee Calculation + +```text fee = (depositFixedFee * depositCount) + (totalDeposit * depositPercentageFee / 10000) ``` Where: -- `depositCount` = number of **non-zero** deposits in the `assets[]` array -- `totalDeposit` = sum of all deposit amounts -**Important**: Fees are ONLY charged on deposits, NOT on atom/triple creation itself. The `atomCost` and `tripleCost` from MultiVault are passed through without any additional fee. +- `depositCount` is the number of non-zero deposits. +- `totalDeposit` is the sum of all deposit amounts. ### Examples -**Single deposit of 0.05 TRUST on an existing vault**: -- Fee = (0.1 × 1) + (0.05 × 5%) = 0.1 + 0.0025 = **0.1025 TRUST** -- User sends: 0.1525 TRUST - -**Creating 1 triple with a 0.1 TRUST deposit on it**: -- Triple creation cost: ~0.0004 TRUST (paid to MultiVault, no fee) -- Deposit fee: (0.1 × 1) + (0.1 × 5%) = 0.1 + 0.005 = **0.105 TRUST** -- User sends: tripleCost + 0.1 + 0.105 = ~0.2054 TRUST +Single deposit of 0.05 TRUST: +- Fee = `(0.1 * 1) + (0.05 * 5%)` +- Fee = `0.1025 TRUST` +- User sends `0.1525 TRUST` +- MultiVault receives `0.05 TRUST` -**Batch deposit on 3 vaults** (assets = [0.01, 0.05, 0.1]): -- Deposit fee: (0.1 × 3) + (0.16 × 5%) = 0.3 + 0.008 = **0.308 TRUST** -- User sends: 0.16 + 0.308 = 0.468 TRUST +Creating one triple with a 0.1 TRUST deposit: -## User Approval Flow - -Users must approve the proxy on MultiVault before using it: - -```solidity -// User calls this once on MultiVault -multiVault.approve(proxyAddress, 1); // 1 = DEPOSIT approval -``` +- Triple creation cost is paid to MultiVault with no proxy fee. +- Deposit fee = `(0.1 * 1) + (0.1 * 5%)` +- Deposit fee = `0.105 TRUST` +- User sends `tripleCost + 0.1 + 0.105` -This allows the proxy to deposit shares on behalf of the user. +Batch deposit on three vaults with assets `[0.01, 0.05, 0.1]`: -## Contract Functions +- Deposit fee = `(0.1 * 3) + (0.16 * 5%)` +- Deposit fee = `0.308 TRUST` +- User sends `0.468 TRUST` -### User Functions +## User Functions ```solidity -// Create atoms with fee on deposits -createAtoms(receiver, data[], assets[], curveId) payable - -// Create triples with fee on deposits -createTriples(receiver, subjectIds[], predicateIds[], objectIds[], assets[], curveId) payable - -// Deposit with fee -deposit(receiver, termId, curveId, minShares) payable - -// Batch deposit with fee -depositBatch(receiver, termIds[], curveIds[], assets[], minShares[]) payable +createAtoms(receiver, data, assets, curveId) +createTriples(receiver, subjectIds, predicateIds, objectIds, assets, curveId) +deposit(receiver, termId, curveId, minShares) +depositBatch(receiver, termIds, curveIds, assets, minShares) ``` -### Admin Functions +All user-facing functions are payable and require `receiver == msg.sender`. + +## Admin Functions ```solidity setDepositFixedFee(newFee) @@ -168,7 +183,9 @@ withdrawAllFees() sweepNonFeeBalance(recipient, amount) ``` -### Versioned Proxy Functions +`withdrawFees` and `withdrawAllFees` always send accrued fees to `feeRecipient`. Direct native TRUST/tTRUST sent to the proxy is treated as non-fee balance and can only be swept separately. + +## Versioned Proxy Functions ```solidity registerVersion(version, implementation) @@ -179,64 +196,69 @@ getDefaultVersion() getVersions() executeAtVersion(version, data) upgradeToVersion(version, implementation, migrationData) +versionedProxyAdmin() +transferVersionedProxyAdmin(newAdmin) +getActiveImplementation() ``` -### View Functions +The proxy keeps ERC-7936-style default-version routing synchronized with the ERC-1967 implementation slot. After a default-version upgrade, regular calls route through the new default implementation while preserving proxy storage. + +## View Functions ```solidity -// Calculate fee for deposits calculateDepositFee(depositCount, totalDeposit) - -// Get total cost including fees getTotalDepositCost(depositAmount) getTotalCreationCost(depositCount, totalDeposit, multiVaultCost) - -// Calculate MultiVault amount from msg.value getMultiVaultAmountFromValue(msgValue) +getAtomCost() +getTripleCost() +isTermCreated(termId) +getShares(user, termId, curveId) ``` ## Network Configuration | Network | Chain ID | MultiVault Address | -|---------|----------|-------------------| +| --- | --- | --- | | Intuition Mainnet | 1155 | `0x6E35cF57A41fA15eA0EaE9C33e751b01A784Fe7e` | | Intuition Testnet | 13579 | `0x2Ece8D4dEdcB9918A398528f3fa4688b1d2CAB91` | ## Frontend Integration ```typescript -// For a single deposit -const depositAmount = parseEther("10"); +const depositAmount = ethers.parseEther("10"); const totalCost = await proxy.getTotalDepositCost(depositAmount); -await proxy.deposit(userAddress, termId, curveId, 0, { value: totalCost }); -// For createTriples with deposits +await proxy.deposit(userAddress, termId, curveId, 0, { + value: totalCost, +}); +``` + +For create flows, include the MultiVault creation cost plus the proxy fee: + +```typescript const tripleCost = await proxy.getTripleCost(); -const depositAmounts = [parseEther("1"), parseEther("1")]; // 2 triples -const depositCount = 2; // non-zero deposits -const totalDeposit = parseEther("2"); -const multiVaultCost = (tripleCost * 2n) + totalDeposit; -const totalCost = await proxy.getTotalCreationCost(depositCount, totalDeposit, multiVaultCost); +const assets = [ethers.parseEther("1"), ethers.parseEther("1")]; +const depositCount = 2; +const totalDeposit = assets.reduce((sum, amount) => sum + amount, 0n); +const multiVaultCost = tripleCost * 2n + totalDeposit; +const totalCost = await proxy.getTotalCreationCost( + depositCount, + totalDeposit, + multiVaultCost +); await proxy.createTriples( userAddress, subjectIds, predicateIds, objectIds, - depositAmounts, + assets, curveId, { value: totalCost } ); ``` -## Security Considerations - -1. **Fee recipient chain**: Ensure `FEE_RECIPIENT` is an address you control on Intuition Network -2. **Admin keys**: Securely store admin private keys -3. **Fee limits**: Consider implementing maximum fee limits for user trust -4. **Upgrades**: Upgrade through the ERC-7936 proxy by registering a new implementation version and setting it as the default -5. **Fee accounting**: Fee withdrawals use explicit accrued-fee accounting; direct native TRUST/tTRUST sent to the proxy is separate non-fee balance - ## Migration From V1 Existing V1 deployments were standalone contracts, not proxy deployments, so they cannot be upgraded in place with `upgradeToAndCall`. @@ -249,6 +271,26 @@ Recommended migration path: 4. Update frontend/app configuration to use the new proxy address. 5. For future changes, deploy a new implementation, register a new version, and set it as the default version through the ERC-7936 proxy. +## Security Notes + +- Use a fee recipient address controlled on the target Intuition network. +- Keep deployer, admin, and proxy admin keys separate where possible. +- Review fee settings before deployment; percentage fees are bounded by contract validation. +- Use `withdrawFees` or `withdrawAllFees` for accrued protocol fees. +- Use `sweepNonFeeBalance` only for native TRUST/tTRUST that was sent directly to the proxy and is not part of `accruedFees`. +- Register and test a new implementation version before making it the default version. + +## Validation + +Current local validation for this branch: + +```text +npm test +58 passing + +node ./node_modules/typescript/bin/tsc --noEmit --pretty false +``` + ## License MIT