Task: Task-Contract Hardening — Ungrindable Subgroup Seed (#151, BL-11), Platform-Resolved Treasury Forwarding (#152), Indexer Lifecycle Events (#153)
ID: task_240926_16
Assigned to: Robbert Abimbola (@robertocarlous)
Created: 2026-09-24
Status: Open (assigned)
Dates: Sep 24 – Oct 1, 2026 (7 days). This window also covers the follow-on task_17 (to be written), so budget Parts A–C to leave room for it.
Repo: https://github.com/InfiniteZeroFoundation/DevNet
Base branch: develop — spec written against commit d321c2a (2026-09-24); re-pull before starting regardless of when you begin.
Roadmap ref: P3-4.3 (Dispute Resolution Layer — reassignment selector), BL-11 / security-review finding H-2, P3-5.3 (fees/treasury), P4 indexer (issue #24 DIN-Indexer, PR #29)
Tracking: Discussion #163 (progress updates, questions, PR links)
- Part A: #151 (closes, pending review). Design agreed in the #151 review comment.
Developer/BACK_LOG.mdBL-11. - Part B: #152 (closes, pending review). Scope agreed in the #152 review comment.
- Part C: #153 (closes, pending review — the
develophalf; the subgraph half lands on PR #29). Scope agreed in the #153 review comment. Indexer-branch consolidation (PR #72 → PR #29): Discussion #162. - Related, not in scope: #156 (aggregation-side shuffle).
Three independent pieces of work, submitted as three separate PRs into develop in order A → B → C. Part C's subgraph half goes onto PR #29 once PR #72 has been folded into it (C0):
- All three touch
DINTaskCoordinator.sol, but different functions:- Part A: dispute seed.
- Part B: forfeiture paths.
- Part C: every
GIstateassignment, plus the submit/finalize functions.
- Parts B and C also touch
DINTaskAuditor.sol. - Rebase each PR on
developafter the previous one merges, rather than stacking the branches.
If Part A stalls on review, Part C doesn't depend on it and can go ahead. Say so in the tracking discussion before reordering.
DINTaskCoordinator._assignFreshSubgroup picks the fresh aggregator subgroup for an upheld S4 dispute by calling _shuffleAddressArray. That function seeds from blockhash(block.number - 1) (foundry/src/DINTaskCoordinator.sol:553-561). resolveDispute is onlyOwner and has no deadline. The model owner can therefore simulate the shuffle off-chain and call resolveDispute(upheld=true) only in a block that produces a subgroup favourable to them. This is BL-11, and it is the dispute-path instance of security-review finding H-2.
Issue #151 proposed a challenger-side commit-reveal. The review on #151 found that this moves the grinding rather than removing it. The challenger knows their own secret and chooses the reveal block, so they can grind the same way. They can also refuse to reveal and get a free re-roll. Part A implements the alternative agreed in that review: a future-block seed fixed at openDispute time. Nobody can predict it when the dispute opens, and once the seed block is mined nobody gets to choose anything.
While resolveDispute/settleRecomputation are both onlyOwner, the owner can still reject an upheld dispute outright. So this is not today's weakest link. It becomes load-bearing once adjudication moves off the owner, and BL-11 requires it before dispute resolution graduates past scaffold.
- Change
_shuffleAddressArray(address[] memory arr)to take an explicitbytes32 seedand derive each swap index fromkeccak256(abi.encodePacked(seed, i, arr.length)). - Keep the existing
autoCreateTier1AndTier2call site (:498) behaviourally unchanged. Pass itblockhash(block.number - 1)explicitly for now. Replacing that seed is #156's job, and #156 should be able to plug its own seed source into this same helper without touching the dispute path again. - Do not fork a second shuffle function.
DINTaskAuditor.sol:748has the same pattern and is out of scope. Leave it alone.
- Add seed state to
Dispute: e.g.uint64 seedBlockandbytes32 seed. A zeroseedmeans not locked. - In
openDispute, setseedBlock = uint64(block.number) + disputeSeedDelay. disputeSeedDelayis a new owner/DAO-settableuint64with a placeholder default in the 5–10 range. Follow thedisputeWindow/resolutionWindowpattern and give it a setter with a non-zero guard.- Upgrade safety:
DINTaskCoordinatoris not behind a proxy today, but checkDocumentation/technical/storage_layout.md. Update it if appending struct fields or new state variables changes anything recorded there.
- New
lockDisputeSeed(uint _GI, TierKind tierKind, uint batchId), callable by anyone. - Requires an open, not-yet-resolved dispute, no locked seed yet, and
block.number > seedBlock. - If
blockhash(seedBlock) != 0: storeseed = keccak256(abi.encodePacked(blockhash(seedBlock), _GI, tierKind, batchId))and emit an event. - The 256-block edge: if
blockhash(seedBlock) == 0(more than 256 blocks have passed), re-anchor withseedBlock = block.number + disputeSeedDelayand emit a re-anchor event. Never store or use a zero-derived seed.- This is not a re-roll lever. The party who dislikes the draw can't stop the challenger or any honest validator from locking it first.
resolveDispute(upheld=true)reverts with a new custom error if the seed isn't locked.upheld=falsestays callable without a seed, since that path draws no subgroup._assignFreshSubgrouppasses the dispute's storedseedto_shuffleAddressArray. After this, the outcome ofresolveDisputemust not depend on the block in which it's called.- Update the NatSpec that currently says "shuffled with the same blockhash-based entropy" (
:1343) and the scaffold-wide comment block if it references the old entropy. - Trust caveat, write it down in the NatSpec: on OP Stack,
blockhashis sequencer-produced, so this design trusts the sequencer not to grind. That is acceptable for DevNet/testnet. VRF, or multi-party commit-reveal with a slashable non-reveal penalty, is the mainnet-grade follow-up and is out of scope here.
- At
d321c2a,dinclihas no commands for the coordinator's dispute functions (openDispute/resolveDispute/settleRecomputation/expireDispute). Building that CLI surface is out of scope here. - Refresh the bundled ABI in
dincli/abis/if the coordinator ABI changes, and note the missing dispute CLI surface in the PR as a follow-up.
-
_shuffleAddressArraytakes an explicitbytes32 seed;autoCreateTier1AndTier2behaviour unchanged -
DisputecarriesseedBlock/seed;openDisputeanchorsseedBlock = block.number + disputeSeedDelay;disputeSeedDelayis settable with a non-zero guard - Permissionless
lockDisputeSeedwith the >256-block re-anchor path; a zero-derived seed is never used -
resolveDispute(upheld=true)requires a locked seed;upheld=falsedoes not;_assignFreshSubgroupshuffles with the stored seed - New tests in
foundry/test/DisputeResolution.t.sol:- Timing independence: the same dispute resolved at two different blocks (use
vm.snapshot/vm.revertTo+vm.roll) yields the same subgroup - Re-anchor: rolling >256 blocks past
seedBlockre-anchors instead of locking; locking after the newseedBlocksucceeds with a non-zero seed - Gating:
resolveDispute(upheld=true)reverts before the seed is locked;upheld=falsesucceeds without one - Lock guards:
lockDisputeSeedreverts beforeseedBlockis mined, when the seed is already locked, for no open dispute, and after resolution - Permissionless: a non-owner, non-challenger address can lock the seed
- Timing independence: the same dispute resolved at two different blocks (use
- No regression in the existing
DisputeResolution.t.solsuite (27 test functions atd321c2a) or anything else;forge build && forge testgreen, full suite - Bundled coordinator ABI in
dincli/abis/refreshed if it changed -
Developer/BACK_LOG.mdBL-11 row updated to reflect the fix, including the sequencer-trust caveat as the remaining residual - Update #151's issue text so it matches the agreed design (or note the supersession on the issue), so the issue doesn't still describe challenger commit-reveal as the plan
Issue #152 as written is partly stale. PR #141 (issue #43) already forwards two of the three paths it lists:
- Frivolous forfeit:
DINTaskCoordinator.resolveDispute(upheld=false)burns 50% and forwards 50% (:1199-1210). - Reward share:
DINTaskAuditor.settleRewards's 5% share is forwarded (:533-536). - Tests: both are covered by
foundry/test/TreasuryForwarding.t.sol(7 tests).
The #152 review found the real gaps are elsewhere.
- Stranded funds. Four paths still only count the treasury share in
treasuryAccruedand never transfer it, so those tokens are stuck in the task contracts:DINTaskCoordinator.settleRecomputation(confirmed=false)(:1292-1293) — also sends the full bond to treasury instead of the 50/50 split every other forfeiture uses (MECHANISM_DESIGN §4 item 1).DINTaskAuditor.resolveTestDataDispute, false dispute: the forfeited bond's treasury half (:1477-1482).DINTaskAuditor.resolveTestDataDispute, upheld: the model owner's reward-pool penalty's treasury half (:1496-1502).DINTaskAuditor.closeExpiredDispute: the expired bond's treasury half (:1518-1524).- Plus
settleRewardswithtreasuryAddressunset: the 5% share is neither sent nor burned. The coordinator burns in the same situation.
- Wrong trust boundary. Both task contracts'
setTreasuryAddressisonlyOwner, and the owner of a task contract is the model owner. A model owner can point it at their own wallet and collect the protocol's 5% share, half of every forfeited challenger bond, and half of the penalty levied on themselves when they lose a test-data dispute. Nothing inDeployPlatform.s.solordincliever calls this setter on a task contract, so on a real deployment it's unset unless the one party with a reason to set it wrong sets it.
- Add
slashTreasury()toIDinValidatorStakeinfoundry/src/DINShared.sol.DinValidatorStake.slashTreasuryis platform-owned, andDeployPlatform.s.sol:98already wires it to theDinTreasuryproxy. Both task contracts already holddinvalidatorStakeContract. - Remove
treasuryAddress,setTreasuryAddress, and the auditor'sTreasuryAddressUpdatedevent from both task contracts.- This deliberately departs from #152's acceptance criterion of a
dinTreasurysetter plus aTreasuryUpdatedevent. That criterion is superseded by the #152 review.
- This deliberately departs from #152's acceptance criterion of a
- If a separate protocol-fee treasury (distinct from the slash destination) is ever wanted, it gets exposed from a platform contract (e.g.
DinCoordinator), never a task-level setter. That's out of scope here. Don't build it.
- One internal helper per contract, e.g.
_burnAndForward(uint256 amount):- burns 50% and sends 50% to
dinvalidatorStakeContract.slashTreasury(), - burns the whole amount if
slashTreasury()is unset, mirroringDinValidatorStake.slash()(:646-652).
- burns 50% and sends 50% to
- Duplicate it in each contract or put it in
DINShared.sol, whichever fits the existing layout better. Say which in the PR. - Route all six paths through it:
- the two already-wired paths from PR #141,
settleRecomputation(false),- the three
DINTaskAuditortest-data-dispute paths.
- Decision to flag, not guess: the 5%
settleRewardsshare is currently forwarded in full, not burned 50/50. It's a protocol fee, not a forfeiture. Keep it forwarded in full: route it through a forward-only variant of the helper (same unset-treasury burn fallback) rather than the 50/50 one, unless you see a reason otherwise. If you do, raise it on #152 first. - Keep
treasuryAccruedas an observability counter if it's still useful. Remove its stale "DinTreasury doesn't exist on develop" NatSpec and the remainingTODO(task_210726_5)comments:DINTaskAuditor.sol:54-63DINTaskCoordinator.sol:1078-1081DINTaskCoordinator.sol:1292
- Refresh the bundled
DINTaskCoordinator/DINTaskAuditorABIs indincli/abis/. - Grep
dincli/andfoundry/script/for anysetTreasuryAddress/treasuryAddressreference and remove it. None existed atd321c2a; confirm that's still true after re-pulling.
-
IDinValidatorStake.slashTreasury()added; task-leveltreasuryAddress/setTreasuryAddress/TreasuryAddressUpdatedremoved from both contracts - All six forfeiture/penalty/treasury-share paths go through the shared helper:
- 50/50 burn/forward for forfeitures and penalties
- forward-in-full for the
settleRewardsfee share - burn fallback when the treasury is unset
-
settleRecomputation(false)uses the 50/50 split, not full-bond-to-treasury - No path leaves tokens stranded in a task contract; stale NatSpec/TODOs removed
-
foundry/test/TreasuryForwarding.t.solextended:- new paths:
settleRecomputation(false), all three test-data dispute paths, and the auditor's unset-treasury fallback - a conservation assertion on each, new and existing: burned + forwarded == amount, and the task contract's balance delta == 0
- new paths:
- Existing
TreasuryForwarding.t.soltests updated for the removed setter (setslashTreasuryon the stake contract insetUpinstead); no regression elsewhere;forge build && forge testgreen, full suite - Bundled task ABIs in
dincli/abis/refreshed - Update #152's issue text (or note the supersession on the issue) so it reflects the platform-resolved design rather than a task-level setter
The subgraph in PR #29 (feat/din-indexer, still open, head 866521a from 2026-07-14) has six task-level handlers commented out in subgraph/subgraph.yaml, because the events they listen to don't exist on develop. Without them the indexer can't see:
- LMS submissions,
- T1/T2 aggregation submissions,
- which CID won each batch, or the new global model per GI,
- GI phase transitions.
Meanwhile dincli's lms.py/aggregation.py keep iterating submissions over RPC (PR #29's Documentation/technical/audits/task-contract-event-audit.md §3.2).
Issue #153 lists only four events and has several errors. The #153 review corrected them, and this part follows that review. The exact event signatures come from audit §4 in PR #29, not the issue text, so the handlers can be uncommented without an ABI mismatch.
Declare each event in its emitting contract, following the existing convention. Not in DINShared.sol, which has no event declarations. Signatures exactly as audit §4:
| Event | Contract / emit site |
|---|---|
GIStateChanged(uint indexed GI, uint8 indexed newState) |
DINTaskCoordinator, every state transition (via C2) |
LocalModelSubmitted(uint indexed GI, uint indexed modelIndex, address indexed client, bytes32 modelCID) |
DINTaskAuditor.submitLocalModel() (:700), after the lmSubmissions[_GI].push(...). modelIndex is the pre-push length. |
T1AggregationSubmitted(uint indexed GI, uint indexed batchId, address indexed aggregator, bytes32 cid) |
DINTaskCoordinator.submitT1Aggregation(), after the t1SubmissionCID write |
T2AggregationSubmitted(uint indexed GI, uint indexed batchId, address indexed aggregator, bytes32 cid) |
DINTaskCoordinator.submitT2Aggregation(), after the t2SubmissionCID write. batchId is always 0 today; keep it for symmetry. |
T1BatchFinalized(uint indexed GI, uint indexed batchId, bytes32 winningCID) |
finalizeT1Aggregation(), once per batch, right after b.finalCID = winningCID (:767) |
T2Finalized(uint indexed GI, bytes32 globalModelCID) |
finalizeT2Aggregation(), right after b.finalCID = winningCID (:859) |
- Replace every
GIstate = GIstates.X;assignment inDINTaskCoordinatorwith an internal_setGIstate(GIstates newState). It assigns the state and emitsGIStateChanged(GI, uint8(newState)). This leaves one emit site, so no future transition can be missed. - Ordering bug to avoid:
_startGIcurrently setsGIstate = GIstates.GIstartedbeforeGI++(:310-311). A mechanical replace would emitGIstartedstamped with the previous GI. IncrementGIfirst, then call_setGIstate(GIstates.GIstarted). Check that no other transition readsGIin a half-updated state. - The constructor/setup transitions (ordinals 0–4) will emit with
GI == 0. That's expected; document it in the NatSpec.
DINTaskCoordinator is large. Run forge build --sizes before and after, and put both numbers in the PR description. If the contract crosses the 24 KB EIP-170 limit, stop and raise it in the tracking discussion rather than splitting the contract on your own initiative.
Refresh the bundled DINTaskCoordinator/DINTaskAuditor ABIs in dincli/abis/. Switching lms.py/aggregation.py from RPC iteration to GraphQL is out of scope. It follows once the subgraph half is live.
This is a prerequisite for C5, not for C1–C4. Agreed in Discussion #162; see that discussion for the full analysis.
-
Why: PR #72 (
feat/din-indexer-65) is stacked on PR #29 and contains all of #29's commits. It adds only two new indexer commits:2b015f5(GraphQL-cap docs +update-subgraph-addresses.sh) and1a2a877(P3-staking events). Its other 23 commits came in via thefeat/p3-stakingmerge and are already ondevelop, which is why #72 shows 185 files. -
You:
- On your fork:
git checkout feat/din-indexer && git merge --ff-only feat/din-indexer-65 && git push. - Close PR #72 with a "folded into #29" note.
- On your fork:
-
Umer: fast-forward the upstream
feat/din-indexerbase branch todevelop. It's a strict ancestor, 230 commits behind. -
You:
- Merge
developinto yourfeat/din-indexer. It was conflict-free atd321c2a; the only non-subgraph/files left differing areDeveloper/issues/indexer.md, the event audit doc,dincli/cli/dindao.py(renamed todinrep.pyby PR No. 175 — re-apply your edits there after mergingdevelop), andtests/test_list_pending_requests.py. - Regenerate
subgraph/abis/from currentdevelop, and re-rungraph codegen && graph build.
- Merge
-
Not blocking C0, but note it:
DinValidatorStakeondevelopemits events #72 doesn't handle yet:UnbondingPeriodUpdatedSlashTreasuryUpdatedEncryptionKeyRegisteredActiveRegistrationIncremented/Decremented- the S5/S6 events
Handle them in the same refresh if it's small; otherwise list them in #29's description as a follow-up.
This lands as further commits on PR #29 (your fork's feat/din-indexer), not as a separate PR and not on develop, because develop has no subgraph/ directory.
- Refresh against
developagain, since PR 3 will have merged after C0, and regeneratesubgraph/abis/so the six new events are in the ABIs. - Uncomment and implement the six handlers.
- Regenerate the
uint8→ GraphQLGIstatesenum mapping from currentfoundry/src/DINShared.sol. The July snapshot is off by one from ordinal 14 onward, becauseLMSevaluationRevealStartedwas inserted on 2026-08-27.T1AggregationStartedis now 17, not 16, and the enum has 24 members (0–23). Fix audit §4.1's example andsubgraph/docs/daemon-event-schema.mdto match. RewardDepositedis no longer dead code.DINTaskAuditor.depositRewardsemitsRewardDeposited(uint256 indexed gi, address indexed depositor, uint256 amount)(:488). Don't delete it, as audit §5 proposes. Add a handler for it, and update §5.ETHTreasuryWithdrawn(commented out under theDinCoordinatordata source) doesn't exist and isn't planned. Drop the stub, or repoint it at the eventsDinTreasuryactually emits (EthReceived/EthWithdrawn/ERC20Withdrawn) with aDinTreasurydata source. Your call; say which in the PR.
- Six events declared in their emitting contracts, with signatures exactly as in audit §4; emitted at the sites in C1
-
_setGIstateused for everyGIstateassignment inDINTaskCoordinator;GIstartedcarries the new GI -
vm.expectEmittests for all six events, including:-
GIStateChanged(N, GIstarted)for GI N (the ordering fix) - one
T1BatchFinalizedper batch in a multi-batch GI - a full-GI walk asserting the ordered sequence of
GIStateChangedstates fromGIstartedthroughGIended
-
- No regression;
forge build && forge testgreen, full suite;forge build --sizesbefore/after numbers in the PR, within the EIP-170 limit - Bundled task ABIs in
dincli/abis/refreshed - C0: PR #72 folded into PR #29 (fast-forward) and closed; #29 refreshed against
develop;subgraph/abis/regenerated;graph buildpasses - C5, as commits on PR #29:
- ABIs regenerated and six handlers enabled
- enum mapping regenerated from current
GIstates -
RewardDepositedhandler added -
ETHTreasuryWithdrawnstub resolved - audit §4.1/§5 and
daemon-event-schema.mdcorrected
- Update #153's issue text (or note the supersession on the issue): six events not four,
submitLocalModelinDINTaskAuditor, events not inDINShared.sol, and the two-PR split
Three PRs from separate branches into develop (A → B → C), plus the Part C subgraph work pushed to PR #29 (after C0):
-
PR 1 (Part A): references #151 (closes) and BL-11. In the description, call out the chosen
disputeSeedDelaydefault and why, and any storage-layout implications. -
PR 2 (Part B): references #152 (closes). In the description, call out where the helper lives (per-contract vs
DINShared.sol), and confirm thesettleRewardsforward-in-full decision. -
PR 3 (Part C): references #153. In the description, include the
forge build --sizesbefore/after numbers, and say the subgraph half follows. -
PR #29 update (Part C0 + C5): comment on #29 when C0 is done and again when C5 is pushed. Update #29's description to cover #72's folded-in scope, reference #153 (closes), and state the
ETHTreasuryWithdrawndecision.
Post each PR link and progress updates in this task's tracking discussion once it's opened.
- Flag every
DINTaskCoordinator.sol/DINTaskAuditor.sol/DINShared.solchange (Part C's event additions included, since the_setGIstaterefactor touches every state transition) for Umer's review given its cryptoeconomic sensitivity, same as prior tasks in this area (task_100926_12, task_100926_13). - Re-pull
developand re-runforge testright before opening each PR. - If you find a reason to prefer commit-reveal (or VRF) over the future-block seed once you're in the code, raise it on #151 before building it. Don't switch designs silently.
- If you find a reason the model owner should control the treasury destination, raise it on #152 before building around it.
- Part C's event signatures come from PR #29's audit §4. If you change any of them, change the subgraph handler signatures to match in the same C5 push to PR #29.
- #156 (aggregation-side shuffle + T1/T2 commit-reveal) is deliberately not in this task. Part A's seed-taking helper (A1) is the only shared piece.