Skip to content

Latest commit

 

History

History
518 lines (405 loc) · 27.8 KB

task_210726_5.md

File metadata and controls

518 lines (405 loc) · 27.8 KB

Task: Platform Tokenomics & Fee Infrastructure — Staking Params, Jailing, Treasury, Fee Router (Issues #37/#42/#43)

ID: task_210726_5 Assigned to: Robbert Abimbola (@robertocarlous) Created: 2026-07-21 Status: Open Repo: https://github.com/InfiniteZeroFoundation/DevNet (base branch: develop) Roadmap ref: P3-5.1, P3-5.2 (partial — emission excluded, see below), P3-5.3 Dates: Jul 21–27, 2026 (5 working days; deadline Monday, July 27)


Context

Issues #37 (staking), #42 (tokenomics), #43 (fees & treasury) are now design-resolved — Abraham Nash (CEO, white-paper author) answered the open decisions in MECHANISM_DESIGN.md §9 via a Slack sync on 2026-07-21; those resolutions are already recorded in Developer/design/MECHANISM_DESIGN.md §9, staking-design.md §5, and tokenomics-design.md §6, and mirrored into the three issue bodies themselves. Nothing is blocking contract work anymore except writing it.

This task combines all three issues into one 5-day budget because the underlying contract work is genuinely coupled: a new DinTreasury and DinFeeRouter are shared infrastructure that both tokenomics (#42) and fees (#43) need, and none of it is large enough on its own to justify a separate task.

Scope boundaries — read before starting:

  1. Platform-level contracts only. Per-model stake bounds, concurrent-registration caps, and the per-GI validator network fee (ETH) currently live in task-level contracts (DINTaskCoordinator/DINTaskAuditor, deployed per-model by model owners) — out of scope. New parameters are stored at the platform level now; wiring them into task-contract enforcement is explicit follow-up work for later, not this task.
  2. DinEmission is excluded entirely. Its decay/epoch numbers depend on the still-open P3-5.2 reward-split simulation, and its mint triggers need task-level GI-lifecycle hooks. It's a separate future task once both land.
  3. Delegation and tombstoning are out of scope for 2.0 (Abraham: parked to P5+; jail/blacklist is sufficient, no permanent removal). Do not build either.
  4. Burning is DIN-only, never ETH. Native ETH has no burn mechanism — nothing can reduce its "supply" the way DinToken.burn() reduces DIN's. Wherever a design doc says "burn network fees" (which are ETH), that share routes to Treasury instead. This is enforced structurally below, not just by convention.
  5. No dincli/Python changes — this is a Solidity-only task.

If anything here conflicts with what you find in the live code, flag it — this spec was written against develop as of 2026-07-21.


1. New contract: DinTreasury.sol

A pure holding/spend contract — no split logic, no routing decisions, just custody. Zero dependencies, so it deploys first in the wiring order (§7).

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);

    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);
    }

    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);
    }

    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);
    }
}

No DAO gating for 2.0 — onlyOwner only, consistent with every other platform contract right now.


2. New contract: DinFeeRouter.sol — the centerpiece

Depends on DinToken (burn + transfers) and DinTreasury (forwarding target). Owns all split-percentage logic; DinTreasury stays a dumb holding contract.

Two split-config shapes — deliberately different:

struct DinSplit { uint16 validatorPoolBps; uint16 treasuryBps; uint16 burnBps; uint16 storageBps; uint16 publicGoodsBps; }
struct EthSplit { uint16 validatorPoolBps; uint16 treasuryBps; uint16 storageBps; uint16 publicGoodsBps; } // NOTE: no burnBps field

EthSplit has no burn field at all — not a runtime check, a compile-time impossibility. This is the direct implementation of scope boundary #4: it should be structurally impossible to configure ETH burning, not just discouraged.

interface IBurnableERC20 is IERC20 { function burn(uint256 amount) external; }

contract DinFeeRouter is Initializable, OwnableUpgradeable, ReentrancyGuardTransient {
    using SafeERC20 for IERC20;

    error InvalidAddress();
    error NotFeeSource();
    error FeeSourceAlreadyAdded();
    error FeeSourceNotAdded();
    error InvalidSplit();
    error TreasuryCutExceedsCeiling();
    error ZeroAmount();

    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;

    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(); }

    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();
        _;
    }

    function addFeeSource(address source) external onlyOwner {
        if (source == address(0)) revert InvalidAddress();
        if (feeSources[source]) revert FeeSourceAlreadyAdded();
        feeSources[source] = true;
        emit FeeSourceAdded(source);
    }

    function removeFeeSource(address source) external onlyOwner {
        if (!feeSources[source]) revert FeeSourceNotAdded();
        feeSources[source] = false;
        emit FeeSourceRemoved(source);
    }

    function setTreasury(address treasury_) external onlyOwner {
        if (treasury_ == address(0)) revert InvalidAddress();
        treasury = treasury_;
        emit TreasuryUpdated(treasury_);
    }

    function setTreasuryBpsCeiling(uint16 ceiling) external onlyOwner {
        if (ceiling > BPS_DENOMINATOR) revert InvalidSplit();
        treasuryBpsCeiling = ceiling;
        emit TreasuryBpsCeilingUpdated(ceiling);
    }

    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);
    }

    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` (payer must have approved this router) and routes it.
    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;
        // publicGoods absorbs rounding dust — invariant: all five sum to `amount` exactly
        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. No burn bucket exists — ETH is never burned.
    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);
    }
}

Interpretation call — confirm at kickoff: DinCoordinator's deposited ETH (from depositAndMint) routes directly to DinTreasury (see §4), bypassing this router entirely — it's mint backing, not a fee flow. DinCoordinator is not added to feeSources. If you read the resolved decisions differently, raise it before building.


3. DinToken.sol — add burn(uint256)

event TokensBurned(address indexed from, uint256 amount);

function burn(uint256 amount) external {
    _burn(msg.sender, amount);
    emit TokensBurned(msg.sender, amount);
}

Self-burn only, no extra access control — same trust model as transfer (you can only move/destroy your own balance). DinFeeRouter burns its own just-received balance (pull via transferFrom, then burn() on itself), so no burnFrom/allowance-based burn is needed. Don't build one.


4. DinCoordinator.sol — mint cap, faucet retirement, treasury routing

New state:

uint256 public mintCap;      // 0 = uncapped (DevNet default)
uint256 public totalMinted;
bool public faucetRetired;   // one-way, mirrors DinToken.setCoordinator's existing one-shot pattern
address public treasury;
function setMintCap(uint256 newCap) external onlyOwner {
    if (faucetRetired) revert FaucetRetired();
    mintCap = newCap;
    emit MintCapUpdated(newCap);
}

function retireFaucet() external onlyOwner {
    if (faucetRetired) revert FaucetRetired();
    faucetRetired = true;
    emit FaucetRetiredEvent();
}

function setTreasury(address treasury_) external onlyOwner {
    if (treasury_ == address(0)) revert InvalidAddress();
    treasury = treasury_;
    emit TreasuryUpdated(treasury_);
}

function depositAndMint() external payable nonReentrant {
    if (faucetRetired) revert FaucetRetired();
    if (msg.value == 0) revert ZeroValue();
    uint256 mintAmount = (msg.value * dinPerEth) / 1e18;
    if (mintCap > 0 && totalMinted + mintAmount > mintCap) revert MintCapExceeded();
    totalMinted += mintAmount;
    dinToken.mint(msg.sender, mintAmount);
    emit EthDepositAndDINminted(msg.sender, msg.value, mintAmount);
}

function withdraw() external onlyOwner nonReentrant {
    if (treasury == address(0)) revert TreasuryNotSet();
    uint256 balance = address(this).balance;
    if (balance == 0) return;
    (bool success, ) = payable(treasury).call{value: balance}("");
    if (!success) revert TransferFailed();
}

MintCapExceeded is a hard revert, not a capped partial-mint — a partial mint for partial ETH would need a refund path that doesn't exist, so keep it simple. withdraw() now sends to treasury instead of owner(), per the resolved decision ("route to treasury, not owner() withdrawal").

Maps to the resolved policy: DevNet stays uncapped/active (mintCap = 0, faucetRetired = false — defaults), testnet sets a cap (setMintCap), mainnet retires it (retireFaucet()).


5. DinValidatorStake.sol — governable params + real jailing

5a. Constants → governable storage

uint256 public MIN_STAKE;      // was constant, set to 10 * 1e18 in initialize()
uint64 public UNBONDING_PERIOD; // was constant, set to 7 days in initialize()

function setMinStake(uint256 newMinStake) external onlyOwner {
    if (newMinStake == 0) revert InvalidMinStake();
    MIN_STAKE = newMinStake;
    emit MinStakeUpdated(newMinStake);
}

function setUnbondingPeriod(uint64 newPeriod) external onlyOwner {
    if (newPeriod == 0) revert InvalidUnbondingPeriod();
    UNBONDING_PERIOD = newPeriod;
    emit UnbondingPeriodUpdated(newPeriod);
}

Same external getter selectors (a public storage var produces the same ABI as the public constant it replaces) — no ABI break. minStake() becomes external view instead of external pure.

5b. New unenforced storage (scope boundary #1 — stored, not wired to anything yet)

struct ModelStakeBounds { uint256 min; uint256 max; }
mapping(uint256 => ModelStakeBounds) public modelMinStakeBounds; // keyed by model id
uint256 public maxConcurrentRegistrationsPerStakeUnit; // 0 = unset

function setModelStakeBounds(uint256 modelId, uint256 min, uint256 max) external onlyOwner {
    if (min > max) revert InvalidStakeBounds();
    modelMinStakeBounds[modelId] = ModelStakeBounds(min, max);
    emit ModelStakeBoundsUpdated(modelId, min, max);
}

function setMaxConcurrentRegistrationsPerStakeUnit(uint256 value) external onlyOwner {
    maxConcurrentRegistrationsPerStakeUnit = value;
    emit MaxConcurrentRegistrationsPerStakeUnitUpdated(value);
}

Test these as round-trip + bounds-validation only — explicitly assert that staking/registration behavior is unaffected by these values, since nothing consumes them yet. This pair is the release valve if the 5-day budget runs tight — see Notes.

5c. Real jailing (currently dead code)

Jailed and jailedUntil already exist in ValidatorStatus/ValidatorInfo and are read by _syncValidatorStatus and unblacklistValidator — but nothing currently writes jailedUntil or sets status = Jailed. Abraham resolved: jail/blacklist only for 2.0, no permanent tombstoning.

error InvalidJailDuration();
error NotJailed();
error JailPeriodNotExpired();
error StakeBelowFloor();

event ValidatorJailed(address indexed validator, uint64 jailedUntil, bytes32 indexed reason, address indexed slasher);
event ValidatorReactivated(address indexed validator);

function jailValidator(address validator, uint64 duration, bytes32 reason)
    external onlySlasherContract
{
    if (validator == address(0)) revert InvalidAddress();
    if (duration == 0) revert InvalidJailDuration();
    ValidatorInfo storage v = validators[validator];
    if (v.status == ValidatorStatus.Blacklisted) revert ValidatorIsBlacklisted();
    uint64 newJailedUntil = uint64(block.timestamp) + duration;
    if (newJailedUntil > v.jailedUntil) v.jailedUntil = newJailedUntil; // extend, never shorten
    v.status = ValidatorStatus.Jailed;
    emit ValidatorJailed(validator, v.jailedUntil, reason, msg.sender);
}

function reactivate() external nonReentrant {
    ValidatorInfo storage v = validators[msg.sender];
    if (v.status != ValidatorStatus.Jailed) revert NotJailed();
    if (block.timestamp < v.jailedUntil) revert JailPeriodNotExpired();
    if (v.activeStake < MIN_STAKE) revert StakeBelowFloor();
    v.jailedUntil = 0;
    _syncValidatorStatus(v);
    emit ValidatorReactivated(msg.sender);
}

Same onlySlasherContract allowlist slash() already uses — no new access-control concept. _syncValidatorStatus's existing early-return guard (skip recompute while jailed) needs no changes; reactivate() is the only path that clears jailedUntil, so it's the only way out of Jailed. Not touching stake/unstake/claimUnstaked beyond their existing Blacklisted gate — jailing's effect comes from isValidatorActive() already excluding non-Active validators from task eligibility.

__gap shrinks from [50] to [46] to account for the 4 new storage slots (2 converted constants + the bounds mapping + the concurrent-registration cap). Get this right or UpgradeValidation.t.sol will catch it — don't guess, check the actual slot count.

Not building: delegation (P5+), tombstoning (jail/blacklist is enough for 2.0).


6. DINModelRegistry.sol — DIN fee path alongside ETH

Migration is additive — the existing ETH fee path is kept for at least one more release, per the resolved decision.

New state:

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;

New setters setDinToken, setFeeRouter, setDinFees(uint256,uint256,uint256,uint256) — all onlyOwner, mirroring the existing setFees pattern. setDinFees must be called at deploy time — the new DIN fee amounts default to 0, and skipping this step means free DIN-denominated registrations.

New additive entrypoints (existing ETH entrypoints and their structs — ModelRequest, ManifestUpdateRequest — stay untouched; OZ's upgrade-safety validator flags any change to a struct already used in a storage array, so track DIN-vs-ETH via the separate mappings above instead of adding fields):

function requestModelRegistrationDIN(
    bytes32 manifestCID, address taskCoordinator, address taskAuditor, bool isOpenSource
) external returns (uint256 requestId) {
    // same slasher/ownership validation as the existing ETH path
    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);
}

...and the mirror requestManifestUpdateDIN(uint256 modelId, bytes32 newManifestCID).

UX note to document loudly: paying in DIN requires dinToken.approve(feeRouterAddress, amount) first — the allowance is against DinFeeRouter, not DINModelRegistry, since routeFeeDIN's transferFrom executes in the router's context. Same shape as the existing approve(stakeAddress, ...) → stake() pattern, just a different spender.

Existing ETH entrypoints — forward to the router if wired, else keep today's behavior:

function requestModelRegistration(...) external payable returns (uint256 requestId) {
    // ... unchanged validation ...
    modelRequests.push(...); // feePaid: msg.value, unchanged
    if (msg.value > 0 && address(feeRouter) != address(0)) {
        feeRouter.routeFeeETH{value: msg.value}(msg.sender);
    }
    // else: ETH stays in this contract's balance — legacy withdrawFees() path still works
    emit ModelRegistrationRequested(requestId, msg.sender);
}

Routing is skipped, not reverted, when feeRouter is unset — this is what keeps every existing test calling these functions passing without modification, while still routing for real once deployed with the router wired.


7. Deployment / wiring order

Extends the current DeployPlatform.s.sol / DeployPlatform.t.sol sequence (verified directly against the live script):

 1. Deploy DinTreasury proxy        → initialize()                                     [NEW]
 2. Deploy DinToken proxy           → initialize()                                      [unchanged]
 3. Deploy DinCoordinator proxy     → initialize(dinTokenAddress)                       [unchanged]
 4. DinToken.setCoordinator(dinCoordinatorAddress)                                      [unchanged, one-shot]
 5. DinCoordinator.setTreasury(treasuryAddress)                                         [NEW]
 6. Deploy DinFeeRouter proxy       → initialize(dinTokenAddress, treasuryAddress)      [NEW]
 7. Deploy DinValidatorStake proxy  → initialize(dinTokenAddress, dinCoordinatorAddress) [unchanged]
 8. DinCoordinator.updateValidatorStakeContract(stakeAddress)                           [unchanged]
 9. Deploy DINModelRegistry proxy   → initialize(stakeAddress)                          [unchanged]
10. DINModelRegistry.setDinToken(dinTokenAddress)                                       [NEW]
11. DINModelRegistry.setFeeRouter(feeRouterAddress)                                     [NEW]
12. DinFeeRouter.addFeeSource(dinModelRegistryAddress)                                  [NEW]
13. DINModelRegistry.setDinFees(openSourceFeeDIN, proprietaryFeeDIN,
      openSourceUpdateFeeDIN, proprietaryUpdateFeeDIN)                                  [NEW — do not skip]

Both DeployPlatform.s.sol and DeployPlatform.t.sol's _deployPlatform() fixture need the same new lines — they're independent copies of this sequence today, not shared code. SecurityFindings.t.sol has its own separate from-scratch fixture that also needs the same wiring, or its tests will silently exercise the fallback (unrouted) branch instead of the real path.


8. Test plan

Following the existing DeployPlatform.t.sol / UpgradeValidation.t.sol / SecurityFindings.t.sol conventions:

  • DeployPlatform.t.sol (extend): Platform struct gains dinTreasury, dinFeeRouter; wiring assertions for every new step in §7; two new ReInitializerProtectionTest entries.
  • UpgradeValidation.t.sol (extend): test_validateImplementation_DinTreasury, test_validateImplementation_DinFeeRouter.
  • DinTreasury.t.sol (new): receive() + event; withdrawETH/withdrawERC20 onlyOwner-gated; insufficient-balance revert; holds an arbitrary ERC20 to prove asset-agnosticism.
  • DinFeeRouter.t.sol (new, highest-value file): split setters reject bad bps sums / over-ceiling treasury cuts; onlyFeeSource gate; routeFeeDIN actually decreases DinToken.totalSupply() by the burn amount (proves a real burn, not just accounting) and increases Treasury's DIN balance by the treasury amount; routeFeeETH — explicit invariant test that no burn ever occurs (total ETH in = treasury + validatorPool + storage + publicGoods, exactly); a fuzz test proving the five DIN buckets always sum exactly to the input amount across random splits/amounts (this is the single most valuable test in the task).
  • DinValidatorStake.t.sol (extend or new): full jail lifecycle — stake → jail → early reactivate() fails → vm.warp past jailedUntil → insufficient-stake reactivate() fails → top up → succeeds → isValidatorActive() true again; jailValidator only callable by a registered slasher; blocked against Blacklisted; extends (never shortens) an existing jail; setMinStake/setUnbondingPeriod onlyOwner; changing UNBONDING_PERIOD doesn't retroactively affect an in-flight pending withdrawal's already-computed withdrawAvailableAt.
  • DinCoordinator.t.sol (extend or new): mint-cap enforcement (revert past cap, succeed at exactly the cap, uncapped by default); retireFaucet one-way; withdraw() sweeps to treasury not owner().
  • DinToken.t.sol (extend or new): burn() callable by any holder; reverts on insufficient balance; totalSupply() decreases correctly.
  • DINModelRegistry.t.sol (extend or new): DIN path (correct fee tier, correct transferFrom pull, modelRequestPaidInDIN flag); ETH path both with and without a wired fee router (both branches must work).

Deliverables

  • DinTreasury.sol implemented and tested
  • DinFeeRouter.sol implemented and tested (including the split-sum fuzz test and the no-ETH-burn invariant test)
  • DinToken.burn() implemented and tested
  • DinCoordinator mint-cap/retire-faucet/treasury-routing implemented and tested
  • DinValidatorStake governable params + jail/reactivate implemented and tested, __gap correctly resized
  • DINModelRegistry DIN fee path implemented alongside the existing ETH path, both tested
  • DeployPlatform.s.sol, DeployPlatform.t.sol, and SecurityFindings.t.sol all updated with the new wiring order
  • UpgradeValidation.t.sol extended for both new contracts
  • forge build + forge test clean
  • PR opened against develop, referencing #37, #42, #43
  • Discussion posted in the Tasks category, referencing this task file

Notes

  • Release valve if the 5-day budget runs tight: cut modelMinStakeBounds/maxConcurrentRegistrationsPerStakeUnit (§5b) to a fast-follow PR. They're inert storage by design — nothing else in this task depends on them, and adding them later costs no rework. Do not cut DinTreasury, DinFeeRouter's core routing/split logic, DinToken.burn, DinCoordinator's cap/retire/treasury routing, or DinValidatorStake's jailing — those are the actual resolved decisions this task exists to implement.
  • Foundry is the primary deploy chain now, not Hardhat — all new work targets foundry/src/, foundry/test/, foundry/script/ only.
  • All new contracts follow the PR #13 upgradeable-contract convention exactly (Initializable/OwnableUpgradeable/_disableInitializers()/__gap) — see Documentation/technical/upgradable-contracts/discussion-draft-proxy-pattern.md.
  • Flag the PR for Umer's review given the cryptoeconomic sensitivity of treasury/fee-routing code.
  • Confirm the DinCoordinator-ETH-bypasses-DinFeeRouter interpretation call (§2) before building — raise it in the Discussion thread if you read it differently.