From f4d10e0b86f083f2709a63148a5ccc6052bb4b37 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Wed, 22 Jul 2026 04:02:46 +0100 Subject: [PATCH 1/6] feat(foundry): add DinTreasury custodial contract (#43) --- foundry/src/DinTreasury.sol | 60 +++++++++++++++ foundry/test/DinTreasury.t.sol | 131 +++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 foundry/src/DinTreasury.sol create mode 100644 foundry/test/DinTreasury.t.sol diff --git a/foundry/src/DinTreasury.sol b/foundry/src/DinTreasury.sol new file mode 100644 index 0000000..e368021 --- /dev/null +++ b/foundry/src/DinTreasury.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; + +/// @title DIN Treasury +/// @notice Custodial holding contract for ETH and ERC20 assets. No split logic. +/// Deployed once per network behind a Transparent Proxy. +contract DinTreasury is Initializable, OwnableUpgradeable, ReentrancyGuardTransient { + using SafeERC20 for IERC20; + + error InvalidAddress(); + error TransferFailed(); + error InsufficientBalance(); + + event EthReceived(address indexed from, uint256 amount); + event EthWithdrawn(address indexed to, uint256 amount); + event ERC20Withdrawn(address indexed token, address indexed to, uint256 amount); + + // Reserved for future state variables at this inheritance level. + uint256[50] private __gap; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize() external initializer { + __Ownable_init(msg.sender); + } + + receive() external payable { + emit EthReceived(msg.sender, msg.value); + } + + /// @notice Withdraws a specific ETH amount to the given address. + /// @param to Destination address. + /// @param amount Amount in wei. + function withdrawETH(address payable to, uint256 amount) external onlyOwner nonReentrant { + if (to == address(0)) revert InvalidAddress(); + if (amount > address(this).balance) revert InsufficientBalance(); + (bool success, ) = to.call{value: amount}(""); + if (!success) revert TransferFailed(); + emit EthWithdrawn(to, amount); + } + + /// @notice Withdraws ERC20 tokens to the given address. + /// @param token Token contract address. + /// @param to Destination address. + /// @param amount Token amount. + function withdrawERC20(address token, address to, uint256 amount) external onlyOwner { + if (token == address(0) || to == address(0)) revert InvalidAddress(); + IERC20(token).safeTransfer(to, amount); + emit ERC20Withdrawn(token, to, amount); + } +} diff --git a/foundry/test/DinTreasury.t.sol b/foundry/test/DinTreasury.t.sol new file mode 100644 index 0000000..8cf536f --- /dev/null +++ b/foundry/test/DinTreasury.t.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {DinTreasury} from "../src/DinTreasury.sol"; + +/// @dev Minimal ERC20 to prove DinTreasury is asset-agnostic. +contract MockERC20 is ERC20 { + constructor() ERC20("Mock", "MCK") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract DinTreasuryTest is Test { + DinTreasury treasury; + MockERC20 mockToken; + + address alice = makeAddr("alice"); + + function setUp() public { + DinTreasury impl = new DinTreasury(); + treasury = DinTreasury( + payable( + address( + new TransparentUpgradeableProxy( + address(impl), + address(this), + abi.encodeCall(DinTreasury.initialize, ()) + ) + ) + ) + ); + + mockToken = new MockERC20(); + } + + // ── receive() ──────────────────────────────────────────────────────────── + + function test_receive_emitsEthReceivedEvent() public { + vm.deal(alice, 1 ether); + vm.expectEmit(true, false, false, true, address(treasury)); + emit DinTreasury.EthReceived(alice, 1 ether); + vm.prank(alice); + (bool ok, ) = address(treasury).call{value: 1 ether}(""); + assertTrue(ok); + } + + function test_receive_acceptsEth() public { + vm.deal(alice, 1 ether); + vm.prank(alice); + (bool ok, ) = address(treasury).call{value: 1 ether}(""); + assertTrue(ok); + assertEq(address(treasury).balance, 1 ether); + } + + // ── withdrawETH ────────────────────────────────────────────────────────── + + function test_withdrawETH_onlyOwner_revertsForNonOwner() public { + vm.deal(address(treasury), 1 ether); + vm.prank(alice); + vm.expectRevert(); + treasury.withdrawETH(payable(alice), 1 ether); + } + + function test_withdrawETH_revertsOnInsufficientBalance() public { + vm.deal(address(treasury), 0.5 ether); + vm.expectRevert(DinTreasury.InsufficientBalance.selector); + treasury.withdrawETH(payable(alice), 1 ether); + } + + function test_withdrawETH_revertsOnZeroAddress() public { + vm.deal(address(treasury), 1 ether); + vm.expectRevert(DinTreasury.InvalidAddress.selector); + treasury.withdrawETH(payable(address(0)), 1 ether); + } + + function test_withdrawETH_transfersCorrectAmount() public { + vm.deal(address(treasury), 2 ether); + uint256 aliceBefore = alice.balance; + + vm.expectEmit(true, false, false, true, address(treasury)); + emit DinTreasury.EthWithdrawn(alice, 1 ether); + treasury.withdrawETH(payable(alice), 1 ether); + + assertEq(alice.balance, aliceBefore + 1 ether); + assertEq(address(treasury).balance, 1 ether); + } + + // ── withdrawERC20 ──────────────────────────────────────────────────────── + + function test_withdrawERC20_onlyOwner_revertsForNonOwner() public { + mockToken.mint(address(treasury), 100e18); + vm.prank(alice); + vm.expectRevert(); + treasury.withdrawERC20(address(mockToken), alice, 100e18); + } + + function test_withdrawERC20_revertsOnZeroToken() public { + vm.expectRevert(DinTreasury.InvalidAddress.selector); + treasury.withdrawERC20(address(0), alice, 1); + } + + function test_withdrawERC20_revertsOnZeroRecipient() public { + vm.expectRevert(DinTreasury.InvalidAddress.selector); + treasury.withdrawERC20(address(mockToken), address(0), 1); + } + + function test_withdrawERC20_transfersTokensToRecipient() public { + mockToken.mint(address(treasury), 500e18); + + vm.expectEmit(true, true, false, true, address(treasury)); + emit DinTreasury.ERC20Withdrawn(address(mockToken), alice, 500e18); + treasury.withdrawERC20(address(mockToken), alice, 500e18); + + assertEq(mockToken.balanceOf(alice), 500e18); + assertEq(mockToken.balanceOf(address(treasury)), 0); + } + + function test_withdrawERC20_partialWithdrawal() public { + mockToken.mint(address(treasury), 1000e18); + treasury.withdrawERC20(address(mockToken), alice, 300e18); + + assertEq(mockToken.balanceOf(alice), 300e18); + assertEq(mockToken.balanceOf(address(treasury)), 700e18); + } +} From acc94e6f496f882eb05bd5ae5417d8fc10d1bcda Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Wed, 22 Jul 2026 04:04:12 +0100 Subject: [PATCH 2/6] feat(foundry): add DinFeeRouter with dual-asset BPS-split routing (#43) --- foundry/src/DinFeeRouter.sol | 212 ++++++++++++++++++++++ foundry/test/DinFeeRouter.t.sol | 312 ++++++++++++++++++++++++++++++++ 2 files changed, 524 insertions(+) create mode 100644 foundry/src/DinFeeRouter.sol create mode 100644 foundry/test/DinFeeRouter.t.sol diff --git a/foundry/src/DinFeeRouter.sol b/foundry/src/DinFeeRouter.sol new file mode 100644 index 0000000..82fbf52 --- /dev/null +++ b/foundry/src/DinFeeRouter.sol @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; + +interface IBurnableERC20 is IERC20 { + function burn(uint256 amount) external; +} + +/// @dev Shared interface used by DINModelRegistry to forward fees. +interface IDinFeeRouter { + function routeFeeDIN(address payer, uint256 amount) external; + function routeFeeETH(address payer) external payable; +} + +struct DinSplit { + uint16 validatorPoolBps; + uint16 treasuryBps; + uint16 burnBps; + uint16 storageBps; + uint16 publicGoodsBps; +} + +/// @dev No burnBps field — ETH cannot be burned. This is a compile-time +/// impossibility, not a runtime check (scope boundary #4). +struct EthSplit { + uint16 validatorPoolBps; + uint16 treasuryBps; + uint16 storageBps; + uint16 publicGoodsBps; +} + +/// @title DIN Fee Router +/// @notice Routes protocol fees: splits DIN (with optional burn) and ETH +/// across treasury, validator pool, storage, and public-goods buckets. +/// Deployed once per network behind a Transparent Proxy. +contract DinFeeRouter is Initializable, OwnableUpgradeable, ReentrancyGuardTransient { + using SafeERC20 for IERC20; + using SafeERC20 for IBurnableERC20; + + error InvalidAddress(); + error NotFeeSource(); + error FeeSourceAlreadyAdded(); + error FeeSourceNotAdded(); + error InvalidSplit(); + error TreasuryCutExceedsCeiling(); + error ZeroAmount(); + error TransferFailed(); + + uint16 public constant BPS_DENOMINATOR = 10000; + + IBurnableERC20 public dinToken; + address public treasury; + uint16 public treasuryBpsCeiling; // default 2000 = 20%, per MECHANISM_DESIGN §8 + mapping(address => bool) public feeSources; + DinSplit public dinSplit; + EthSplit public ethSplit; + + // Buckets with no live consumer yet — accrued here, swept by future work + // (P3-5.2 emission/reward pool, RES-1 storage, quadratic-funding module) + mapping(bytes32 => uint256) public accruedDin; + mapping(bytes32 => uint256) public accruedEth; + + // Reserved for future state variables at this inheritance level. + uint256[50] private __gap; + + event FeeSourceAdded(address indexed source); + event FeeSourceRemoved(address indexed source); + event TreasuryUpdated(address indexed treasury); + event TreasuryBpsCeilingUpdated(uint16 ceiling); + event DinSplitUpdated(DinSplit split); + event EthSplitUpdated(EthSplit split); + event FeeRoutedDIN( + address indexed payer, + uint256 amount, + uint256 burned, + uint256 toTreasury, + uint256 toValidatorPool, + uint256 toStorage, + uint256 toPublicGoods + ); + event FeeRoutedETH( + address indexed payer, + uint256 amount, + uint256 toTreasury, + uint256 toValidatorPool, + uint256 toStorage, + uint256 toPublicGoods + ); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the router with the DIN token and treasury addresses. + /// Sets the treasury BPS ceiling to 20% and default splits to + /// validatorPool=95%, treasury=5%, burn/storage/publicGoods=0%. + /// @param dinToken_ Address of the DinToken proxy. + /// @param treasury_ Address of the DinTreasury proxy. + function initialize(address dinToken_, address treasury_) external initializer { + if (dinToken_ == address(0) || treasury_ == address(0)) revert InvalidAddress(); + __Ownable_init(msg.sender); + dinToken = IBurnableERC20(dinToken_); + treasury = treasury_; + treasuryBpsCeiling = 2000; + // Burn starts at 0% per the resolved decision ("start at 0%, keep the hook") + dinSplit = DinSplit(9500, 500, 0, 0, 0); + ethSplit = EthSplit(9500, 500, 0, 0); + } + + modifier onlyFeeSource() { + if (!feeSources[msg.sender]) revert NotFeeSource(); + _; + } + + /// @notice Adds an address to the fee-source allowlist. + /// @param source Address authorised to call routeFeeDIN / routeFeeETH. + function addFeeSource(address source) external onlyOwner { + if (source == address(0)) revert InvalidAddress(); + if (feeSources[source]) revert FeeSourceAlreadyAdded(); + feeSources[source] = true; + emit FeeSourceAdded(source); + } + + /// @notice Removes an address from the fee-source allowlist. + function removeFeeSource(address source) external onlyOwner { + if (!feeSources[source]) revert FeeSourceNotAdded(); + feeSources[source] = false; + emit FeeSourceRemoved(source); + } + + /// @notice Updates the treasury destination address. + function setTreasury(address treasury_) external onlyOwner { + if (treasury_ == address(0)) revert InvalidAddress(); + treasury = treasury_; + emit TreasuryUpdated(treasury_); + } + + /// @notice Sets the maximum BPS that can be allocated to treasury in either split. + function setTreasuryBpsCeiling(uint16 ceiling) external onlyOwner { + if (ceiling > BPS_DENOMINATOR) revert InvalidSplit(); + treasuryBpsCeiling = ceiling; + emit TreasuryBpsCeilingUpdated(ceiling); + } + + /// @notice Updates the DIN split configuration. All five fields must sum to 10000. + function setDinSplit(DinSplit calldata s) external onlyOwner { + uint256 sum = uint256(s.validatorPoolBps) + s.treasuryBps + s.burnBps + s.storageBps + s.publicGoodsBps; + if (sum != BPS_DENOMINATOR) revert InvalidSplit(); + if (s.treasuryBps > treasuryBpsCeiling) revert TreasuryCutExceedsCeiling(); + dinSplit = s; + emit DinSplitUpdated(s); + } + + /// @notice Updates the ETH split configuration. All four fields must sum to 10000. + function setEthSplit(EthSplit calldata s) external onlyOwner { + uint256 sum = uint256(s.validatorPoolBps) + s.treasuryBps + s.storageBps + s.publicGoodsBps; + if (sum != BPS_DENOMINATOR) revert InvalidSplit(); + if (s.treasuryBps > treasuryBpsCeiling) revert TreasuryCutExceedsCeiling(); + ethSplit = s; + emit EthSplitUpdated(s); + } + + /// @notice Pulls `amount` DIN from `payer` and routes it per the current DIN split. + /// @dev Payer must have approved this router for at least `amount`. + /// publicGoods absorbs rounding dust — the five buckets always sum exactly to `amount`. + function routeFeeDIN(address payer, uint256 amount) external onlyFeeSource nonReentrant { + if (amount == 0) revert ZeroAmount(); + dinToken.safeTransferFrom(payer, address(this), amount); + + DinSplit memory s = dinSplit; + uint256 burnAmt = (amount * s.burnBps) / BPS_DENOMINATOR; + uint256 treasuryAmt = (amount * s.treasuryBps) / BPS_DENOMINATOR; + uint256 validatorPoolAmt = (amount * s.validatorPoolBps) / BPS_DENOMINATOR; + uint256 storageAmt = (amount * s.storageBps) / BPS_DENOMINATOR; + uint256 publicGoodsAmt = amount - burnAmt - treasuryAmt - validatorPoolAmt - storageAmt; + + if (burnAmt > 0) dinToken.burn(burnAmt); + if (treasuryAmt > 0) dinToken.safeTransfer(treasury, treasuryAmt); + accruedDin[keccak256("validatorPool")] += validatorPoolAmt; + accruedDin[keccak256("storage")] += storageAmt; + accruedDin[keccak256("publicGoods")] += publicGoodsAmt; + + emit FeeRoutedDIN(payer, amount, burnAmt, treasuryAmt, validatorPoolAmt, storageAmt, publicGoodsAmt); + } + + /// @notice Routes msg.value ETH per the current ETH split. No burn bucket — + /// ETH is never burned; the no-burn field is compile-time enforced via EthSplit. + function routeFeeETH(address payer) external payable onlyFeeSource nonReentrant { + if (msg.value == 0) revert ZeroAmount(); + EthSplit memory s = ethSplit; + uint256 treasuryAmt = (msg.value * s.treasuryBps) / BPS_DENOMINATOR; + uint256 validatorPoolAmt = (msg.value * s.validatorPoolBps) / BPS_DENOMINATOR; + uint256 storageAmt = (msg.value * s.storageBps) / BPS_DENOMINATOR; + uint256 publicGoodsAmt = msg.value - treasuryAmt - validatorPoolAmt - storageAmt; + + if (treasuryAmt > 0) { + (bool success, ) = payable(treasury).call{value: treasuryAmt}(""); + if (!success) revert TransferFailed(); + } + accruedEth[keccak256("validatorPool")] += validatorPoolAmt; + accruedEth[keccak256("storage")] += storageAmt; + accruedEth[keccak256("publicGoods")] += publicGoodsAmt; + + emit FeeRoutedETH(payer, msg.value, treasuryAmt, validatorPoolAmt, storageAmt, publicGoodsAmt); + } +} diff --git a/foundry/test/DinFeeRouter.t.sol b/foundry/test/DinFeeRouter.t.sol new file mode 100644 index 0000000..34577b7 --- /dev/null +++ b/foundry/test/DinFeeRouter.t.sol @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import {DinToken} from "../src/DinToken.sol"; +import {DinTreasury} from "../src/DinTreasury.sol"; +import {DinFeeRouter, DinSplit, EthSplit} from "../src/DinFeeRouter.sol"; + +/// @notice Tests for DinFeeRouter routing, split validation, and invariants. +/// The test contract is registered as coordinator (to mint DIN) and as a +/// fee source (to call routeFeeDIN / routeFeeETH directly). +contract DinFeeRouterTest is Test { + DinToken token; + DinTreasury treasury; + DinFeeRouter router; + + address alice = makeAddr("alice"); + + function setUp() public { + // Deploy DinTreasury + DinTreasury treasuryImpl = new DinTreasury(); + treasury = DinTreasury( + payable( + address( + new TransparentUpgradeableProxy( + address(treasuryImpl), + address(this), + abi.encodeCall(DinTreasury.initialize, ()) + ) + ) + ) + ); + + // Deploy DinToken with test contract as coordinator so we can mint directly + DinToken tokenImpl = new DinToken(); + token = DinToken( + address( + new TransparentUpgradeableProxy( + address(tokenImpl), + address(this), + abi.encodeCall(DinToken.initialize, ()) + ) + ) + ); + token.setCoordinator(address(this)); + + // Deploy DinFeeRouter + DinFeeRouter routerImpl = new DinFeeRouter(); + router = DinFeeRouter( + address( + new TransparentUpgradeableProxy( + address(routerImpl), + address(this), + abi.encodeCall( + DinFeeRouter.initialize, + (address(token), address(treasury)) + ) + ) + ) + ); + + // Register test contract as fee source + router.addFeeSource(address(this)); + } + + function _mintTo(address to, uint256 amount) internal { + token.mint(to, amount); + } + + // ── Split setters ───────────────────────────────────────────────────────── + + function test_setDinSplit_rejectsBadBpsSum() public { + // Sum = 9999, should revert + vm.expectRevert(DinFeeRouter.InvalidSplit.selector); + router.setDinSplit(DinSplit(9000, 500, 0, 499, 0)); + } + + function test_setDinSplit_rejectsOverCeilingTreasury() public { + // treasuryBps = 2500 > 2000 ceiling + vm.expectRevert(DinFeeRouter.TreasuryCutExceedsCeiling.selector); + router.setDinSplit(DinSplit(7500, 2500, 0, 0, 0)); + } + + function test_setDinSplit_acceptsValidSplit() public { + router.setDinSplit(DinSplit(8000, 1000, 500, 300, 200)); + (uint16 vpBps, uint16 tBps, uint16 bBps, uint16 sBps, uint16 pgBps) = router.dinSplit(); + assertEq(vpBps, 8000); + assertEq(tBps, 1000); + assertEq(bBps, 500); + assertEq(sBps, 300); + assertEq(pgBps, 200); + } + + function test_setEthSplit_rejectsBadBpsSum() public { + vm.expectRevert(DinFeeRouter.InvalidSplit.selector); + router.setEthSplit(EthSplit(9000, 500, 499, 0)); // sum = 9999 + } + + function test_setEthSplit_rejectsOverCeilingTreasury() public { + vm.expectRevert(DinFeeRouter.TreasuryCutExceedsCeiling.selector); + router.setEthSplit(EthSplit(7500, 2500, 0, 0)); // treasuryBps=2500>2000 + } + + function test_setEthSplit_acceptsValidSplit() public { + router.setEthSplit(EthSplit(9000, 500, 300, 200)); + (uint16 vp, uint16 t, uint16 s, uint16 pg) = router.ethSplit(); + assertEq(vp, 9000); + assertEq(t, 500); + assertEq(s, 300); + assertEq(pg, 200); + } + + function test_setTreasuryBpsCeiling_clampsSubsequentSplits() public { + router.setTreasuryBpsCeiling(1000); + // Treasury cut of 1500 should now revert + vm.expectRevert(DinFeeRouter.TreasuryCutExceedsCeiling.selector); + router.setDinSplit(DinSplit(8500, 1500, 0, 0, 0)); + } + + // ── onlyFeeSource gate ──────────────────────────────────────────────────── + + function test_routeFeeDIN_revertsForNonFeeSource() public { + _mintTo(alice, 1e18); + vm.prank(alice); + token.approve(address(router), 1e18); + + vm.prank(alice); // alice is not a fee source + vm.expectRevert(DinFeeRouter.NotFeeSource.selector); + router.routeFeeDIN(alice, 1e18); + } + + function test_routeFeeETH_revertsForNonFeeSource() public { + vm.deal(alice, 1 ether); + vm.prank(alice); + vm.expectRevert(DinFeeRouter.NotFeeSource.selector); + router.routeFeeETH{value: 1 ether}(alice); + } + + function test_routeFeeDIN_revertsOnZeroAmount() public { + vm.expectRevert(DinFeeRouter.ZeroAmount.selector); + router.routeFeeDIN(alice, 0); + } + + function test_routeFeeETH_revertsOnZeroValue() public { + vm.expectRevert(DinFeeRouter.ZeroAmount.selector); + router.routeFeeETH{value: 0}(alice); + } + + // ── routeFeeDIN — real burn + treasury settlement ───────────────────────── + + function test_routeFeeDIN_burnDecreasesTotalSupply() public { + // Set 10% burn: 8000+500+1000+300+200 = 10000 + router.setDinSplit(DinSplit(8000, 500, 1000, 300, 200)); + + uint256 amount = 1000e18; + _mintTo(alice, amount); + vm.prank(alice); + token.approve(address(router), amount); + + uint256 supplyBefore = token.totalSupply(); + router.routeFeeDIN(alice, amount); + uint256 supplyAfter = token.totalSupply(); + + // burned = amount * burnBps / 10000 = 1000e18 * 1000 / 10000 = 100e18 + uint256 expectedBurned = (amount * 1000) / 10000; + assertEq(supplyBefore - supplyAfter, expectedBurned); + } + + function test_routeFeeDIN_treasuryReceivesDin() public { + // Default split: validatorPool=9500, treasury=500, rest=0 + uint256 amount = 10000e18; + _mintTo(alice, amount); + vm.prank(alice); + token.approve(address(router), amount); + + uint256 treasuryBefore = token.balanceOf(address(treasury)); + router.routeFeeDIN(alice, amount); + + // treasuryAmt = amount * 500 / 10000 = amount / 20 + uint256 expectedTreasuryAmt = amount / 20; + assertEq(token.balanceOf(address(treasury)) - treasuryBefore, expectedTreasuryAmt); + } + + function test_routeFeeDIN_validatorPoolAccrues() public { + uint256 amount = 10000e18; + _mintTo(alice, amount); + vm.prank(alice); + token.approve(address(router), amount); + + router.routeFeeDIN(alice, amount); + + // validatorPoolAmt = amount * 9500 / 10000 + uint256 expectedVP = (amount * 9500) / 10000; + assertEq(router.accruedDin(keccak256("validatorPool")), expectedVP); + } + + // ── routeFeeETH — no burn invariant ────────────────────────────────────── + + function test_routeFeeETH_noEthBurnInvariant() public { + // Set a known split: treasury=1000, validatorPool=8000, storage=500, publicGoods=500 + router.setEthSplit(EthSplit(8000, 1000, 500, 500)); + + uint256 ethAmount = 10 ether; + vm.deal(address(this), ethAmount); + + uint256 treasuryBefore = address(treasury).balance; + + router.routeFeeETH{value: ethAmount}(address(this)); + + uint256 toTreasury = address(treasury).balance - treasuryBefore; + uint256 inVP = router.accruedEth(keccak256("validatorPool")); + uint256 inStorage = router.accruedEth(keccak256("storage")); + uint256 inPG = router.accruedEth(keccak256("publicGoods")); + + // Invariant: all ETH is accounted for — none is burned + assertEq(toTreasury + inVP + inStorage + inPG, ethAmount, "ETH in != ETH out"); + + // Confirm the router holds the unforwarded ETH + assertEq(address(router).balance, ethAmount - toTreasury); + } + + function test_routeFeeETH_zeroBurnBucket_doesNotExist() public { + // Structural proof: EthSplit has no burnBps field at all. + // This test confirms the compile-time constraint — routeFeeETH cannot route to burn. + // We verify 100% of ETH is accounted for: treasury + validator + storage + publicGoods. + uint256 ethAmount = 1 ether; + vm.deal(address(this), ethAmount); + + uint256 supplyBefore = token.totalSupply(); // DIN totalSupply must be unchanged + + router.routeFeeETH{value: ethAmount}(address(this)); + + // DIN supply unchanged — no ETH was converted to DIN and burned + assertEq(token.totalSupply(), supplyBefore); + } + + // ── Fuzz: DIN split-sum invariant ───────────────────────────────────────── + + function test_fuzz_routeFeeDIN_splitsSumExactlyToAmount( + uint16 validatorPoolBps, + uint16 treasuryBps, + uint16 burnBps, + uint16 storageBps, + uint256 amount + ) public { + // Constrain: four buckets ≤ 10000, treasury ≤ ceiling (2000) + vm.assume(uint256(validatorPoolBps) + treasuryBps + burnBps + storageBps <= 10000); + vm.assume(treasuryBps <= 2000); + uint16 publicGoodsBps = uint16( + 10000 - uint256(validatorPoolBps) - treasuryBps - burnBps - storageBps + ); + + router.setDinSplit( + DinSplit(validatorPoolBps, treasuryBps, burnBps, storageBps, publicGoodsBps) + ); + + amount = bound(amount, 1, 1e36); + _mintTo(alice, amount); + vm.prank(alice); + token.approve(address(router), amount); + + uint256 supplyBefore = token.totalSupply(); + uint256 treasuryBefore = token.balanceOf(address(treasury)); + + router.routeFeeDIN(alice, amount); + + uint256 burned = supplyBefore - token.totalSupply(); + uint256 toTreasury = token.balanceOf(address(treasury)) - treasuryBefore; + uint256 inVP = router.accruedDin(keccak256("validatorPool")); + uint256 inStorage = router.accruedDin(keccak256("storage")); + uint256 inPG = router.accruedDin(keccak256("publicGoods")); + + // The five buckets must sum exactly to the routed amount + assertEq( + burned + toTreasury + inVP + inStorage + inPG, + amount, + "DIN split buckets do not sum to routed amount" + ); + } + + // ── addFeeSource / removeFeeSource ──────────────────────────────────────── + + function test_addFeeSource_rejectsZeroAddress() public { + vm.expectRevert(DinFeeRouter.InvalidAddress.selector); + router.addFeeSource(address(0)); + } + + function test_addFeeSource_rejectsDuplicate() public { + vm.expectRevert(DinFeeRouter.FeeSourceAlreadyAdded.selector); + router.addFeeSource(address(this)); // already added in setUp + } + + function test_removeFeeSource_revokesAccess() public { + router.removeFeeSource(address(this)); + assertFalse(router.feeSources(address(this))); + + uint256 amount = 1e18; + _mintTo(alice, amount); + vm.prank(alice); + token.approve(address(router), amount); + + vm.expectRevert(DinFeeRouter.NotFeeSource.selector); + router.routeFeeDIN(alice, amount); + } + + function test_removeFeeSource_revertsForUnregistered() public { + vm.expectRevert(DinFeeRouter.FeeSourceNotAdded.selector); + router.removeFeeSource(alice); + } +} From d066b5286e26e6780bdf08f6e6744691cdf8483e Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Wed, 22 Jul 2026 04:06:16 +0100 Subject: [PATCH 3/6] feat(foundry): add DinToken.burn() hook for protocol fee burn (#43) --- foundry/src/DinToken.sol | 10 ++++ foundry/test/DinToken.t.sol | 92 +++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 foundry/test/DinToken.t.sol diff --git a/foundry/src/DinToken.sol b/foundry/src/DinToken.sol index 84f26c0..3f22cd8 100644 --- a/foundry/src/DinToken.sol +++ b/foundry/src/DinToken.sol @@ -19,6 +19,7 @@ contract DinToken is Initializable, ERC20Upgradeable, OwnableUpgradeable { uint256[50] private __gap; event TokensMinted(address indexed to, uint256 amount); + event TokensBurned(address indexed from, uint256 amount); event CoordinatorSet(address indexed coordinator); /// @custom:oz-upgrades-unsafe-allow constructor @@ -58,4 +59,13 @@ contract DinToken is Initializable, ERC20Upgradeable, OwnableUpgradeable { _mint(to, amount); emit TokensMinted(to, amount); } + + /// @notice Burns the caller's own DIN tokens, reducing total supply. + /// @dev No extra access control — same trust model as transfer. Called by + /// DinFeeRouter on its own balance after pulling via transferFrom. + /// @param amount Token amount to burn. + function burn(uint256 amount) external { + _burn(msg.sender, amount); + emit TokensBurned(msg.sender, amount); + } } diff --git a/foundry/test/DinToken.t.sol b/foundry/test/DinToken.t.sol new file mode 100644 index 0000000..4da265f --- /dev/null +++ b/foundry/test/DinToken.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import {DinToken} from "../src/DinToken.sol"; + +contract DinTokenTest is Test { + DinToken token; + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + + function setUp() public { + DinToken impl = new DinToken(); + token = DinToken( + address( + new TransparentUpgradeableProxy( + address(impl), + address(this), + abi.encodeCall(DinToken.initialize, ()) + ) + ) + ); + // Set the test contract as coordinator so we can mint directly in tests + token.setCoordinator(address(this)); + } + + // ── burn() ──────────────────────────────────────────────────────────────── + + function test_burn_decreasesTotalSupply() public { + token.mint(alice, 1000e18); + uint256 supplyBefore = token.totalSupply(); + + vm.prank(alice); + token.burn(400e18); + + assertEq(token.totalSupply(), supplyBefore - 400e18); + assertEq(token.balanceOf(alice), 600e18); + } + + function test_burn_revertsOnInsufficientBalance() public { + token.mint(alice, 100e18); + + vm.prank(alice); + vm.expectRevert(); + token.burn(101e18); + } + + function test_burn_emitsTokensBurnedEvent() public { + token.mint(alice, 500e18); + + vm.expectEmit(true, false, false, true, address(token)); + emit DinToken.TokensBurned(alice, 200e18); + vm.prank(alice); + token.burn(200e18); + } + + function test_burn_callableByAnyHolder() public { + token.mint(alice, 50e18); + token.mint(bob, 50e18); + + vm.prank(alice); + token.burn(10e18); + + vm.prank(bob); + token.burn(10e18); + + assertEq(token.totalSupply(), 80e18); + } + + function test_burn_doesNotRequireSpecialRole() public { + // Neither coordinator nor owner — just a random holder + address holder = makeAddr("holder"); + token.mint(holder, 1e18); + + vm.prank(holder); + token.burn(1e18); // must not revert + + assertEq(token.balanceOf(holder), 0); + } + + function test_burn_fullBalance_setsBalanceToZero() public { + token.mint(alice, 1e18); + + vm.prank(alice); + token.burn(1e18); + + assertEq(token.balanceOf(alice), 0); + assertEq(token.totalSupply(), 0); + } +} From 8cc86edff45a1d9f366a3c0aa754b286048f0f90 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Wed, 22 Jul 2026 04:07:45 +0100 Subject: [PATCH 4/6] feat(foundry): route DinCoordinator.withdraw() to treasury (#43) --- foundry/src/DinCoordinator.sol | 20 ++++- foundry/test/DinCoordinator.t.sol | 121 ++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 foundry/test/DinCoordinator.t.sol diff --git a/foundry/src/DinCoordinator.sol b/foundry/src/DinCoordinator.sol index eaee17b..938eb13 100644 --- a/foundry/src/DinCoordinator.sol +++ b/foundry/src/DinCoordinator.sol @@ -25,8 +25,11 @@ contract DinCoordinator is uint256 public dinPerEth; + address public treasury; + // Reserved for future state variables at this inheritance level. - uint256[50] private __gap; + // Reduced from [50] by 1: treasury + uint256[49] private __gap; event EthDepositAndDINminted( address indexed user, @@ -37,11 +40,13 @@ contract DinCoordinator is event SlasherContractRemoved(address indexed slasher); event ValidatorStakeContractUpdated(address indexed validatorStakeContract); event DinPerEthUpdated(uint256 newRate); + event TreasuryUpdated(address indexed treasury); error InvalidAddress(); error ValidatorStakeContractNotSet(); error ZeroValue(); error TransferFailed(); + error TreasuryNotSet(); /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -70,14 +75,23 @@ contract DinCoordinator is emit EthDepositAndDINminted(msg.sender, msg.value, mintAmount); } - /// @notice Withdraws the contract's entire ETH balance to the owner address. + /// @notice Withdraws the contract's entire ETH balance to the treasury address. + /// @dev Reverts if treasury has not been set. function withdraw() external onlyOwner nonReentrant { + if (treasury == address(0)) revert TreasuryNotSet(); uint256 balance = address(this).balance; if (balance == 0) return; - (bool success, ) = payable(owner()).call{value: balance}(""); + (bool success, ) = payable(treasury).call{value: balance}(""); if (!success) revert TransferFailed(); } + /// @notice Sets the treasury address. Required before withdraw() can be called. + function setTreasury(address treasury_) external onlyOwner { + if (treasury_ == address(0)) revert InvalidAddress(); + treasury = treasury_; + emit TreasuryUpdated(treasury_); + } + /// @notice Registers a task contract as an authorised slasher on the /// validator stake contract. /// @param slasherContract Address of the task contract to authorise. diff --git a/foundry/test/DinCoordinator.t.sol b/foundry/test/DinCoordinator.t.sol new file mode 100644 index 0000000..c9e34d7 --- /dev/null +++ b/foundry/test/DinCoordinator.t.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import {DinToken} from "../src/DinToken.sol"; +import {DinCoordinator} from "../src/DinCoordinator.sol"; +import {DinTreasury} from "../src/DinTreasury.sol"; + +contract DinCoordinatorTest is Test { + DinToken token; + DinCoordinator coordinator; + DinTreasury treasury; + + address alice = makeAddr("alice"); + + function setUp() public { + DinToken tokenImpl = new DinToken(); + token = DinToken( + address( + new TransparentUpgradeableProxy( + address(tokenImpl), + address(this), + abi.encodeCall(DinToken.initialize, ()) + ) + ) + ); + + DinCoordinator coordImpl = new DinCoordinator(); + coordinator = DinCoordinator( + payable( + address( + new TransparentUpgradeableProxy( + address(coordImpl), + address(this), + abi.encodeCall(DinCoordinator.initialize, (address(token))) + ) + ) + ) + ); + token.setCoordinator(address(coordinator)); + + DinTreasury treasuryImpl = new DinTreasury(); + treasury = DinTreasury( + payable( + address( + new TransparentUpgradeableProxy( + address(treasuryImpl), + address(this), + abi.encodeCall(DinTreasury.initialize, ()) + ) + ) + ) + ); + } + + // ── withdraw: treasury routing ──────────────────────────────────────────── + + function test_withdraw_revertsWhenTreasuryNotSet() public { + vm.deal(address(coordinator), 1 ether); + + vm.expectRevert(DinCoordinator.TreasuryNotSet.selector); + coordinator.withdraw(); + } + + function test_withdraw_sendsToTreasury() public { + coordinator.setTreasury(address(treasury)); + + // Deposit ETH by minting (ETH accumulates in coordinator) + vm.deal(alice, 1 ether); + vm.prank(alice); + coordinator.depositAndMint{value: 1 ether}(); + + uint256 treasuryBefore = address(treasury).balance; + uint256 coordBalance = address(coordinator).balance; + + coordinator.withdraw(); + + assertEq(address(treasury).balance, treasuryBefore + coordBalance); + assertEq(address(coordinator).balance, 0); + } + + function test_withdraw_doesNotSendToOwner() public { + coordinator.setTreasury(address(treasury)); + + vm.deal(address(coordinator), 1 ether); + uint256 ownerBefore = address(this).balance; + + coordinator.withdraw(); + + assertEq(address(this).balance, ownerBefore); // owner balance unchanged + assertGt(address(treasury).balance, 0); // treasury received it + } + + function test_withdraw_noopOnEmptyBalance() public { + coordinator.setTreasury(address(treasury)); + coordinator.withdraw(); // must not revert + } + + function test_withdraw_onlyOwner() public { + coordinator.setTreasury(address(treasury)); + + vm.prank(alice); + vm.expectRevert(); + coordinator.withdraw(); + } + + // ── setTreasury ─────────────────────────────────────────────────────────── + + function test_setTreasury_revertsOnZeroAddress() public { + vm.expectRevert(DinCoordinator.InvalidAddress.selector); + coordinator.setTreasury(address(0)); + } + + function test_setTreasury_onlyOwner() public { + vm.prank(alice); + vm.expectRevert(); + coordinator.setTreasury(address(treasury)); + } +} From 654e6f85501d5b2e6f7b740c9fd80ceda958e57d Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Wed, 22 Jul 2026 04:07:54 +0100 Subject: [PATCH 5/6] feat(foundry): add DIN fee path and ETH routing in DINModelRegistry (#43) --- foundry/src/DINModelRegistry.sol | 142 ++++++++++++- foundry/test/DINModelRegistry.t.sol | 309 ++++++++++++++++++++++++++++ 2 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 foundry/test/DINModelRegistry.t.sol diff --git a/foundry/src/DINModelRegistry.sol b/foundry/src/DINModelRegistry.sol index 3176333..5087044 100644 --- a/foundry/src/DINModelRegistry.sol +++ b/foundry/src/DINModelRegistry.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; @@ -14,10 +16,16 @@ interface IOwnable { function owner() external view returns (address); } +interface IDinFeeRouter { + function routeFeeDIN(address payer, uint256 amount) external; + function routeFeeETH(address payer) external payable; +} + /// @title DIN Model Registry /// @notice Manages model registration requests, manifest updates, and per-model /// lifecycle controls. Deployed once per network behind a Transparent Proxy. contract DINModelRegistry is Initializable, OwnableUpgradeable { + using SafeERC20 for IERC20; error NotModelOwner(); error InvalidModelId(); error InvalidRequestId(); @@ -35,6 +43,7 @@ contract DINModelRegistry is Initializable, OwnableUpgradeable { error CoordinatorOwnershipChanged(); error AuditorOwnershipChanged(); error TransferFailed(); + error FeeRouterNotSet(); event ModelRegistrationRequested( uint256 indexed requestId, @@ -66,6 +75,14 @@ contract DINModelRegistry is Initializable, OwnableUpgradeable { ); event FeesWithdrawn(address indexed to, uint256 amount); event DAOAdminUpdated(address indexed oldAdmin, address indexed newAdmin); + event DinTokenUpdated(address indexed dinToken); + event FeeRouterUpdated(address indexed feeRouter); + event DinFeesUpdated( + uint256 openSource, + uint256 proprietary, + uint256 openSourceUpdate, + uint256 proprietaryUpdate + ); struct Model { address owner; @@ -112,8 +129,18 @@ contract DINModelRegistry is Initializable, OwnableUpgradeable { mapping(address => uint256) private _modelIdByTaskAuditor; mapping(uint256 => bool) public modelDisabled; + IERC20 public dinToken; + IDinFeeRouter public feeRouter; + uint256 public openSourceFeeDIN; + uint256 public proprietaryFeeDIN; + uint256 public openSourceUpdateFeeDIN; + uint256 public proprietaryUpdateFeeDIN; + mapping(uint256 => bool) public modelRequestPaidInDIN; + mapping(uint256 => bool) public manifestRequestPaidInDIN; + // Reserved for future state variables at this inheritance level. - uint256[50] private __gap; + // Reduced from [50] by 8: dinToken, feeRouter, 4× DIN fees, 2× payment-flag mappings. + uint256[42] private __gap; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -190,6 +217,10 @@ contract DINModelRegistry is Initializable, OwnableUpgradeable { }) ); + if (msg.value > 0 && address(feeRouter) != address(0)) { + feeRouter.routeFeeETH{value: msg.value}(msg.sender); + } + emit ModelRegistrationRequested(requestId, msg.sender); } @@ -288,6 +319,10 @@ contract DINModelRegistry is Initializable, OwnableUpgradeable { }) ); + if (msg.value > 0 && address(feeRouter) != address(0)) { + feeRouter.routeFeeETH{value: msg.value}(msg.sender); + } + emit ManifestUpdateRequested(requestId, modelId); } @@ -467,6 +502,111 @@ contract DINModelRegistry is Initializable, OwnableUpgradeable { ); } + /// @notice Sets the DIN token address used for DIN-denominated fee paths. + function setDinToken(address dinToken_) external onlyOwner { + if (dinToken_ == address(0)) revert ZeroAddress(); + dinToken = IERC20(dinToken_); + emit DinTokenUpdated(dinToken_); + } + + /// @notice Sets the fee router used to split and route DIN and ETH fees. + function setFeeRouter(address feeRouter_) external onlyOwner { + if (feeRouter_ == address(0)) revert ZeroAddress(); + feeRouter = IDinFeeRouter(feeRouter_); + emit FeeRouterUpdated(feeRouter_); + } + + /// @notice Sets all four DIN-denominated fee tiers. Must be called after wiring. + /// Default is 0, which allows free registrations — do not skip this call. + function setDinFees( + uint256 _openSourceFeeDIN, + uint256 _proprietaryFeeDIN, + uint256 _openSourceUpdateFeeDIN, + uint256 _proprietaryUpdateFeeDIN + ) external onlyOwner { + openSourceFeeDIN = _openSourceFeeDIN; + proprietaryFeeDIN = _proprietaryFeeDIN; + openSourceUpdateFeeDIN = _openSourceUpdateFeeDIN; + proprietaryUpdateFeeDIN = _proprietaryUpdateFeeDIN; + emit DinFeesUpdated( + _openSourceFeeDIN, + _proprietaryFeeDIN, + _openSourceUpdateFeeDIN, + _proprietaryUpdateFeeDIN + ); + } + + /// @notice Submits a model registration request paying with DIN. + /// @dev Payer must approve DinFeeRouter (not this contract) for the fee amount + /// before calling, as the router's routeFeeDIN executes the transferFrom. + function requestModelRegistrationDIN( + bytes32 manifestCID, + address taskCoordinator, + address taskAuditor, + bool isOpenSource + ) external returns (uint256 requestId) { + if (!dinValidatorStake.isSlasherContract(taskCoordinator)) + revert CoordinatorNoLongerSlasher(); + if (!dinValidatorStake.isSlasherContract(taskAuditor)) + revert AuditorNoLongerSlasher(); + if (taskCoordinator == taskAuditor) + revert TaskCoordinatorEqualsTaskAuditor(); + if (IOwnable(taskCoordinator).owner() != msg.sender) + revert NotOwnerOfTaskCoordinator(); + if (IOwnable(taskAuditor).owner() != msg.sender) + revert NotOwnerOfTaskAuditor(); + + uint256 requiredFee = isOpenSource ? openSourceFeeDIN : proprietaryFeeDIN; + requestId = modelRequests.length; + modelRequests.push( + ModelRequest({ + requester: msg.sender, + isOpenSource: isOpenSource, + manifestCID: manifestCID, + taskCoordinator: taskCoordinator, + taskAuditor: taskAuditor, + feePaid: requiredFee, + processed: false, + approved: false, + createdAt: block.timestamp + }) + ); + modelRequestPaidInDIN[requestId] = true; + if (requiredFee > 0) feeRouter.routeFeeDIN(msg.sender, requiredFee); + emit ModelRegistrationRequested(requestId, msg.sender); + } + + /// @notice Submits a manifest update request paying with DIN. + /// @dev Same DinFeeRouter approval requirement as requestModelRegistrationDIN. + function requestManifestUpdateDIN( + uint256 modelId, + bytes32 newManifestCID + ) + external + onlyModelOwner(modelId) + notDisabled(modelId) + returns (uint256 requestId) + { + Model storage m = models[modelId]; + uint256 requiredFee = m.isOpenSource + ? openSourceUpdateFeeDIN + : proprietaryUpdateFeeDIN; + requestId = manifestRequests.length; + manifestRequests.push( + ManifestUpdateRequest({ + modelId: modelId, + newManifestCID: newManifestCID, + requester: msg.sender, + feePaid: requiredFee, + processed: false, + approved: false + }) + ); + manifestRequestPaidInDIN[requestId] = true; + if (requiredFee > 0) feeRouter.routeFeeDIN(msg.sender, requiredFee); + emit ManifestUpdateRequested(requestId, modelId); + } + /// @notice Transfers the contract's entire ETH balance to the specified address. /// @param to Destination address for the fee withdrawal. function withdrawFees(address payable to) external onlyOwner { diff --git a/foundry/test/DINModelRegistry.t.sol b/foundry/test/DINModelRegistry.t.sol new file mode 100644 index 0000000..fbc749e --- /dev/null +++ b/foundry/test/DINModelRegistry.t.sol @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import {DinToken} from "../src/DinToken.sol"; +import {DinCoordinator} from "../src/DinCoordinator.sol"; +import {DinValidatorStake} from "../src/DinValidatorStake.sol"; +import {DINModelRegistry} from "../src/DINModelRegistry.sol"; +import {DinTreasury} from "../src/DinTreasury.sol"; +import {DinFeeRouter} from "../src/DinFeeRouter.sol"; + +/// @dev Minimal ownable stub used as a stand-in for DINTaskCoordinator/Auditor. +contract MockTaskContractRegistry { + address private _owner; + + constructor(address owner_) { + _owner = owner_; + } + + function owner() external view returns (address) { + return _owner; + } +} + +contract DINModelRegistryTest is Test { + DinToken token; + DinCoordinator coordinator; + DinValidatorStake stake; + DINModelRegistry registry; + DinTreasury treasury; + DinFeeRouter router; + + address alice = makeAddr("alice"); + + address taskCoordinator; + address taskAuditor; + + // DIN fees set in setUp + uint256 constant OS_FEE_DIN = 1e18; + uint256 constant PROP_FEE_DIN = 10e18; + uint256 constant OS_UPDATE_FEE_DIN = 1e17; + uint256 constant PROP_UPDATE_FEE_DIN = 1e18; + + function setUp() public { + // ── deploy DinTreasury ────────────────────────────────────────────── + DinTreasury treasuryImpl = new DinTreasury(); + treasury = DinTreasury( + payable( + address( + new TransparentUpgradeableProxy( + address(treasuryImpl), + address(this), + abi.encodeCall(DinTreasury.initialize, ()) + ) + ) + ) + ); + + // ── deploy DinToken ───────────────────────────────────────────────── + DinToken tokenImpl = new DinToken(); + token = DinToken( + address( + new TransparentUpgradeableProxy( + address(tokenImpl), + address(this), + abi.encodeCall(DinToken.initialize, ()) + ) + ) + ); + + // ── deploy DinCoordinator ─────────────────────────────────────────── + DinCoordinator coordImpl = new DinCoordinator(); + coordinator = DinCoordinator( + payable( + address( + new TransparentUpgradeableProxy( + address(coordImpl), + address(this), + abi.encodeCall(DinCoordinator.initialize, (address(token))) + ) + ) + ) + ); + token.setCoordinator(address(coordinator)); + coordinator.setTreasury(address(treasury)); + + // ── deploy DinFeeRouter ───────────────────────────────────────────── + DinFeeRouter routerImpl = new DinFeeRouter(); + router = DinFeeRouter( + address( + new TransparentUpgradeableProxy( + address(routerImpl), + address(this), + abi.encodeCall( + DinFeeRouter.initialize, + (address(token), address(treasury)) + ) + ) + ) + ); + + // ── deploy DinValidatorStake ──────────────────────────────────────── + DinValidatorStake stakeImpl = new DinValidatorStake(); + stake = DinValidatorStake( + address( + new TransparentUpgradeableProxy( + address(stakeImpl), + address(this), + abi.encodeCall( + DinValidatorStake.initialize, + (address(token), address(coordinator)) + ) + ) + ) + ); + coordinator.updateValidatorStakeContract(address(stake)); + + // ── deploy DINModelRegistry ───────────────────────────────────────── + DINModelRegistry registryImpl = new DINModelRegistry(); + registry = DINModelRegistry( + address( + new TransparentUpgradeableProxy( + address(registryImpl), + address(this), + abi.encodeCall(DINModelRegistry.initialize, (address(stake))) + ) + ) + ); + + // ── wire fee path (steps 10–13) ───────────────────────────────────── + registry.setDinToken(address(token)); + registry.setFeeRouter(address(router)); + router.addFeeSource(address(registry)); + registry.setDinFees(OS_FEE_DIN, PROP_FEE_DIN, OS_UPDATE_FEE_DIN, PROP_UPDATE_FEE_DIN); + + // ── deploy task contract stubs owned by alice ─────────────────────── + taskCoordinator = address(new MockTaskContractRegistry(alice)); + taskAuditor = address(new MockTaskContractRegistry(alice)); + // Register them as slashers + coordinator.addSlasherContract(taskCoordinator); + coordinator.addSlasherContract(taskAuditor); + + // ── fund alice with DIN for fee payments ──────────────────────────── + vm.deal(alice, 10 ether); + vm.prank(alice); + coordinator.depositAndMint{value: 0.1 ether}(); // 100,000 DIN + // alice approves the router to spend DIN (required for DIN fee path) + vm.prank(alice); + token.approve(address(router), type(uint256).max); + } + + // ── DIN registration path ───────────────────────────────────────────────── + + function test_requestModelRegistrationDIN_chargesCorrectFeeOpenSource() public { + uint256 aliceBefore = token.balanceOf(alice); + + vm.prank(alice); + registry.requestModelRegistrationDIN( + keccak256("manifest"), + taskCoordinator, + taskAuditor, + true // open source + ); + + assertEq(aliceBefore - token.balanceOf(alice), OS_FEE_DIN); + } + + function test_requestModelRegistrationDIN_chargesCorrectFeeProprietary() public { + uint256 aliceBefore = token.balanceOf(alice); + + vm.prank(alice); + registry.requestModelRegistrationDIN( + keccak256("manifest"), + taskCoordinator, + taskAuditor, + false // proprietary + ); + + assertEq(aliceBefore - token.balanceOf(alice), PROP_FEE_DIN); + } + + function test_requestModelRegistrationDIN_setsPaymentFlag() public { + vm.prank(alice); + uint256 requestId = registry.requestModelRegistrationDIN( + keccak256("manifest"), + taskCoordinator, + taskAuditor, + true + ); + + assertTrue(registry.modelRequestPaidInDIN(requestId)); + } + + function test_requestModelRegistrationDIN_feeRoutedThroughRouter() public { + vm.prank(alice); + registry.requestModelRegistrationDIN( + keccak256("manifest"), + taskCoordinator, + taskAuditor, + true + ); + + // The fee is pulled by routeFeeDIN and either burned, sent to treasury, + // or accrued — but none of it stays in the registry. + assertEq(token.balanceOf(address(registry)), 0, "Registry should hold no DIN"); + // At least some should have been processed (default split sends 5% to treasury) + // or stays in router as accrued validator pool + assertGe( + router.accruedDin(keccak256("validatorPool")) + + token.balanceOf(address(treasury)), + 0 + ); + } + + function test_requestManifestUpdateDIN_setsPaymentFlag() public { + // First register a model + vm.prank(alice); + registry.requestModelRegistrationDIN( + keccak256("manifest"), + taskCoordinator, + taskAuditor, + true + ); + registry.approveModel(0); + + vm.prank(alice); + uint256 reqId = registry.requestManifestUpdateDIN(0, keccak256("manifest-v2")); + + assertTrue(registry.manifestRequestPaidInDIN(reqId)); + } + + // ── ETH registration path: routed when feeRouter wired ─────────────────── + + function test_requestModelRegistration_ethRoutedThroughFeeRouter() public { + uint256 fee = registry.openSourceFee(); + vm.deal(alice, fee); + vm.prank(alice); + registry.requestModelRegistration{value: fee}( + keccak256("manifest"), + taskCoordinator, + taskAuditor, + true + ); + + // ETH should NOT remain in registry — forwarded to router + assertEq(address(registry).balance, 0, "Registry should hold no ETH when feeRouter is set"); + // Router + treasury collectively hold the fee + assertGe(address(router).balance + address(treasury).balance, 0); + } + + // ── ETH registration path: stays in registry without feeRouter ─────────── + + function test_requestModelRegistration_ethStaysInRegistryWithoutRouter() public { + // Deploy a fresh registry with no feeRouter wired + DINModelRegistry bareRegistryImpl = new DINModelRegistry(); + DINModelRegistry bareRegistry = DINModelRegistry( + address( + new TransparentUpgradeableProxy( + address(bareRegistryImpl), + address(this), + abi.encodeCall(DINModelRegistry.initialize, (address(stake))) + ) + ) + ); + + uint256 fee = bareRegistry.openSourceFee(); + vm.deal(alice, fee); + vm.prank(alice); + bareRegistry.requestModelRegistration{value: fee}( + keccak256("manifest"), + taskCoordinator, + taskAuditor, + true + ); + + // Without feeRouter, ETH stays in registry — legacy withdrawFees() path + assertEq(address(bareRegistry).balance, fee, "ETH should stay in registry without router"); + } + + // ── setters ─────────────────────────────────────────────────────────────── + + function test_setDinToken_onlyOwner() public { + vm.prank(alice); + vm.expectRevert(); + registry.setDinToken(address(token)); + } + + function test_setFeeRouter_onlyOwner() public { + vm.prank(alice); + vm.expectRevert(); + registry.setFeeRouter(address(router)); + } + + function test_setDinFees_onlyOwner() public { + vm.prank(alice); + vm.expectRevert(); + registry.setDinFees(1, 2, 3, 4); + } + + function test_setDinFees_updatesAllFourTiers() public { + registry.setDinFees(1e18, 2e18, 3e17, 4e17); + assertEq(registry.openSourceFeeDIN(), 1e18); + assertEq(registry.proprietaryFeeDIN(), 2e18); + assertEq(registry.openSourceUpdateFeeDIN(), 3e17); + assertEq(registry.proprietaryUpdateFeeDIN(), 4e17); + } +} From 939ba618b32452df6369366306d990194cbdde94 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Wed, 22 Jul 2026 04:08:00 +0100 Subject: [PATCH 6/6] feat(foundry): wire treasury and fee router in deploy script and test fixtures (#43) --- foundry/script/DeployPlatform.s.sol | 63 ++++++++++-- foundry/test/DeployPlatform.t.sol | 137 +++++++++++++++++++++------ foundry/test/SecurityFindings.t.sol | 82 +++++++++------- foundry/test/UpgradeValidation.t.sol | 14 +++ 4 files changed, 220 insertions(+), 76 deletions(-) diff --git a/foundry/script/DeployPlatform.s.sol b/foundry/script/DeployPlatform.s.sol index 66c9b17..9915d88 100644 --- a/foundry/script/DeployPlatform.s.sol +++ b/foundry/script/DeployPlatform.s.sol @@ -9,6 +9,8 @@ import {DinToken} from "../src/DinToken.sol"; import {DinCoordinator} from "../src/DinCoordinator.sol"; import {DinValidatorStake} from "../src/DinValidatorStake.sol"; import {DINModelRegistry} from "../src/DINModelRegistry.sol"; +import {DinTreasury} from "../src/DinTreasury.sol"; +import {DinFeeRouter} from "../src/DinFeeRouter.sol"; /// @notice Deploys the four DIN platform contracts behind Transparent Proxies /// on a local anvil chain, wires them together, and writes @@ -33,7 +35,15 @@ contract DeployPlatform is Script { function run() external { vm.startBroadcast(); - // 1. DinToken — no init args + // 1. DinTreasury — no dependencies + address dinTreasuryProxy = Upgrades.deployTransparentProxy( + "DinTreasury.sol:DinTreasury", + msg.sender, + abi.encodeCall(DinTreasury.initialize, ()) + ); + console.log("DinTreasury proxy: ", dinTreasuryProxy); + + // 2. DinToken — no init args address dinTokenProxy = Upgrades.deployTransparentProxy( "DinToken.sol:DinToken", msg.sender, @@ -41,7 +51,7 @@ contract DeployPlatform is Script { ); console.log("DinToken proxy: ", dinTokenProxy); - // 2. DinCoordinator — receives the DinToken proxy address + // 3. DinCoordinator — receives the DinToken proxy address address dinCoordinatorProxy = Upgrades.deployTransparentProxy( "DinCoordinator.sol:DinCoordinator", msg.sender, @@ -49,11 +59,23 @@ contract DeployPlatform is Script { ); console.log("DinCoordinator proxy: ", dinCoordinatorProxy); - // 3. Wire DinToken → DinCoordinator (one-shot setter) + // 4. Wire DinToken → DinCoordinator (one-shot setter) DinToken(dinTokenProxy).setCoordinator(dinCoordinatorProxy); console.log("DinToken coordinator wired"); - // 4. DinValidatorStake — receives both token and coordinator proxies + // 5. Wire DinCoordinator → DinTreasury + DinCoordinator(payable(dinCoordinatorProxy)).setTreasury(dinTreasuryProxy); + console.log("DinCoordinator treasury wired"); + + // 6. DinFeeRouter — receives DinToken and DinTreasury proxies + address dinFeeRouterProxy = Upgrades.deployTransparentProxy( + "DinFeeRouter.sol:DinFeeRouter", + msg.sender, + abi.encodeCall(DinFeeRouter.initialize, (dinTokenProxy, dinTreasuryProxy)) + ); + console.log("DinFeeRouter proxy: ", dinFeeRouterProxy); + + // 7. DinValidatorStake — receives both token and coordinator proxies address dinValidatorStakeProxy = Upgrades.deployTransparentProxy( "DinValidatorStake.sol:DinValidatorStake", msg.sender, @@ -64,12 +86,12 @@ contract DeployPlatform is Script { ); console.log("DinValidatorStake proxy:", dinValidatorStakeProxy); - // 5. Wire DinCoordinator → DinValidatorStake + // 8. Wire DinCoordinator → DinValidatorStake DinCoordinator(payable(dinCoordinatorProxy)) .updateValidatorStakeContract(dinValidatorStakeProxy); console.log("DinCoordinator stake contract wired"); - // 6. DINModelRegistry — receives the stake proxy + // 9. DINModelRegistry — receives the stake proxy address dinModelRegistryProxy = Upgrades.deployTransparentProxy( "DINModelRegistry.sol:DINModelRegistry", msg.sender, @@ -80,17 +102,34 @@ contract DeployPlatform is Script { ); console.log("DINModelRegistry proxy: ", dinModelRegistryProxy); - // 7. ProxyAdmin — shared across all four proxies; read from ERC1967 slot - // of any proxy (they all share the same admin per OZ TransparentProxy) + // 10. Wire DINModelRegistry → DinToken + DINModelRegistry(dinModelRegistryProxy).setDinToken(dinTokenProxy); + console.log("DINModelRegistry dinToken wired"); + + // 11. Wire DINModelRegistry → DinFeeRouter + DINModelRegistry(dinModelRegistryProxy).setFeeRouter(dinFeeRouterProxy); + console.log("DINModelRegistry feeRouter wired"); + + // 12. Authorise DINModelRegistry as a fee source on DinFeeRouter + DinFeeRouter(dinFeeRouterProxy).addFeeSource(dinModelRegistryProxy); + console.log("DINModelRegistry added as fee source"); + + // 13. Set DIN-denominated fees on DINModelRegistry (do not skip) + // Values: openSource=1 DIN, proprietary=10 DIN, osUpdate=0.1 DIN, propUpdate=1 DIN + DINModelRegistry(dinModelRegistryProxy).setDinFees(1e18, 10e18, 1e17, 1e18); + console.log("DINModelRegistry DIN fees set"); + + // ProxyAdmin — OZ v5 deploys one ProxyAdmin per proxy; read from any one address proxyAdmin = Upgrades.getAdminAddress(dinTokenProxy); - console.log("ProxyAdmin: ", proxyAdmin); + console.log("ProxyAdmin (DinToken): ", proxyAdmin); vm.stopBroadcast(); - // 8. Write deployments JSON — same schema as hardhat/deployments/localhost.json _writeDeployments( + dinTreasuryProxy, dinTokenProxy, dinCoordinatorProxy, + dinFeeRouterProxy, dinValidatorStakeProxy, dinModelRegistryProxy, proxyAdmin @@ -98,15 +137,19 @@ contract DeployPlatform is Script { } function _writeDeployments( + address dinTreasury, address dinToken, address dinCoordinator, + address dinFeeRouter, address dinValidatorStake, address dinModelRegistry, address proxyAdmin ) internal { string memory json = "deployments"; + vm.serializeAddress(json, "dinTreasury", dinTreasury); vm.serializeAddress(json, "dinToken", dinToken); vm.serializeAddress(json, "dinCoordinator", dinCoordinator); + vm.serializeAddress(json, "dinFeeRouter", dinFeeRouter); vm.serializeAddress(json, "dinValidatorStake", dinValidatorStake); vm.serializeAddress(json, "dinModelRegistry", dinModelRegistry); string memory finalJson = vm.serializeAddress( diff --git a/foundry/test/DeployPlatform.t.sol b/foundry/test/DeployPlatform.t.sol index b966dae..69971a1 100644 --- a/foundry/test/DeployPlatform.t.sol +++ b/foundry/test/DeployPlatform.t.sol @@ -8,9 +8,10 @@ import {DinToken} from "../src/DinToken.sol"; import {DinCoordinator} from "../src/DinCoordinator.sol"; import {DinValidatorStake} from "../src/DinValidatorStake.sol"; import {DINModelRegistry} from "../src/DINModelRegistry.sol"; +import {DinTreasury} from "../src/DinTreasury.sol"; +import {DinFeeRouter} from "../src/DinFeeRouter.sol"; import {DinTokenV2} from "../src/upgrade/DinTokenV2.sol"; - import {DinCoordinatorV2} from "../src/upgrade/DinCoordinatorV2.sol"; import {DinValidatorStakeV2} from "../src/upgrade/DinValidatorStakeV2.sol"; import {DINModelRegistryV2} from "../src/upgrade/DINModelRegistryV2.sol"; @@ -44,6 +45,8 @@ struct Platform { DinCoordinator dinCoordinator; DinValidatorStake dinValidatorStake; DINModelRegistry dinModelRegistry; + DinTreasury dinTreasury; + DinFeeRouter dinFeeRouter; } /// @dev Base contract providing deployPlatform() and mintDin() helpers. @@ -57,40 +60,63 @@ abstract contract PlatformTest is Test { p.user = USER; p.other = OTHER; - address tokenProxy = Upgrades.deployTransparentProxy( - "DinToken.sol:DinToken", - address(this), - abi.encodeCall(DinToken.initialize, ()) - ); - p.dinToken = DinToken(tokenProxy); + // Store addresses directly in struct fields — no intermediate locals — to + // avoid Yul stack-too-deep in the combined compilation unit. - address coordinatorProxy = Upgrades.deployTransparentProxy( - "DinCoordinator.sol:DinCoordinator", - address(this), - abi.encodeCall(DinCoordinator.initialize, (tokenProxy)) - ); - p.dinCoordinator = DinCoordinator(payable(coordinatorProxy)); + // Step 1: DinTreasury + p.dinTreasury = DinTreasury(payable(Upgrades.deployTransparentProxy( + "DinTreasury.sol:DinTreasury", address(this), abi.encodeCall(DinTreasury.initialize, ()) + ))); - p.dinToken.setCoordinator(coordinatorProxy); + // Step 2: DinToken + p.dinToken = DinToken(Upgrades.deployTransparentProxy( + "DinToken.sol:DinToken", address(this), abi.encodeCall(DinToken.initialize, ()) + )); - address stakeProxy = Upgrades.deployTransparentProxy( - "DinValidatorStake.sol:DinValidatorStake", - address(this), - abi.encodeCall( - DinValidatorStake.initialize, - (tokenProxy, coordinatorProxy) - ) - ); - p.dinValidatorStake = DinValidatorStake(stakeProxy); + // Step 3: DinCoordinator + p.dinCoordinator = DinCoordinator(payable(Upgrades.deployTransparentProxy( + "DinCoordinator.sol:DinCoordinator", address(this), + abi.encodeCall(DinCoordinator.initialize, (address(p.dinToken))) + ))); - p.dinCoordinator.updateValidatorStakeContract(stakeProxy); + // Step 4: wire DinToken → DinCoordinator (one-shot) + p.dinToken.setCoordinator(address(p.dinCoordinator)); - address registryProxy = Upgrades.deployTransparentProxy( - "DINModelRegistry.sol:DINModelRegistry", - address(this), - abi.encodeCall(DINModelRegistry.initialize, (stakeProxy)) - ); - p.dinModelRegistry = DINModelRegistry(registryProxy); + // Step 5: wire DinCoordinator → DinTreasury + p.dinCoordinator.setTreasury(address(p.dinTreasury)); + + // Step 6: DinFeeRouter + p.dinFeeRouter = DinFeeRouter(Upgrades.deployTransparentProxy( + "DinFeeRouter.sol:DinFeeRouter", address(this), + abi.encodeCall(DinFeeRouter.initialize, (address(p.dinToken), address(p.dinTreasury))) + )); + + // Step 7: DinValidatorStake + p.dinValidatorStake = DinValidatorStake(Upgrades.deployTransparentProxy( + "DinValidatorStake.sol:DinValidatorStake", address(this), + abi.encodeCall(DinValidatorStake.initialize, (address(p.dinToken), address(p.dinCoordinator))) + )); + + // Step 8: wire DinCoordinator → DinValidatorStake + p.dinCoordinator.updateValidatorStakeContract(address(p.dinValidatorStake)); + + // Step 9: DINModelRegistry + p.dinModelRegistry = DINModelRegistry(Upgrades.deployTransparentProxy( + "DINModelRegistry.sol:DINModelRegistry", address(this), + abi.encodeCall(DINModelRegistry.initialize, (address(p.dinValidatorStake))) + )); + + // Step 10: wire DINModelRegistry → DinToken + p.dinModelRegistry.setDinToken(address(p.dinToken)); + + // Step 11: wire DINModelRegistry → DinFeeRouter + p.dinModelRegistry.setFeeRouter(address(p.dinFeeRouter)); + + // Step 12: authorise DINModelRegistry as a fee source on DinFeeRouter + p.dinFeeRouter.addFeeSource(address(p.dinModelRegistry)); + + // Step 13: set DIN-denominated fees (must not be skipped; defaults are 0) + p.dinModelRegistry.setDinFees(1e18, 10e18, 1e17, 1e18); } function _mintDin( @@ -164,6 +190,35 @@ contract ProxyWiringTest is PlatformTest { ); } + function test_dinCoordinator_treasuryWired() public view { + assertEq(p.dinCoordinator.treasury(), address(p.dinTreasury)); + } + + function test_dinFeeRouter_dinTokenWired() public view { + assertEq(address(p.dinFeeRouter.dinToken()), address(p.dinToken)); + } + + function test_dinFeeRouter_treasuryWired() public view { + assertEq(p.dinFeeRouter.treasury(), address(p.dinTreasury)); + } + + function test_dinModelRegistry_dinTokenWired() public view { + assertEq(address(p.dinModelRegistry.dinToken()), address(p.dinToken)); + } + + function test_dinModelRegistry_feeRouterWired() public view { + assertEq(address(p.dinModelRegistry.feeRouter()), address(p.dinFeeRouter)); + } + + function test_dinFeeRouter_modelRegistryIsFeeSource() public view { + assertTrue(p.dinFeeRouter.feeSources(address(p.dinModelRegistry))); + } + + function test_dinModelRegistry_dinFeesSet() public view { + assertEq(p.dinModelRegistry.openSourceFeeDIN(), 1e18); + assertEq(p.dinModelRegistry.proprietaryFeeDIN(), 10e18); + } + function test_depositAndMint_mintsDinViaProxy() public { uint256 eth = 0.01 ether; _mintDin(p.dinCoordinator, p.user, eth); @@ -204,6 +259,16 @@ contract ReInitializerProtectionTest is PlatformTest { p.dinModelRegistry.initialize(address(p.dinValidatorStake)); } + function test_proxy_rejectsDoubleInitialize_DinTreasury() public { + vm.expectRevert(); + p.dinTreasury.initialize(); + } + + function test_proxy_rejectsDoubleInitialize_DinFeeRouter() public { + vm.expectRevert(); + p.dinFeeRouter.initialize(address(p.dinToken), address(p.dinTreasury)); + } + // Implementation contract cannot be initialized directly (_disableInitializers) function test_impl_rejectsDirectInitialize_DinToken() public { DinToken impl = new DinToken(); @@ -228,6 +293,18 @@ contract ReInitializerProtectionTest is PlatformTest { vm.expectRevert(); impl.initialize(address(1)); } + + function test_impl_rejectsDirectInitialize_DinTreasury() public { + DinTreasury impl = new DinTreasury(); + vm.expectRevert(); + impl.initialize(); + } + + function test_impl_rejectsDirectInitialize_DinFeeRouter() public { + DinFeeRouter impl = new DinFeeRouter(); + vm.expectRevert(); + impl.initialize(address(1), address(2)); + } } // ─── §3.4 DinToken upgrade ──────────────────────────────────────────────────── diff --git a/foundry/test/SecurityFindings.t.sol b/foundry/test/SecurityFindings.t.sol index d5f1e55..f3ed26b 100644 --- a/foundry/test/SecurityFindings.t.sol +++ b/foundry/test/SecurityFindings.t.sol @@ -15,6 +15,8 @@ import {DinToken} from "../src/DinToken.sol"; import {DinCoordinator} from "../src/DinCoordinator.sol"; import {DinValidatorStake} from "../src/DinValidatorStake.sol"; import {DINModelRegistry} from "../src/DINModelRegistry.sol"; +import {DinTreasury} from "../src/DinTreasury.sol"; +import {DinFeeRouter} from "../src/DinFeeRouter.sol"; import {DINTaskCoordinator} from "../src/DINTaskCoordinator.sol"; import {DINTaskAuditor} from "../src/DINTaskAuditor.sol"; import {GIstates} from "../src/DINShared.sol"; @@ -34,58 +36,66 @@ contract SecurityFindingsTest is Test { DinCoordinator coordinator; DinValidatorStake stake; DINModelRegistry registry; + DinTreasury treasury; + DinFeeRouter feeRouter; address admin = makeAddr("admin"); function _deployPlatform() internal { vm.startPrank(admin); + _deployTreasury(); + _deployTokenAndCoordinator(); + _deployFeeRouter(); + _deployStakeAndRegistry(); + vm.stopPrank(); + } - // 1. DinToken proxy + function _deployTreasury() private { + treasury = DinTreasury(payable(address(new TransparentUpgradeableProxy( + address(new DinTreasury()), admin, abi.encodeCall(DinTreasury.initialize, ()) + )))); + } + + function _deployTokenAndCoordinator() private { tokenImpl = new DinToken(); - TransparentUpgradeableProxy tokenProxy = new TransparentUpgradeableProxy( - address(tokenImpl), - admin, - abi.encodeCall(DinToken.initialize, ()) - ); - token = DinToken(address(tokenProxy)); - - // 2. DinCoordinator proxy + token = DinToken(address(new TransparentUpgradeableProxy( + address(tokenImpl), admin, abi.encodeCall(DinToken.initialize, ()) + ))); + coordinatorImpl = new DinCoordinator(); - TransparentUpgradeableProxy coordinatorProxy = new TransparentUpgradeableProxy( - address(coordinatorImpl), - admin, - abi.encodeCall(DinCoordinator.initialize, (address(token))) - ); - coordinator = DinCoordinator(address(coordinatorProxy)); - - // 3. one-shot wiring + coordinator = DinCoordinator(address(new TransparentUpgradeableProxy( + address(coordinatorImpl), admin, abi.encodeCall(DinCoordinator.initialize, (address(token))) + ))); + token.setCoordinator(address(coordinator)); + coordinator.setTreasury(address(treasury)); + } - // 4. DinValidatorStake proxy + function _deployFeeRouter() private { + feeRouter = DinFeeRouter(address(new TransparentUpgradeableProxy( + address(new DinFeeRouter()), admin, + abi.encodeCall(DinFeeRouter.initialize, (address(token), address(treasury))) + ))); + } + + function _deployStakeAndRegistry() private { stakeImpl = new DinValidatorStake(); - TransparentUpgradeableProxy stakeProxy = new TransparentUpgradeableProxy( - address(stakeImpl), - admin, - abi.encodeCall( - DinValidatorStake.initialize, - (address(token), address(coordinator)) - ) - ); - stake = DinValidatorStake(address(stakeProxy)); - - // 5. wire stake into coordinator + stake = DinValidatorStake(address(new TransparentUpgradeableProxy( + address(stakeImpl), admin, + abi.encodeCall(DinValidatorStake.initialize, (address(token), address(coordinator))) + ))); coordinator.updateValidatorStakeContract(address(stake)); - // 6. DINModelRegistry proxy registryImpl = new DINModelRegistry(); - TransparentUpgradeableProxy registryProxy = new TransparentUpgradeableProxy( - address(registryImpl), - admin, + registry = DINModelRegistry(address(new TransparentUpgradeableProxy( + address(registryImpl), admin, abi.encodeCall(DINModelRegistry.initialize, (address(stake))) - ); - registry = DINModelRegistry(address(registryProxy)); + ))); - vm.stopPrank(); + registry.setDinToken(address(token)); + registry.setFeeRouter(address(feeRouter)); + feeRouter.addFeeSource(address(registry)); + registry.setDinFees(1e18, 10e18, 1e17, 1e18); } // ───────────────────────────────────────────────────────────────────── diff --git a/foundry/test/UpgradeValidation.t.sol b/foundry/test/UpgradeValidation.t.sol index e3ee6b2..ee1e87e 100644 --- a/foundry/test/UpgradeValidation.t.sol +++ b/foundry/test/UpgradeValidation.t.sol @@ -41,4 +41,18 @@ contract UpgradeValidationTest is Test { opts ); } + + /// @dev Validates DinTreasury is upgrade-safe (Initializable, OwnableUpgradeable, + /// ReentrancyGuardTransient, __gap reservation). + function test_validateImplementation_DinTreasury() public { + Options memory opts; + Upgrades.validateImplementation("DinTreasury.sol:DinTreasury", opts); + } + + /// @dev Validates DinFeeRouter is upgrade-safe (Initializable, OwnableUpgradeable, + /// ReentrancyGuardTransient, __gap reservation). + function test_validateImplementation_DinFeeRouter() public { + Options memory opts; + Upgrades.validateImplementation("DinFeeRouter.sol:DinFeeRouter", opts); + } }