From ceda9dacd17f40c1ff45dee3afc7274445fba5de Mon Sep 17 00:00:00 2001 From: piyushbijarania Date: Thu, 22 Oct 2026 21:51:54 +0530 Subject: [PATCH] Add dev fee and 2-step handover Implement dev fee deductions, limits, and 2-step transfer. --- script/DeployOracleFactory.s.sol | 5 +- src/Oracle.sol | 65 +++++- src/OracleFactory.sol | 35 ++- test/OracleDevFee.t.sol | 361 +++++++++++++++++++++++++++++++ 4 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 test/OracleDevFee.t.sol diff --git a/script/DeployOracleFactory.s.sol b/script/DeployOracleFactory.s.sol index f1558a4..214b6f1 100644 --- a/script/DeployOracleFactory.s.sol +++ b/script/DeployOracleFactory.s.sol @@ -12,7 +12,10 @@ contract DeployOracleFactory is Script { function run() public { vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - factory = new OracleFactory(0xab53369e91dcFC275744DC0A30BD3E363B2785e0); + address devAddress = vm.envOr("DEV_ADDRESS", address(0xab53369e91dcFC275744DC0A30BD3E363B2785e0)); + uint256 devFee = vm.envOr("DEV_FEE", uint256(1000)); // Default 1% (1000 / 1e5) + + factory = new OracleFactory(0xab53369e91dcFC275744DC0A30BD3E363B2785e0, devAddress, devFee); console.log("Factory Deployed: ", address(factory)); diff --git a/src/Oracle.sol b/src/Oracle.sol index 94c0cd9..40a3287 100644 --- a/src/Oracle.sol +++ b/src/Oracle.sol @@ -20,6 +20,11 @@ error PriceHistoryEndOutOfBounds(); error InvalidPriceHistoryRange(); error EmptyHistory(); error InvalidSampleSize(); +error OnlyDev(); +error OnlyProposedDev(); +error DevFeeExceedsMax(uint256 devFee, uint256 maxDevFee); +error DevFeeTransferFailed(); +error ZeroAddress(); contract Oracle is Ownable, ReentrancyGuard { @@ -29,6 +34,10 @@ contract Oracle is Ownable, ReentrancyGuard { event TokenWithdrawn(address indexed user, uint256 amount); event Voted(address indexed target, address indexed voter, bool isBlacklist, uint256 weight); event BlacklistStatusChanged(address indexed target, bool isBlacklisted); + event DevProposed(address indexed proposedDev); + event DevTransferred(address indexed oldDev, address indexed newDev); + event DevFeeReceiverUpdated(address indexed newReceiver); + event DevFeePaid(address indexed receiver, uint256 amount); IERC20 public immutable WEIGHT_TOKEN; @@ -54,6 +63,12 @@ contract Oracle is Ownable, ReentrancyGuard { uint256 private constant DENOMINATOR = 1e5; uint256 private constant WAD = 1e18; + uint256 public constant MAX_DEV_FEE = 30_000; // Maximum allowed dev fee: 30% of DENOMINATOR (1e5) + + address public dev; + address public proposedDev; + address public devFeeReceiver; + uint256 public immutable devFee; uint256 private latestValue; // most recent raw submission uint256 private aggregatedValue; // last aggregated price (decayed weighted mean) @@ -81,9 +96,16 @@ contract Oracle is Ownable, ReentrancyGuard { _; } - constructor(address owner_, string memory name_,string memory description_, address weightToken_,uint256 halfLifeSeconds_,uint256 q_,uint256 depositLockingPeriod_,uint256 withdrawalLockingPeriod_,uint256 rewardBps_, uint256 gamma_, uint256 defaultSampleSize_) Ownable(owner_) { + constructor(address owner_, address dev_, uint256 devFee_, string memory name_, string memory description_, address weightToken_, uint256 halfLifeSeconds_,uint256 q_, uint256 depositLockingPeriod_, uint256 withdrawalLockingPeriod_, uint256 rewardBps_, uint256 gamma_, uint256 defaultSampleSize_) Ownable(owner_) { if (weightToken_ == address(0)) revert InvalidWeightTokenAddress(); if (defaultSampleSize_ == 0) revert InvalidSampleSize(); + if (dev_ == address(0)) revert ZeroAddress(); + if (devFee_ > MAX_DEV_FEE) revert DevFeeExceedsMax(devFee_, MAX_DEV_FEE); + + dev = dev_; + devFeeReceiver = dev_; + devFee = devFee_; + WEIGHT_TOKEN = IERC20(weightToken_); HALF_LIFE_SECONDS = halfLifeSeconds_; Q = q_; DEPOSIT_LOCKING_PERIOD = depositLockingPeriod_; WITHDRAWAL_LOCKING_PERIOD = withdrawalLockingPeriod_; lastTimestamp = block.timestamp; lastUpdated = block.timestamp; REWARD_BPS = rewardBps_; name = name_; description = description_; @@ -91,12 +113,47 @@ contract Oracle is Ownable, ReentrancyGuard { } receive() external payable { - lastTimestamp = block.timestamp; - emit Funded(msg.sender, msg.value); + _handleDeposit(); } + function deposit() external payable { + _handleDeposit(); + } + + function _handleDeposit() internal { lastTimestamp = block.timestamp; - emit Funded(msg.sender, msg.value); + uint256 amount = msg.value; + if (amount > 0 && devFee > 0) { + uint256 fee = (amount * devFee) / DENOMINATOR; + if (fee > 0) { + (bool ok, ) = devFeeReceiver.call{value: fee}(""); + if (!ok) revert DevFeeTransferFailed(); + emit DevFeePaid(devFeeReceiver, fee); + } + } + emit Funded(msg.sender, amount); + } + + function proposeDev(address newDev) external { + if (msg.sender != dev) revert OnlyDev(); + if (newDev == address(0)) revert ZeroAddress(); + proposedDev = newDev; + emit DevProposed(newDev); + } + + function acceptDev() external { + if (msg.sender != proposedDev) revert OnlyProposedDev(); + address oldDev = dev; + dev = msg.sender; + proposedDev = address(0); + emit DevTransferred(oldDev, msg.sender); + } + + function setDevFeeReceiver(address newReceiver) external { + if (msg.sender != dev) revert OnlyDev(); + if (newReceiver == address(0)) revert ZeroAddress(); + devFeeReceiver = newReceiver; + emit DevFeeReceiverUpdated(newReceiver); } function submitValue(uint256 newValue) external nonReentrant onlyTokenHolder { diff --git a/src/OracleFactory.sol b/src/OracleFactory.sol index 96ab263..e6cd6db 100644 --- a/src/OracleFactory.sol +++ b/src/OracleFactory.sol @@ -5,9 +5,15 @@ import {Ownable} from "lib/openzeppelin-contracts/contracts/access/Ownable.sol"; import {Oracle} from "./Oracle.sol"; error InvalidWeightToken(); +error OnlyDev(); +error OnlyProposedDev(); +error DevFeeExceedsMax(uint256 devFee, uint256 maxDevFee); +error ZeroAddress(); contract OracleFactory is Ownable { event OracleCreated(address indexed oracle, address indexed creator, address token); + event DevProposed(address indexed proposedDev); + event DevTransferred(address indexed oldDev, address indexed newDev); struct OracleInfo { address oracle; @@ -15,10 +21,35 @@ contract OracleFactory is Ownable { address creator; } + uint256 public constant MAX_DEV_FEE = 30_000; // Maximum allowed dev fee: 30% of DENOMINATOR (1e5) + address public dev; + address public proposedDev; + uint256 public immutable devFee; + OracleInfo[] public oracles; mapping(address => address[]) public creatorToOracle; - constructor(address initialOwner) Ownable(initialOwner) {} + constructor(address initialOwner, address initialDev, uint256 devFee_) Ownable(initialOwner) { + if (initialDev == address(0)) revert ZeroAddress(); + if (devFee_ > MAX_DEV_FEE) revert DevFeeExceedsMax(devFee_, MAX_DEV_FEE); + dev = initialDev; + devFee = devFee_; + } + + function proposeDev(address newDev) external { + if (msg.sender != dev) revert OnlyDev(); + if (newDev == address(0)) revert ZeroAddress(); + proposedDev = newDev; + emit DevProposed(newDev); + } + + function acceptDev() external { + if (msg.sender != proposedDev) revert OnlyProposedDev(); + address oldDev = dev; + dev = msg.sender; + proposedDev = address(0); + emit DevTransferred(oldDev, msg.sender); + } /// @notice Anyone can create a new Oracle using an existing ERC20 token. /// @param weightToken Address of the ERC20 token to be used for weighting. @@ -46,6 +77,8 @@ contract OracleFactory is Ownable { Oracle o = new Oracle( msg.sender, // owner (oracle creator) + dev, // dev + devFee, // devFee name, description, weightToken, diff --git a/test/OracleDevFee.t.sol b/test/OracleDevFee.t.sol new file mode 100644 index 0000000..9b3536e --- /dev/null +++ b/test/OracleDevFee.t.sol @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "lib/forge-std/src/Test.sol"; +import {ERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import { + Oracle, + OnlyDev, + OnlyProposedDev, + DevFeeExceedsMax, + ZeroAddress, + DevFeeTransferFailed +} from "../src/Oracle.sol"; +import { + OracleFactory, + InvalidWeightToken, + OnlyDev as FactoryOnlyDev, + OnlyProposedDev as FactoryOnlyProposedDev, + DevFeeExceedsMax as FactoryDevFeeExceedsMax, + ZeroAddress as FactoryZeroAddress +} from "../src/OracleFactory.sol"; + +contract MockToken is ERC20 { + constructor() ERC20("Mock Token", "MTK") { + _mint(msg.sender, 1_000_000 ether); + } +} + +contract RejectEther { + // Rejects any incoming ETH +} + +contract OracleDevFeeTest is Test { + MockToken public token; + Oracle public oracle; + OracleFactory public factory; + + address public owner = address(0x1111); + address public dev = address(0x2222); + address public newDev = address(0x3333); + address public feeReceiver = address(0x4444); + address public user = address(0x5555); + + uint256 public constant DEFAULT_DEV_FEE = 10_000; // 10% (10,000 / 1e5) + uint256 public constant MAX_DEV_FEE = 30_000; // 30% + + event DevProposed(address indexed proposedDev); + event DevTransferred(address indexed oldDev, address indexed newDev); + event DevFeeReceiverUpdated(address indexed newReceiver); + event DevFeePaid(address indexed receiver, uint256 amount); + event Funded(address indexed from, uint256 amount); + + function setUp() public { + token = new MockToken(); + + oracle = new Oracle( + owner, + dev, + DEFAULT_DEV_FEE, + "Test Feed", + "Test Description", + address(token), + 3600, // halfLifeSeconds + 50000, // q + 300, // depositLockingPeriod + 300, // withdrawalLockingPeriod + 500, // rewardBps + 60, // gamma + 5 // defaultSampleSize + ); + + factory = new OracleFactory(owner, dev, DEFAULT_DEV_FEE); + vm.deal(user, 100 ether); + } + + // --- Constructor Validations --- + + function test_Constructor_SetsDevAndDevFee() public view { + assertEq(oracle.dev(), dev); + assertEq(oracle.devFeeReceiver(), dev); + assertEq(oracle.devFee(), DEFAULT_DEV_FEE); + assertEq(oracle.proposedDev(), address(0)); + assertEq(oracle.MAX_DEV_FEE(), MAX_DEV_FEE); + } + + function test_Constructor_RevertsOnZeroDevAddress() public { + vm.expectRevert(ZeroAddress.selector); + new Oracle( + owner, + address(0), + DEFAULT_DEV_FEE, + "Test", + "Desc", + address(token), + 3600, + 50000, + 300, + 300, + 500, + 60, + 5 + ); + } + + function test_Constructor_RevertsIfFeeExceedsMax() public { + uint256 excessiveFee = MAX_DEV_FEE + 1; // 30.001% + vm.expectRevert(abi.encodeWithSelector(DevFeeExceedsMax.selector, excessiveFee, MAX_DEV_FEE)); + new Oracle( + owner, + dev, + excessiveFee, + "Test", + "Desc", + address(token), + 3600, + 50000, + 300, + 300, + 500, + 60, + 5 + ); + } + + function test_Constructor_AllowsMaxFee() public { + Oracle maxFeeOracle = new Oracle( + owner, + dev, + MAX_DEV_FEE, + "Test", + "Desc", + address(token), + 3600, + 50000, + 300, + 300, + 500, + 60, + 5 + ); + assertEq(maxFeeOracle.devFee(), MAX_DEV_FEE); + } + + // --- Fee Deductions on deposit() and receive() --- + + function test_Deposit_DeductsFeeAndSendsToReceiver() public { + uint256 depositAmount = 10 ether; + uint256 expectedFee = (depositAmount * DEFAULT_DEV_FEE) / 100_000; // 1 ether + uint256 expectedRemainder = depositAmount - expectedFee; // 9 ether + + uint256 devBalanceBefore = dev.balance; + uint256 oracleBalanceBefore = address(oracle).balance; + + vm.prank(user); + vm.expectEmit(true, false, false, true); + emit DevFeePaid(dev, expectedFee); + vm.expectEmit(true, false, false, true); + emit Funded(user, depositAmount); + oracle.deposit{value: depositAmount}(); + + assertEq(dev.balance, devBalanceBefore + expectedFee); + assertEq(address(oracle).balance, oracleBalanceBefore + expectedRemainder); + } + + function test_Receive_DeductsFeeAndSendsToReceiver() public { + uint256 sendAmount = 5 ether; + uint256 expectedFee = (sendAmount * DEFAULT_DEV_FEE) / 100_000; // 0.5 ether + uint256 expectedRemainder = sendAmount - expectedFee; // 4.5 ether + + uint256 devBalanceBefore = dev.balance; + uint256 oracleBalanceBefore = address(oracle).balance; + + vm.prank(user); + vm.expectEmit(true, false, false, true); + emit DevFeePaid(dev, expectedFee); + (bool ok, ) = address(oracle).call{value: sendAmount}(""); + assertTrue(ok); + + assertEq(dev.balance, devBalanceBefore + expectedFee); + assertEq(address(oracle).balance, oracleBalanceBefore + expectedRemainder); + } + + function test_Deposit_ZeroFeeOracleKeepsAllBalance() public { + Oracle zeroFeeOracle = new Oracle( + owner, + dev, + 0, + "Zero Fee", + "Desc", + address(token), + 3600, + 50000, + 300, + 300, + 500, + 60, + 5 + ); + + uint256 depositAmount = 2 ether; + uint256 devBalanceBefore = dev.balance; + + vm.prank(user); + zeroFeeOracle.deposit{value: depositAmount}(); + + assertEq(dev.balance, devBalanceBefore); + assertEq(address(zeroFeeOracle).balance, depositAmount); + } + + function test_Deposit_RevertsIfDevFeeReceiverRejectsEther() public { + RejectEther rejecter = new RejectEther(); + + vm.prank(dev); + oracle.setDevFeeReceiver(address(rejecter)); + + vm.prank(user); + vm.expectRevert(DevFeeTransferFailed.selector); + oracle.deposit{value: 1 ether}(); + } + + // --- Updating devFeeReceiver --- + + function test_SetDevFeeReceiver_Success() public { + vm.prank(dev); + vm.expectEmit(true, false, false, false); + emit DevFeeReceiverUpdated(feeReceiver); + oracle.setDevFeeReceiver(feeReceiver); + + assertEq(oracle.devFeeReceiver(), feeReceiver); + + // Next deposit routes to new feeReceiver + uint256 depositAmount = 1 ether; + uint256 expectedFee = (depositAmount * DEFAULT_DEV_FEE) / 100_000; + uint256 receiverBalanceBefore = feeReceiver.balance; + + vm.prank(user); + oracle.deposit{value: depositAmount}(); + + assertEq(feeReceiver.balance, receiverBalanceBefore + expectedFee); + } + + function test_SetDevFeeReceiver_RevertsIfNotDev() public { + vm.prank(user); + vm.expectRevert(OnlyDev.selector); + oracle.setDevFeeReceiver(feeReceiver); + } + + function test_SetDevFeeReceiver_RevertsOnZeroAddress() public { + vm.prank(dev); + vm.expectRevert(ZeroAddress.selector); + oracle.setDevFeeReceiver(address(0)); + } + + // --- 2-Step Dev Transfer --- + + function test_TwoStepDevTransfer_Success() public { + // Step 1: Current dev proposes newDev + vm.prank(dev); + vm.expectEmit(true, false, false, false); + emit DevProposed(newDev); + oracle.proposeDev(newDev); + + assertEq(oracle.dev(), dev); + assertEq(oracle.proposedDev(), newDev); + + // Step 2: newDev accepts role + vm.prank(newDev); + vm.expectEmit(true, true, false, false); + emit DevTransferred(dev, newDev); + oracle.acceptDev(); + + assertEq(oracle.dev(), newDev); + assertEq(oracle.proposedDev(), address(0)); + + // Former dev can no longer call dev functions + vm.prank(dev); + vm.expectRevert(OnlyDev.selector); + oracle.setDevFeeReceiver(address(0x9999)); + + // New dev can call dev functions + vm.prank(newDev); + oracle.setDevFeeReceiver(address(0x9999)); + assertEq(oracle.devFeeReceiver(), address(0x9999)); + } + + function test_ProposeDev_RevertsIfNotDev() public { + vm.prank(user); + vm.expectRevert(OnlyDev.selector); + oracle.proposeDev(newDev); + } + + function test_ProposeDev_RevertsOnZeroAddress() public { + vm.prank(dev); + vm.expectRevert(ZeroAddress.selector); + oracle.proposeDev(address(0)); + } + + function test_AcceptDev_RevertsIfNotProposedDev() public { + vm.prank(dev); + oracle.proposeDev(newDev); + + vm.prank(user); + vm.expectRevert(OnlyProposedDev.selector); + oracle.acceptDev(); + } + + // --- OracleFactory Tests --- + + function test_Factory_ConstructorAndGetters() public view { + assertEq(factory.dev(), dev); + assertEq(factory.devFee(), DEFAULT_DEV_FEE); + assertEq(factory.MAX_DEV_FEE(), MAX_DEV_FEE); + } + + function test_Factory_Constructor_RevertsOnZeroDev() public { + vm.expectRevert(FactoryZeroAddress.selector); + new OracleFactory(owner, address(0), DEFAULT_DEV_FEE); + } + + function test_Factory_Constructor_RevertsIfFeeExceedsMax() public { + uint256 excessive = MAX_DEV_FEE + 1; + vm.expectRevert(abi.encodeWithSelector(FactoryDevFeeExceedsMax.selector, excessive, MAX_DEV_FEE)); + new OracleFactory(owner, dev, excessive); + } + + function test_Factory_TwoStepDevTransfer() public { + vm.prank(dev); + factory.proposeDev(newDev); + assertEq(factory.proposedDev(), newDev); + + vm.prank(user); + vm.expectRevert(FactoryOnlyProposedDev.selector); + factory.acceptDev(); + + vm.prank(newDev); + factory.acceptDev(); + assertEq(factory.dev(), newDev); + assertEq(factory.proposedDev(), address(0)); + } + + function test_Factory_CreateOracle_PropagatesDevAndFee() public { + vm.prank(user); + (address oracleAddr, ) = factory.createOracle( + "Created Feed", + "Created Desc", + address(token), + 3600, + 50000, + 300, + 300, + 500, + 60, + 5 + ); + + Oracle createdOracle = Oracle(payable(oracleAddr)); + assertEq(createdOracle.dev(), dev); + assertEq(createdOracle.devFeeReceiver(), dev); + assertEq(createdOracle.devFee(), DEFAULT_DEV_FEE); + } +}