From a6fbb1d96db4be1e4d791cb60d7f4d16c25c2d30 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Thu, 15 Oct 2026 03:28:55 +0100 Subject: [PATCH 1/4] fix(contracts): position-stable batch seat assignment (#243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch layout is now derived from the fixed per-GI registration list (dinAggregators[_GI] / dinAuditors[_GI]) instead of the live active pool, so an unstake between seed-lock and batch creation forfeits only the leaver's own seat — every other validator's batch and position is determined solely by the fixed list and the locked seed. Key changes: - autoCreateTier1AndTier2: shuffle fixed regList in-place; closed-form batch count (t1cnt = mLen/T1_MODELS_PER_BATCH + partial-batch term, capped by rLen/T1_AGGREGATORS_PER_BATCH); position-stable seat fill scans reserve from totalSeats..rLen-1 to replace inactive primaries. - createAuditorsBatches (DINTaskAuditor): same three-step pattern — shuffle fixed regList, closed-form batchCnt, position-stable fill. - DINTaskCoordinator.createAuditorsBatches wrapper: removed dead bool-success check (DINTaskAuditor always reverts or returns true; the branch was unreachable); saves 75 bytes, putting the contract 1094 B below EIP-170 and passing the CI size gate. - New test: BatchAssignmentStability.t.sol — three snapshot/revert tests verify that unstaking exactly one validator displaces only that validator's own seat and leaves all others unchanged. - Docs: adversarial-threat-model.md Row 2 updated, DINTaskCoordinator.md §6.3/§6.4 rewritten, DINTaskAuditor.md createAuditorsBatches section updated, BACK_LOG.md BL-26 flipped to Fixed. - Tasks: task specs 23–28 added for issues #243–#248. --- Developer/BACK_LOG.md | 2 +- Developer/design/adversarial-threat-model.md | 3 +- Developer/tasks/task_141026_23.md | 78 ++++ Developer/tasks/task_141026_24.md | 91 ++++ Developer/tasks/task_141026_25.md | 88 ++++ Developer/tasks/task_141026_26.md | 97 ++++ Developer/tasks/task_141026_27.md | 94 ++++ Developer/tasks/task_141026_28.md | 78 ++++ .../technical/contracts/DINTaskAuditor.md | 15 +- .../technical/contracts/DINTaskCoordinator.md | 18 +- foundry/src/DINTaskAuditor.sol | 77 ++-- foundry/src/DINTaskCoordinator.sol | 112 +++-- foundry/test/BatchAssignmentStability.t.sol | 414 ++++++++++++++++++ 13 files changed, 1079 insertions(+), 88 deletions(-) create mode 100644 Developer/tasks/task_141026_23.md create mode 100644 Developer/tasks/task_141026_24.md create mode 100644 Developer/tasks/task_141026_25.md create mode 100644 Developer/tasks/task_141026_26.md create mode 100644 Developer/tasks/task_141026_27.md create mode 100644 Developer/tasks/task_141026_28.md create mode 100644 foundry/test/BatchAssignmentStability.t.sol diff --git a/Developer/BACK_LOG.md b/Developer/BACK_LOG.md index a6a92de..1001163 100644 --- a/Developer/BACK_LOG.md +++ b/Developer/BACK_LOG.md @@ -32,7 +32,7 @@ | BL-23 | DP / Privacy (cache_model_0) | `clip_scope: global` default paired with an unreviewed `clipping_norm: 1.0` may over-clip real weights | [PR #109](https://github.com/InfiniteZeroFoundation/DevNet/pull/109) (`fix/dp-sensitivity`) correctly ties noise to the clip (`noise_scale = noise_multiplier * clipping_norm`) and defaults `clip_scope` to `global` so the clip actually bounds the combined L2 norm the noise is calibrated against — the sensitivity-calibration fix itself is sound (`per_layer` only bounds the combined norm at `clipping_norm * sqrt(n)`, not `clipping_norm`, so pairing it with the new noise formula would under-noise as tensor count grows). But `cache_model_0/manifest.json`'s `clipping_norm: 1.0` was never revisited: under the old `per_layer` scope it bounded each tensor independently to norm 1.0; under `global` it bounds the *entire model's combined* L2 norm to 1.0, a much tighter constraint for any model with more than one floating tensor (`sqrt(Σ‖tensor_i‖²)` grows with tensor count even if no individual tensor's norm changes). No test in PR #109 exercises this against `cache_model_0`'s real trained `state_dict` (the new tests use synthetic tensors), so it's unverified whether the new default clips — and thus degrades — the reference model's weights far more aggressively than before. Follow-up: run `apply_dp_mechanism`/`clip_state_dict` against `cache_model_0`'s actual trained weights under the new default and retune `clipping_norm` if the combined-norm bound turns out to be destructive. | P3 (verify before/at merge) | PR #109 review, Sep 8, 2026 | 🆕 New — unaddressed; `cache_model_0/manifest.json`'s `clipping_norm` still `1.0`, and `tests/test_cache_client_dp.py` still exercises synthetic tensors (`build_small_state_dict`), not real trained weights | | BL-24 | DP / Privacy (cache_model_0) | F4 — Laplace noise calibrated against an L2 clip, not the L1 sensitivity it's normally calibrated against | `post_training_laplace`'s `laplace_scale` is scaled by the same `clipping_norm` as the Gaussian mechanisms (`clip_state_dict`'s L2-norm clip), but the Laplace mechanism's textbook privacy guarantee is calibrated against L1 sensitivity, not L2. [PR #109](https://github.com/InfiniteZeroFoundation/DevNet/pull/109) flagged this explicitly rather than silently assuming it's fine (`Documentation/technical/services/clients.md:210-212`: "Laplace noise calibrated against an L2 clip is an open question tracked separately"), and [#99](https://github.com/InfiniteZeroFoundation/DevNet/issues/99)'s F4 (`Documentation/technical/audits/dp-mechanism-review.md` §F4) called the structural mismatch confirmed but left the exact magnitude/severity for a DP specialist's sign-off — explicitly excluded from #99's scope rather than fixed. [#119](https://github.com/InfiniteZeroFoundation/DevNet/pull/119) (which closed #99's remaining F8 items) left F4 untouched, so nothing currently tracks it now that #99 is closed. Needs either a DP specialist's opinion that L2-calibrated Laplace is acceptable as-is (and the open-question note downgraded to an explanation), or a fix (e.g. recalibrating against an actual L1 clip, or reclassifying `post_training_laplace` as experimental alongside F5's `per_layer` treatment) before any ε is claimed for it. | P3/P4 (needs DP specialist input first) | [#99](https://github.com/InfiniteZeroFoundation/DevNet/issues/99) F4 (excluded from scope), `Documentation/technical/audits/dp-mechanism-review.md` §F4, Sep 10, 2026 | 🆕 New — unaddressed; `Documentation/technical/services/clients.md`'s open-question note on L2-calibrated Laplace noise is unchanged since PR #109/#119 | | BL-25 | Tooling / reference model (cache_model_0) | `cache_model_0/abis/` task-contract artifacts are stale vs. `foundry/src` | `cache_model_0/abis/DINTaskCoordinator.json` and `DINTaskAuditor.json` are full hardhat artifacts (abi + bytecode) last regenerated 2026-06-09, missing 67 ABI entries present in `foundry/src` for the coordinator (104 vs 170) and 96 for the auditor (68 vs 158), with pre-foundry constructors. [PR #171](https://github.com/InfiniteZeroFoundation/DevNet/pull/171) regenerated the six bundled `dincli/abis/` files from `foundry/out` but deliberately left these alone: [cache_model_0/manifest.json](../cache_model_0/manifest.json)'s `task_contracts` block pins each artifact to an IPFS CID, records `"type": "hardhat-artifact"` and a hardhat/cancun/optimizer-off `compilation` block, and carries deployed addresses, and dincli resolves task artifacts by CID. Regenerating the files locally would leave the manifest describing artifacts it no longer points to. Needs one pass: regenerate both from `foundry/out` (with bytecode, e.g. `dincli system dump-abi --bytecode --output cache_model_0/abis`), update the manifest's artifact type and compilation metadata to foundry, re-pin to IPFS and update the CIDs. Addresses only change on a redeploy. | P3/P4 | PR #171 merge (2026-09-26): out of scope for the PR's ABI refresh | 🆕 New — unaddressed | -| BL-26 | Solidity/foundry (task contracts) | Batch-assignment seed lock (H-2) shares BL-11's re-roll residual, plus a new pool-reshaping gap | `DINTaskCoordinator.autoCreateTier1AndTier2`/`createAuditorsBatches` (task_240926_18 Part B, issue #156 H-2 aggregation side) reuse BL-11's future-block-seed + permissionless-lock pattern (`lockAggSeed`/`lockAuditSeed`), so they inherit its residual (2): the model owner can decline to lock a seed they dislike and wait out the ~256-block `blockhash` window for a fresh draw — repeatable, since nothing locks automatically today. A second, distinct gap not present in BL-11's `_assignFreshSubgroup` (which shuffles a fixed pool and only lets an exiting validator drop itself, not re-roll the rest): `_activeAggregatorPool`/`_activeAuditorPool` are evaluated at `autoCreateTier1AndTier2`/`createAuditorsBatches` call time, *after* the seed is already public, and Fisher-Yates re-rolls the whole assignment on any pool-size change — so a validator can unstake between lock and create to reshape everyone else's batch. Cost to the attacker is real but modest (their own Sybils sit out the GI and enter unbonding). Ready-made fix for the pool-reshaping gap: adopt `_assignFreshSubgroup`'s pattern — shuffle the fixed per-GI registration list (`dinAggregators[_GI]` / the auditor equivalent) with the locked seed, then skip inactive validators while filling seats, so an unstake only forfeits the leaver's own seat instead of re-rolling everyone else's. | P3/P4 | [PR #191](https://github.com/InfiniteZeroFoundation/DevNet/pull/191) review, Sep 29, 2026 | 🟡 Partially mitigated — the decline-to-lock re-roll is narrowed by a validator-side lock in dincli, added at the PR #191 merge (`dincli aggregator lock-seed` / `dincli auditor lock-seed`, and automatically in `show-t1-batches`/`show-t2-batches`/`lms-evaluation show-batch` before batches exist): any validator online after the seed block locks it, so the owner only gets a re-roll if nobody else is. A contract-side cap on re-anchors was considered and deferred: with no GI-abort path, a GI capped out would be stuck forever (`endGI` needs `AggregatorsSlashed`, `startGI` needs `GIended`), locking validators' registration slots and the reward pool — it needs a GI-abort design (who may abort, where the reward pool goes) first. Pool-reshaping gap unaddressed. Documented as residuals in the PR (`DINTaskCoordinator.md` §6.3). Sequencer-trust residual (shared with BL-11) tracked in [issue #178](https://github.com/InfiniteZeroFoundation/DevNet/issues/178); the re-roll-by-declining-to-lock and pool-reshaping gaps above have no dedicated issue yet, same as BL-11's own un-issued residual (2) | +| BL-26 | Solidity/foundry (task contracts) | Batch-assignment seed lock (H-2) shares BL-11's re-roll residual, plus a new pool-reshaping gap | `DINTaskCoordinator.autoCreateTier1AndTier2`/`createAuditorsBatches` (task_240926_18 Part B, issue #156 H-2 aggregation side) reuse BL-11's future-block-seed + permissionless-lock pattern (`lockAggSeed`/`lockAuditSeed`), so they inherit its residual (2): the model owner can decline to lock a seed they dislike and wait out the ~256-block `blockhash` window for a fresh draw — repeatable, since nothing locks automatically today. A second, distinct gap not present in BL-11's `_assignFreshSubgroup` (which shuffles a fixed pool and only lets an exiting validator drop itself, not re-roll the rest): `_activeAggregatorPool`/`_activeAuditorPool` were evaluated at `autoCreateTier1AndTier2`/`createAuditorsBatches` call time, *after* the seed was already public, and Fisher-Yates re-rolled the whole assignment on any pool-size change — so a validator could unstake between lock and create to reshape everyone else's batch. Cost to the attacker is real but modest (their own Sybils sit out the GI and enter unbonding). | P3/P4 | [PR #191](https://github.com/InfiniteZeroFoundation/DevNet/pull/191) review, Sep 29, 2026 | ✅ Fixed — PR # (issue [#243](https://github.com/InfiniteZeroFoundation/DevNet/issues/243)). Pool-reshaping gap closed: batch layout is now derived from the fixed per-GI registration list (`dinAggregators[_GI]` / `dinAuditors[_GI]`) rather than the live active pool; an unstake after seed-lock forfeits only the leaver's own seat (position-stable seat assignment). Re-roll-by-declining-to-lock residual remains, narrowed by validator-side dincli lock. Sequencer-trust residual tracked in [issue #178](https://github.com/InfiniteZeroFoundation/DevNet/issues/178). | | BL-27 | dincli (model owner) | `dincli model-owner deploy` still uses the pre-foundry task-contract constructors | `dincli/cli/modelownerd/deploy.py` sends `DINTaskCoordinator.constructor(stake)` (`:35`) and `DINTaskAuditor.constructor(stake, coordinator)` (`:77`), but `foundry/src` takes `(stake, modelId)` and `(stake, coordinator, modelId)`. Against foundry artifacts, which are the bundled `dincli/abis/` since PR #171, the deploy fails at ABI encoding, so model owners can't deploy task contracts from dincli. The `tests/dincli/` harness hides this because it deploys task contracts from **hardhat** artifacts. The fix also has to settle where `modelId` comes from: the registry assigns it only at approval, after the contracts exist (`DINTaskCoordinator.md` §10 No. 4). | P3 (before any model-owner onboarding on DevNet 3.0) | PR No. 204 review, Sep 30, 2026 (caveats `DINTaskCoordinator.md` §10 No. 10, `DINTaskAuditor.md` §13 No. 8) | 🆕 New — unaddressed; tracked in [#223](https://github.com/InfiniteZeroFoundation/DevNet/issues/223) | | BL-28 | dincli (model owner) | No dincli command for `releaseGIRegistrationSlots` | `DINTaskCoordinator.releaseGIRegistrationSlots(gi)` (owner-only, once per ended GI) is the only thing that decrements validators' `activeRegistrationCount`; `endGI` deliberately doesn't (BL-10). dincli ships the function in its ABI but has no command that calls it, so under a non-zero `maxConcurrentRegistrationsPerStakeUnit` every GI permanently consumes its validators' concurrent-registration slots until they hit `TC_/TA_ConcurrentRegistrationCapReached`. Harmless while the cap is 0 (the default). | P3 (before the cap is set on any network) | task_100926_12 / issue #37 fast-follow; PR No. 204 review | 🆕 New — unaddressed; tracked in [#223](https://github.com/InfiniteZeroFoundation/DevNet/issues/223) | | BL-29 | Solidity/foundry (fees) | `DinFeeRouter`'s non-treasury shares accrue with no way out | `routeFeeETH`/`routeFeeDIN` pay the treasury share out immediately but only *count* the `validatorPool`, `storage` and `publicGoods` shares in `accruedEth`/`accruedDin`. No function withdraws or distributes them, so with the default 95% validator-pool split, 95% of every swept faucet/registry fee stays in the router until an upgrade adds a path. Needs a decision on who can pull each bucket and where the validator-pool share goes (reward pools? `DinEmission`?). | P3/P4 | PR No. 204 review (`DinCoordinator.md` §14 No. 2), Sep 30, 2026; related to issue #43's open items | 🆕 New — unaddressed; tracked in [#221](https://github.com/InfiniteZeroFoundation/DevNet/issues/221) | diff --git a/Developer/design/adversarial-threat-model.md b/Developer/design/adversarial-threat-model.md index bc48e80..b2e2e0e 100644 --- a/Developer/design/adversarial-threat-model.md +++ b/Developer/design/adversarial-threat-model.md @@ -32,7 +32,7 @@ entry in `foundry/test/P3Adversarial.t.sol`. |---|---| | **Attacker** | Sybil operator: k identities, each staking MIN_STAKE, targeting k-of-n seats in one batch | | **Capability** | k × MIN_STAKE total stake; no control of randomness — batch assignment is `_shuffleAddressArray(valPool, keccak256(seed, "AGG_ADDR"))` (`DINTaskCoordinator.sol:582`) and auditor seat assignment is `_shuffleAddressArray(auditorPool, keccak256(seed, "AUD_ADDR"))` (`DINTaskAuditor.sol:871`), where `seed` is the per-GI `aggSeed`/`auditSeed`: anchored to a future block (`block.number + disputeSeedDelay`) at the preceding GI-state transition and locked permissionlessly via `lockAggSeed`/`lockAuditSeed` (issue #156 H-2, PR #191). `autoCreateTier1AndTier2` and `createAuditorsBatches` are still `onlyOwner`, but revert unless the seed is locked, so the block the owner calls in no longer changes the shuffle. | -| **Protocol response today** | No Sybil-specific defence beyond the per-identity stake requirement. Owner block-picking of the seat shuffle is closed by the H-2 fix: the seed is fixed at a block not yet mined when it is anchored, and batch creation reads the stored seed (`test_createAuditorsBatches_timingIndependent` / `test_autoCreateTier1AndTier2_timingIndependent` in `SecurityFindings.t.sol`). Residuals remain (BL-26): the owner can decline to lock a seed they dislike and let it re-anchor after the ~256-block `blockhash` window (a repeatable re-roll while nobody else locks — aggregator/auditor dincli now locks the seed from the validator side to narrow this), and a validator can unstake between lock and batch creation to reshape the active pool. The seed also trusts the sequencer not to grind `blockhash` (TA-4). None of this changes the Sybil-cost framing below — k identities still win seats at the hypergeometric rate. | +| **Protocol response today** | No Sybil-specific defence beyond the per-identity stake requirement. Owner block-picking of the seat shuffle is closed by the H-2 fix: the seed is fixed at a block not yet mined when it is anchored, and batch creation reads the stored seed (`test_createAuditorsBatches_timingIndependent` / `test_autoCreateTier1AndTier2_timingIndependent` in `SecurityFindings.t.sol`). Post-lock pool reshaping is closed by issue #243 (PR #): batch layout is now derived from the fixed per-GI registration list (`dinAggregators[_GI]`, `dinAuditors[_GI]`) rather than the live active pool, so an unstake between seed-lock and batch-creation forfeits only the leaver's own seat — every other validator's batch and seat position is determined solely by the fixed list and the locked seed (`test_aggregator_unstakeOnlyDisplacesOwnSeat` in `BatchAssignmentStability.t.sol`). Residual: the owner can still decline to lock a seed they dislike and let it re-anchor after the ~256-block `blockhash` window (a repeatable re-roll while nobody else locks — aggregator/auditor dincli now locks the seed from the validator side to narrow this). The seed also trusts the sequencer not to grind `blockhash` (TA-4). None of this changes the Sybil-cost framing below — k identities still win seats at the hypergeometric rate. | | **Expected test outcome** | **COST-BOUNDED** — the test sets up n registered identities, k of which are Sybil. P(Sybil controls ≥ majority of a 3-seat batch) is hypergeometric: ≈ 3p² − 2p³ where p = k/n (not k/n). The test asserts the fraction of batches where Sybil controls majority and the total stake cost is k × MIN_STAKE. | | **Trust assumption** | n (total registered validators) large enough that k × MIN_STAKE to control majority is economically prohibitive relative to poisoned-model gain (TA-3); seat-shuffle seed not ground by the sequencer (TA-4). | @@ -166,6 +166,7 @@ entry in `foundry/test/P3Adversarial.t.sol`. | T1 wrong CID — finalCID not replaced, dissenter not refunded | [#194](https://github.com/InfiniteZeroFoundation/DevNet/issues/194) (#181 covers who adjudicates, not repairing the outcome) | Row 5 (T1 wrong CID) | `settleRecomputation(confirmed=true)` replaces `finalCID` and refunds the honest dissenter | | Dual-role registration guard | [#180](https://github.com/InfiniteZeroFoundation/DevNet/issues/180) | Row 6 (cross-role + owner block-selection) | **Flipped by task_061026_20 Part B:** per-address guard in `registerDINAuditor` (`TA_DualRoleNotAllowed`). The Sybil variant (second funded address) remains, bounded by stake cost | | Stronger batch-shuffle seed | [#156](https://github.com/InfiniteZeroFoundation/DevNet/issues/156) | Rows 2, 6 (owner block-selection) | **Flipped by PR #191:** shuffles seeded by the locked, future-block `aggSeed`/`auditSeed` rather than at owner-controlled call time. Residuals (decline-to-lock re-roll, post-lock pool reshaping) tracked in BL-26 | +| Post-lock pool-reshaping gap | [#243](https://github.com/InfiniteZeroFoundation/DevNet/issues/243) | Row 2 (Sybil batch control) | **Flipped by PR #:** batch layout derived from fixed registration list (`dinAggregators[_GI]`) not the live active pool; an unstake after seed-lock forfeits only the leaver's own seat (position-stable seat assignment). Residual: decline-to-lock re-roll still open (TA-4). Covered by `BatchAssignmentStability.t.sol` | | Decentralized dispute adjudication | [#181](https://github.com/InfiniteZeroFoundation/DevNet/issues/181) | Row 7 (owner suppresses dispute) and bond-locked-forever variant | `resolveDispute` gated by multi-sig, DAO committee, or fraud-proof; timeout added for unresolved disputes | | Dispute timeout for unresolved disputes | [#181](https://github.com/InfiniteZeroFoundation/DevNet/issues/181) | Row 7 (owner never resolves → bond locked) | `expireDispute` (or a new function) reachable when `d.resolved == false` after a deadline | | BlockFLow duplicate-update scoring | [#39](https://github.com/InfiniteZeroFoundation/DevNet/issues/39) | Row 10 (duplicate-update gaming) | BlockFLow fold-in scoring implemented | diff --git a/Developer/tasks/task_141026_23.md b/Developer/tasks/task_141026_23.md new file mode 100644 index 0000000..df84a5c --- /dev/null +++ b/Developer/tasks/task_141026_23.md @@ -0,0 +1,78 @@ +# Task: Fix Batch-Assignment Pool-Reshaping Gap — Shuffle Fixed Registration List, Not Live Active Pool (#243) + +**ID:** task_141026_23 +**Assigned to:** Unassigned +**Created:** 2026-10-14 +**Status:** Open (unassigned) +**Dates:** No fixed deadline. Can start immediately; no cross-task dependency. +**Repo:** https://github.com/InfiniteZeroFoundation/DevNet +**Base branch:** `develop` — spec written against commit `aa76ce18` (2026-10-14); re-pull before starting. +**Roadmap ref:** P3-6 (security hardening before audit); BL-26 (`Developer/BACK_LOG.md`) +**Tracking:** +- [#243](https://github.com/InfiniteZeroFoundation/DevNet/issues/243) (closes on PR merge) +- Post progress updates and the PR link in a comment on #243. + +--- + +## Background + +`DINTaskCoordinator.autoCreateTier1AndTier2` and `createAuditorsBatches` build the validator pool by calling `_activeAggregatorPool` / `_activeAuditorPool` **at call time**, after the batch-creation seed is already public. A validator can unstake between the seed lock and the batch-creation call. This changes the active-pool size and causes Fisher-Yates to re-roll the entire batch assignment for every other participant — not just the leaving validator's own seat. + +The fix is already available in the codebase: `_assignFreshSubgroup` (the dispute re-eval draw, landed in PR #171) shuffles a **fixed per-GI registration list** with the locked seed and skips inactive entries while filling seats. An unstake under that scheme only drops the leaver's own slot; everyone else's assignment is already committed. + +This is a **distinct gap** from the re-roll-by-declining-to-lock residual tracked in issue #178. It requires no GI-abort design (issue #222) as a prerequisite. + +--- + +## Status check at `aa76ce18` + +| Contract | Function | Gap | +|---|---|---| +| `DINTaskCoordinator.sol` | `autoCreateTier1AndTier2` (~L714–780) | Calls `_activeAggregatorPool()` at create time after seed is locked | +| `DINTaskCoordinator.sol` | `createAuditorsBatches` (~L820–880) | Calls `_activeAuditorPool()` at create time after seed is locked | +| `DINTaskCoordinator.sol` | `_assignFreshSubgroup` | Correct pattern — uses fixed `dinAggregators[_GI]` list + seed | + +--- + +## Implementation + +### 1. Aggregator batch creation + +In `autoCreateTier1AndTier2`: + +- Replace the `_activeAggregatorPool()` call with `dinAggregators[_GI]` (the fixed list of aggregators who registered for this GI). +- Shuffle `dinAggregators[_GI]` using the locked seed (already available: `aggSeed[_GI]`), following the same pattern as `_assignFreshSubgroup`. +- Walk the shuffled list to fill T1/T2 seats, **skipping** any entry where `!stake.isValidatorActive(addr)` — this is the same skip-inactive pattern `_assignFreshSubgroup` uses. +- An inactive validator (unstaked after registration) gets no seat; everyone else keeps theirs. + +### 2. Auditor batch creation + +Apply the same change in `createAuditorsBatches`: + +- Replace `_activeAuditorPool()` with the per-GI registered auditor list. +- Shuffle with `auditSeed[_GI]`, skip inactive entries. + +### 3. No change to shuffle helper + +The existing `_shuffleAddressArray(arr, seed)` helper (task_240926_18 Part B) already takes an explicit seed. No new helper needed. + +--- + +## Deliverables + +- [ ] `autoCreateTier1AndTier2` shuffles `dinAggregators[_GI]` (or equivalent registered list) with the locked `aggSeed[_GI]`; inactive validators are skipped, not re-rolled +- [ ] `createAuditorsBatches` applies the same pattern with `auditSeed[_GI]` and the per-GI registered auditor list +- [ ] Neither function calls `_activeAggregatorPool` / `_activeAuditorPool` at batch-creation time +- [ ] Tests (in an existing adversarial or integration test file, or a new `BatchAssignment.t.sol`): + - [ ] Unstake between seed lock and `autoCreateTier1AndTier2` does **not** change any other participant's batch seat + - [ ] The unstaked validator gets no seat and the remaining seats are filled from the rest of the registered list + - [ ] Batch assignments with the same seed are identical regardless of when `autoCreateTier1AndTier2` is called (timing independence — same pattern as task_240926_18 Part B's determinism test) +- [ ] `forge build && forge test` green, full suite +- [ ] If bundled ABIs changed, refresh `dincli/abis/` +- [ ] `Developer/BACK_LOG.md` BL-26 status updated to "Fixed — PR #" + +## Notes + +- Do not change the seed-lock mechanism itself. The seed-lock flow (permissionless `lockAggSeed` / `lockAuditSeed`) is unchanged; this fix is only about what the shuffle reads as its input pool. +- Do not address the re-roll-by-declining-to-lock residual (issue #178) or the GI-abort design (issue #222) in this PR. Note both as out of scope. +- Flag all `DINTaskCoordinator.sol` / `DINTaskAuditor.sol` changes for Umer's review. diff --git a/Developer/tasks/task_141026_24.md b/Developer/tasks/task_141026_24.md new file mode 100644 index 0000000..22e8469 --- /dev/null +++ b/Developer/tasks/task_141026_24.md @@ -0,0 +1,91 @@ +# Task: On-Chain Deployment Drift Check — verify foundry/deployments/.json matches the chain (#244) + +**ID:** task_141026_24 +**Assigned to:** Unassigned +**Created:** 2026-10-14 +**Status:** Open (unassigned) +**Dates:** No fixed deadline. Must land before the DevNet 3.0 deploy; should also run after every `UpgradePlatform.s.sol` run. +**Repo:** https://github.com/InfiniteZeroFoundation/DevNet +**Base branch:** `develop` — spec written against commit `aa76ce18` (2026-10-14); re-pull before starting. +**Roadmap ref:** P3 (pre-deploy tooling); BL-33 (`Developer/BACK_LOG.md`) +**Tracking:** +- [#244](https://github.com/InfiniteZeroFoundation/DevNet/issues/244) (closes on PR merge) +- Post progress updates and the PR link in a comment on #244. + +--- + +## Background + +Nothing currently verifies that `foundry/deployments/.json` (and the `din_info` snapshot written by `dincli system import-deployments`) still matches what is live on-chain. A stale deployments file after a failed or partial upgrade silently routes calls to the wrong implementation, and there is no quick way to detect it without reading raw storage slots by hand. + +This task implements a **read-only drift check** that compares the recorded deployments against the live chain and prints `OK` / `DRIFT` per item. + +--- + +## Preferred approach: `dincli system verify-deployment` + +A new subcommand added to `dincli/cli/system.py`, following the pattern of the existing `import-deployments` command. It reads `foundry/deployments/.json` (or the `din_info` config file), connects to the configured RPC, and checks the following for each recorded proxy: + +### Check 1 — EIP-1967 implementation slot + +Read `bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)` from the proxy address and compare against the recorded implementation address. + +### Check 2 — EIP-1967 admin slot + +Read `bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1)` from the proxy address and compare against the recorded `proxyAdmin*` address. + +### Check 3 — Runtime code hash + +Fetch `eth_getCode(implementation)` and compute its keccak256. Compare against `keccak256` of the `deployedBytecode` field in the corresponding `foundry/out/.sol/.json` artifact (immutables section masked to zero). Report `DRIFT` if the hashes differ. + +### Check 4 — Owner / DIN-Representative + +Call `owner()` on each ownable contract and compare against the recorded owner address. + +### Check 5 — Wiring + +Verify the key cross-contract links: +- `DinCoordinator.dinToken()` == recorded `dinToken` address +- `DinCoordinator.dinValidatorStake()` == recorded `dinValidatorStake` address +- `DinCoordinator.dinModelRegistry()` == recorded `dinModelRegistry` address +- Slasher authorizations on `DinValidatorStake`: each task-coordinator address listed in the deployments JSON is an authorized slasher. + +### Output format + +``` +[OK] DINTaskCoordinator proxy → implementation 0xabc... +[OK] DINTaskCoordinator proxyAdmin 0xdef... +[DRIFT] DINTaskAuditor implementation: recorded 0x111... actual 0x222... +[OK] DinCoordinator owner 0xghi... +[OK] wiring: DinCoordinator.dinToken → 0xjkl... +``` + +Exits 0 if all checks pass, non-zero on any `DRIFT` or `ERROR`. + +--- + +## Alternative: forge script + +If the dincli approach is too heavy for the use case, a forge script `script/VerifyDeployment.s.sol` is acceptable. It should read the same deployments JSON via `vm.readFile` + `stdJson`, perform the same five check categories above, and output to stdout. Either approach closes the issue. + +Whichever path is chosen, record the decision in a comment on #244. + +--- + +## Deliverables + +- [ ] `dincli system verify-deployment` (or `script/VerifyDeployment.s.sol`) implements all five check categories above +- [ ] Output is plain text, one line per check, `OK` / `DRIFT` / `ERROR`, usable in CI without color codes (`--no-color` flag or auto-detect via `sys.stdout.isatty()`) +- [ ] Exits non-zero on any `DRIFT` or `ERROR` +- [ ] Tests (using the existing `monkeypatch` + `CliRunner` pattern for dincli, or a Foundry integration test for the forge-script path): + - [ ] All-OK run exits 0 and prints only `[OK]` lines + - [ ] A mismatched implementation slot prints `[DRIFT]` and exits non-zero + - [ ] A mismatched owner prints `[DRIFT]` and exits non-zero +- [ ] `Documentation/technical/testing/` or `Documentation/public/guides/` — add a short section describing when and how to run the drift check +- [ ] `Developer/BACK_LOG.md` BL-33 status updated to "Fixed — PR #" + +## Notes + +- This check is read-only. It must never send transactions or modify any state. +- It covers only what the deployments JSON records. A clean run does not prove nothing else was deployed or authorized outside that file. Say this in the command's help text. +- Do not attempt to auto-repair drift. Report and exit only. diff --git a/Developer/tasks/task_141026_25.md b/Developer/tasks/task_141026_25.md new file mode 100644 index 0000000..86f9d72 --- /dev/null +++ b/Developer/tasks/task_141026_25.md @@ -0,0 +1,88 @@ +# Task: Contributor Environment Preflight Check — dincli system doctor / scripts/preflight.sh (#245) + +**ID:** task_141026_25 +**Assigned to:** Unassigned +**Created:** 2026-10-14 +**Status:** Open (unassigned) +**Dates:** No fixed deadline. Small scope (~half a day). Can start immediately. +**Repo:** https://github.com/InfiniteZeroFoundation/DevNet +**Base branch:** `develop` — spec written against commit `aa76ce18` (2026-10-14); re-pull before starting. +**Roadmap ref:** P3/P4 (onboarding tooling); BL-32 (`Developer/BACK_LOG.md`) +**Tracking:** +- [#245](https://github.com/InfiniteZeroFoundation/DevNet/issues/245) (closes on PR merge) +- Post progress updates and the PR link in a comment on #245. + +--- + +## Background + +There is no single command to verify a developer's machine is ready before starting work. Missing `foundry/node_modules` causes parallel test races on CI; wrong Python version, missing `torch`, bad `.env` — each of these has blocked contributors. The preflight check collects all these validations into one read-only command so the diagnosis is immediate. + +--- + +## Preferred approach: `dincli system doctor` + +Add a new `doctor` subcommand to `dincli/cli/system.py` (the `system` Typer sub-app already lives there). It runs all checks and prints one line per check: + +``` +[OK] Python 3.12.6 +[OK] forge 0.3.2 / anvil 0.3.2 +[OK] solc 0.8.28 (via solc-select) +[OK] foundry/node_modules present +[OK] eth-account 6.3.0 +[OK] py-multibase 2.0.1 +[MISSING] torch — run: pip install torch +[OK] .env.sepolia_op_devnet found +[OK] RPC https://... → chainId 11155420 +[MISSING] IPFS_API_URL not set and no Filebase credentials — see Documentation/public/guides/ipfs.md +``` + +### Checks (in order) + +1. **Python version** — `sys.version_info >= (3, 12)`. Print installed version. `[MISSING]` if below 3.12. +2. **forge / anvil installed** — `shutil.which("forge")` and `shutil.which("anvil")`. Run `forge --version` and print. `[MISSING]` with install hint if absent. +3. **solc 0.8.28** — `forge config --json | jq .solc_version` or parse `foundry/foundry.toml`. Report whether the required version is accessible. Warn if not. +4. **foundry/node_modules** — Check that `foundry/node_modules/@openzeppelin/upgrades-core` exists. `[MISSING]` with `cd foundry && npm ci` hint if absent. This is documented in `CLAUDE.md` as the source of parallel test flakiness. +5. **eth-account importable** — `import importlib; importlib.import_module("eth_account")`. Print version. `[MISSING]` if ImportError. +6. **py-multibase importable** — same pattern. +7. **torch importable** — same pattern. Note explicitly: `[MISSING]` is a warning (not a hard failure) since `torch` is only needed for the full test suite and reference service files, not for CLI operation. +8. **`.env` / `.env.` present** — Check both `Path(".env")` and `Path(f".env.{network}")` exist in the repo root. `[MISSING]` with a pointer to the env file docs if absent. +9. **RPC endpoint answers** — Use the configured RPC to call `eth_chainId`. Print the returned chain ID. `[ERROR]` with the endpoint URL if the call fails or times out (5 s timeout). +10. **IPFS config resolves** — Call `resolve_ipfs_config()` from `dincli/services/ipfs.py`. `[OK]` if it returns a provider; `[MISSING]` with a pointer to `Documentation/public/guides/ipfs.md` if it raises or returns nothing. + +### Exit codes + +- Exit 0 if every check is `[OK]` or `[MISSING torch]` (torch is advisory). +- Exit 1 if any check other than torch is `[MISSING]` or `[ERROR]`. + +### Flags + +- `--no-color` — disable ANSI codes (auto-detected from `sys.stdout.isatty()`). +- `--network ` — override which `.env.` and RPC are checked (defaults to the configured network). + +--- + +## Alternative: `scripts/preflight.sh` + +If the Typer subcommand approach is not preferred, a standalone `scripts/preflight.sh` is acceptable as long as it covers the same 10 checks and meets the same exit-code contract. Record the decision in a comment on #245. + +--- + +## Deliverables + +- [ ] `dincli system doctor` (or `scripts/preflight.sh`) implements all 10 checks above +- [ ] Exits 0 when all critical checks pass; exits 1 on any critical `[MISSING]` or `[ERROR]` +- [ ] Output is one line per check; no ANSI codes when stdout is not a TTY +- [ ] Tests using `CliRunner` or equivalent: + - [ ] All-OK run exits 0 and prints only `[OK]` lines + - [ ] Missing `forge` binary prints `[MISSING]` and exits 1 + - [ ] RPC timeout prints `[ERROR]` and exits 1 + - [ ] Missing `torch` prints `[MISSING]` but exits 0 (advisory only) +- [ ] `Documentation/public/guides/getting-started.md` — add `dincli system doctor` as the first step in the "Set up your environment" section +- [ ] `Developer/BACK_LOG.md` BL-32 status updated to "Fixed — PR #" + +## Notes + +- This command is **read-only**: it must never install packages, modify files, or send transactions. +- The `[MISSING]` hint text should be the exact command to run, not a vague pointer. Example: `[MISSING] foundry/node_modules — run: cd foundry && npm ci`. +- Reuse `dincli/cli/utils.py`'s `CONFIG_DIR` / `CACHE_DIR` helpers where useful, but the command has no configuration of its own. diff --git a/Developer/tasks/task_141026_26.md b/Developer/tasks/task_141026_26.md new file mode 100644 index 0000000..44bbe0f --- /dev/null +++ b/Developer/tasks/task_141026_26.md @@ -0,0 +1,97 @@ +# Task: Regenerate cache_model_0/abis/ from foundry/out and update manifest metadata (#246) + +**ID:** task_141026_26 +**Assigned to:** Unassigned +**Created:** 2026-10-14 +**Status:** Open (unassigned) +**Dates:** No fixed deadline. Can start immediately; no cross-task dependency. Single-pass fix. +**Repo:** https://github.com/InfiniteZeroFoundation/DevNet +**Base branch:** `develop` — spec written against commit `aa76ce18` (2026-10-14); re-pull before starting. +**Roadmap ref:** P3/P4 (reference model correctness); BL-25 (`Developer/BACK_LOG.md`) +**Tracking:** +- [#246](https://github.com/InfiniteZeroFoundation/DevNet/issues/246) (closes on PR merge) +- Post progress updates and the PR link in a comment on #246. + +--- + +## Background + +`cache_model_0/abis/DINTaskCoordinator.json` and `DINTaskAuditor.json` are hardhat artifacts last regenerated 2026-06-09. They are missing 67 ABI entries for the coordinator (104 vs. 170 in `foundry/src`) and 96 entries for the auditor (68 vs. 158), and carry the pre-foundry constructors. PR #171 regenerated the six bundled `dincli/abis/` files but explicitly left these alone because the manifest pins them by IPFS CID. + +Any model owner or tool that follows `cache_model_0` as a reference and resolves task-contract ABIs through the manifest will hit ABI encoding mismatches on reward settlement, commit-reveal, seed-lock, encrypted-test-data, and dispute-resolution functions added in P3. + +--- + +## Status check at `aa76ce18` + +| File | ABI entries | Artifact type | Last regenerated | +|---|---|---|---| +| `cache_model_0/abis/DINTaskCoordinator.json` | 104 | `hardhat-artifact` | 2026-06-09 | +| `cache_model_0/abis/DINTaskAuditor.json` | 68 | `hardhat-artifact` | 2026-06-09 | +| `foundry/src/DINTaskCoordinator.sol` (current) | 170 | — | — | +| `foundry/src/DINTaskAuditor.sol` (current) | 158 | — | — | + +--- + +## Steps + +### 1. Regenerate the ABI files from foundry/out + +```bash +cd foundry && forge build +``` + +Copy the two artifacts from `foundry/out`: + +```bash +cp foundry/out/DINTaskCoordinator.sol/DINTaskCoordinator.json cache_model_0/abis/DINTaskCoordinator.json +cp foundry/out/DINTaskAuditor.sol/DINTaskAuditor.json cache_model_0/abis/DINTaskAuditor.json +``` + +These include both the ABI and the deployment bytecode (foundry full artifact format). + +### 2. Update cache_model_0/manifest.json metadata + +In the `task_contracts` block, for both `coordinator` and `auditor` entries, update: + +- `"type"`: change `"hardhat-artifact"` → `"foundry-artifact"` +- `"compilation"` block: update to match foundry's build settings: + ```json + { + "toolchain": "foundry", + "solc": "0.8.28", + "viaIR": true, + "optimizer": false, + "evmVersion": "cancun" + } + ``` + (Match the values in `foundry/foundry.toml`.) +- Leave `"address"` fields unchanged — addresses only change on a contract redeploy. + +### 3. Re-pin both files to IPFS and update the CIDs + +Upload each regenerated file to IPFS using the configured IPFS provider: + +```bash +dincli ipfs upload cache_model_0/abis/DINTaskCoordinator.json +dincli ipfs upload cache_model_0/abis/DINTaskAuditor.json +``` + +Update the `cid` fields in `cache_model_0/manifest.json`'s `task_contracts` block to the new CIDs returned. + +--- + +## Deliverables + +- [ ] `cache_model_0/abis/DINTaskCoordinator.json` regenerated from `foundry/out`, ABI entry count matches `foundry/src` +- [ ] `cache_model_0/abis/DINTaskAuditor.json` regenerated from `foundry/out`, ABI entry count matches `foundry/src` +- [ ] `cache_model_0/manifest.json` `task_contracts` block updated: `"type": "foundry-artifact"`, `"compilation"` block reflects foundry 0.8.28 / via-IR / cancun, CIDs point to the newly pinned files +- [ ] `forge build` is clean after the changes (no changes to contracts needed) +- [ ] Verify manually that `dincli` can resolve and decode an ABI entry from the updated manifest (e.g. call `startGI` ABI lookup) — document the check in the PR description +- [ ] `Developer/BACK_LOG.md` BL-25 status updated to "Fixed — PR #" + +## Notes + +- Do not change the deployed contract addresses in the manifest. They only change on a redeploy. +- If `foundry/out` is absent when starting (clean clone), run `cd foundry && npm ci && forge build` first. +- If a `dincli system dump-abi --bytecode` subcommand lands before this task is started (it is a proposal in the issue), use that instead of copying from `foundry/out` directly — either is acceptable. diff --git a/Developer/tasks/task_141026_27.md b/Developer/tasks/task_141026_27.md new file mode 100644 index 0000000..65dce23 --- /dev/null +++ b/Developer/tasks/task_141026_27.md @@ -0,0 +1,94 @@ +# Task: Validate cache_model_0 clipping_norm under clip_scope: global and retune if over-clipping (#247) + +**ID:** task_141026_27 +**Assigned to:** Unassigned +**Created:** 2026-10-14 +**Status:** Open (unassigned) +**Dates:** No fixed deadline. Requires access to `cache_model_0`'s real trained `state_dict`. +**Repo:** https://github.com/InfiniteZeroFoundation/DevNet +**Base branch:** `develop` — spec written against commit `aa76ce18` (2026-10-14); re-pull before starting. +**Roadmap ref:** P3 (DP correctness for reference model); BL-23 (`Developer/BACK_LOG.md`) +**Tracking:** +- [#247](https://github.com/InfiniteZeroFoundation/DevNet/issues/247) (closes on PR merge) +- Post progress updates and the PR link in a comment on #247. + +--- + +## Background + +PR #109 (`fix/dp-sensitivity`) correctly changed the DP default from `clip_scope: per_layer` to `clip_scope: global` so that `noise_scale = noise_multiplier * clipping_norm` is calibrated against the actual combined L2 norm of all tensors — the sensitivity fix itself is sound. + +However, `cache_model_0/manifest.json`'s `clipping_norm: 1.0` was never revisited after this change: + +- Under **`per_layer`**: `clipping_norm: 1.0` bounded each tensor independently to norm 1.0 — a realistic bound for any one layer. +- Under **`global`**: `clipping_norm: 1.0` bounds the **combined** L2 norm (`sqrt(Σ ‖tensor_i‖²)`) to 1.0 — for a model with multiple floating tensors, the combined norm easily exceeds 1.0 even if no individual tensor's norm changes. This clips the reference model more aggressively than before and degrades training quality proportionally. + +PR #109's tests use `build_small_state_dict` (synthetic tensors) and never exercise the real `cache_model_0` weights, so this is unverified on the actual model. + +--- + +## Status check at `aa76ce18` + +- `cache_model_0/manifest.json`: `"clipping_norm": 1.0`, `"clip_scope": "global"` (the PR #109 default) +- `tests/test_cache_client_dp.py`: uses `build_small_state_dict` only — no test loads the real trained `state_dict` +- `cid_services/client.py`: `clip_state_dict` / `apply_dp_mechanism` — the functions to exercise + +--- + +## Steps + +### 1. Measure the combined L2 norm of the real cache_model_0 weights + +Load the trained `state_dict` from `cache_model_0/` (the MNIST-style reference model) and compute the combined L2 norm: + +```python +import torch, math + +state_dict = torch.load("cache_model_0/") +combined_norm = math.sqrt(sum(t.norm().item() ** 2 for t in state_dict.values() if t.is_floating_point())) +print(f"Combined L2 norm: {combined_norm:.4f}") +``` + +Record this value in the PR description. + +### 2. Run clip_state_dict with the current clipping_norm: 1.0 + +Apply the DP mechanism with `clipping_norm=1.0` and `clip_scope="global"`: + +```python +from cid_services.client import clip_state_dict +clipped = clip_state_dict(state_dict, clipping_norm=1.0, clip_scope="global") +clipped_norm = math.sqrt(sum(t.norm().item() ** 2 for t in clipped.values() if t.is_floating_point())) +print(f"Clipped L2 norm: {clipped_norm:.4f} (reduction: {(1 - clipped_norm/combined_norm)*100:.1f}%)") +``` + +### 3. Decide whether to retune + +If the reduction is negligible (< 5%), `clipping_norm: 1.0` is acceptable as-is — update `cache_model_0/manifest.json` with a comment confirming this was verified. + +If the reduction is significant (≥ 5%), retune `clipping_norm` to a value that allows the real weights to pass through with minimal clipping (< 5%). A typical approach: set `clipping_norm` to the 75th–90th percentile of the combined norm measured across a representative sample of trained `state_dict`s. Record the chosen value and the measurement in the PR description. + +### 4. Add a real-weights test + +Add a test to `tests/test_cache_client_dp.py` that: + +- Loads the actual `cache_model_0` state_dict (using the existing model-loading path) +- Applies `clip_state_dict` with `clip_scope="global"` and the (possibly retuned) `clipping_norm` +- Asserts that the combined L2 norm post-clip is within 5% of the pre-clip norm (i.e. the model is not being significantly clipped under the new default) + +--- + +## Deliverables + +- [ ] Measurement of the combined L2 norm of the real `cache_model_0` trained weights documented in the PR +- [ ] `clip_state_dict` exercised against the real weights with current `clipping_norm: 1.0`; clipping percentage documented +- [ ] `cache_model_0/manifest.json` `clipping_norm` updated if retuning is needed; unchanged if 1.0 is verified OK +- [ ] New test in `tests/test_cache_client_dp.py` loading the real state_dict and asserting < 5% clipping under the configured `clipping_norm` and `clip_scope: global` +- [ ] `pytest tests/test_cache_client_dp.py` green +- [ ] `Developer/BACK_LOG.md` BL-23 status updated to "Fixed — PR #" + +## Notes + +- The sensitivity-calibration fix in PR #109 itself (the noise formula) is not in scope. This task only validates and, if necessary, retunes the `clipping_norm` value for the reference model. +- BL-24 (Laplace mechanism calibrated against L2 clip, not L1 sensitivity) is tracked separately in its own backlog entry and is explicitly out of scope here. +- If the trained `state_dict` is not checked into the repo but is fetched from IPFS, use `dincli ipfs fetch` with the CID in `cache_model_0/manifest.json` to retrieve it before running the measurements. diff --git a/Developer/tasks/task_141026_28.md b/Developer/tasks/task_141026_28.md new file mode 100644 index 0000000..32faa6c --- /dev/null +++ b/Developer/tasks/task_141026_28.md @@ -0,0 +1,78 @@ +# Task: Document One-Shot Dispute Limit in Code Comments and slashing-taxonomy.md (#248) + +**ID:** task_141026_28 +**Assigned to:** Unassigned +**Created:** 2026-10-14 +**Status:** Open (unassigned) +**Dates:** No fixed deadline. Documentation-only; small scope (< 1 hour). Can start immediately. +**Repo:** https://github.com/InfiniteZeroFoundation/DevNet +**Base branch:** `develop` — spec written against commit `aa76ce18` (2026-10-14); re-pull before starting. +**Roadmap ref:** P3 (doc completeness before audit); BL-15 (`Developer/BACK_LOG.md`) +**Tracking:** +- [#248](https://github.com/InfiniteZeroFoundation/DevNet/issues/248) (closes on PR merge) +- Post progress updates and the PR link in a comment on #248. + +--- + +## Background + +`DINTaskCoordinator.resolveDispute` sets `disputes[gi][tierKind][batchId].resolved = true` with no path back. `openDispute` reverts with `TC_DisputeAlreadyResolved` if called on an already-resolved batch. The result is that each batch allows exactly **one dispute per GI lifetime**, whether upheld or rejected. + +This is a reasonable design choice at the scaffold stage, but it was never recorded: +- No comment in `openDispute` or `resolveDispute` explains the one-shot constraint. +- `Developer/design/slashing-taxonomy.md` describes the dispute flow without noting the limit. + +A future reviewer or implementer who assumes disputes can be reopened will either silently hit `TC_DisputeAlreadyResolved` or incorrectly treat the constraint as a bug to fix. This task adds the missing documentation. + +**No behavior change. Documentation only.** + +--- + +## Status check at `aa76ce18` + +- `DINTaskCoordinator.sol` — `openDispute`: checks `disputes[...].resolved` and reverts, but no comment explains this is a permanent one-shot guard. +- `DINTaskCoordinator.sol` — `resolveDispute`: sets `resolved = true` with no comment about finality. +- `Developer/design/slashing-taxonomy.md` — dispute resolution section describes open/resolve flow but omits the one-shot constraint. + +--- + +## Changes + +### 1. DINTaskCoordinator.sol — openDispute + +Add a one-line comment directly above the `resolved` check: + +```solidity +// One dispute per batch per GI: once resolved (upheld or rejected), the batch cannot be re-disputed. +if (disputes[_GI][_tierKind][_batchId].resolved) revert TC_DisputeAlreadyResolved(); +``` + +### 2. DINTaskCoordinator.sol — resolveDispute + +Add a one-line comment where `resolved` is set to true: + +```solidity +disputes[_GI][_tierKind][_batchId].resolved = true; // permanent — see openDispute comment above +``` + +### 3. Developer/design/slashing-taxonomy.md — dispute resolution section + +In the section describing the S4 dispute flow (open → lock seed → resolve), add a note: + +> **One dispute per batch:** once a dispute on a batch is resolved — upheld or rejected — `resolved` is set permanently. No further dispute can be opened for that batch in the same GI. If multi-dispute-per-batch is ever needed, `resolved` would need to become a counter or a re-openable state enum; open a separate issue for that decision. + +--- + +## Deliverables + +- [ ] One-line comment added to `openDispute` explaining the one-shot guard +- [ ] One-line comment added to `resolveDispute` where `resolved = true` is set +- [ ] `Developer/design/slashing-taxonomy.md` updated with the one-dispute-per-batch note and a pointer to how the constraint would be lifted if needed +- [ ] `forge build && forge test` green (no behavior changes, but confirm comments don't break anything) +- [ ] `Developer/BACK_LOG.md` BL-15 status updated to "Fixed — PR #" + +## Notes + +- This is documentation-only. Do not change any contract logic, storage layout, or test assertions. +- Do not add multi-dispute-per-batch support in this PR. If that is desired later, it belongs in a new issue. +- Because there are no logic changes, no ABI refresh is needed and no `UpgradeValidation.t.sol` run is required. diff --git a/Documentation/technical/contracts/DINTaskAuditor.md b/Documentation/technical/contracts/DINTaskAuditor.md index e32bbfe..80f1736 100644 --- a/Documentation/technical/contracts/DINTaskAuditor.md +++ b/Documentation/technical/contracts/DINTaskAuditor.md @@ -110,12 +110,17 @@ Mappings marked *(internal)* have no auto-getter; read them through the view nam ## 6. Batches & Test Data -- **`createAuditorsBatches(gi, seed)`** (from the coordinator, state `AuditSeedLocked`) — `seed` is the coordinator's locked `auditSeed[gi]` (see [DINTaskCoordinator §6.3](DINTaskCoordinator.md#63-batch-assignment-seed-lock)). A zero seed reverts with `TA_AuditSeedNotLocked`; the coordinator checks this too, so this is a second guard. The function then: - 1. filters `dinAuditors[gi]` to auditors still `isValidatorActive` at call time (`TA_NotEnoughAuditors` if fewer than `auditorsPerBatch` remain); - 2. shuffles the active auditors with `keccak256(seed, "AUD_ADDR")` and the model indexes with `keccak256(seed, "AUD_IDX")` (Fisher-Yates, in memory); - 3. greedily forms batches of 3 auditors × 3 models (the last batch may take 2). Leftover auditors and models are unused. +- **`createAuditorsBatches(gi, seed)`** (from the coordinator, state `AuditSeedLocked`) — `seed` is the coordinator's locked `auditSeed[gi]` (see [DINTaskCoordinator §6.3](DINTaskCoordinator.md#63-batch-assignment-seed-lock)). A zero seed reverts with `TA_AuditSeedNotLocked`; the coordinator checks this too, so this is a second guard. - Emits `AuditorsBatchAuto` per batch and `AuditorsBatchesCreated`. The seed comes from a block hash, so the residual trust assumptions in the coordinator doc apply here as well; one of them is specific to this function: a validator can still change the active pool by unstaking after the seed is locked and before this call. + **Position-stable seat assignment** (issue #243): batch layout is derived from the fixed per-GI registration list (`dinAuditors[_GI]`), not the live active pool. An unstake between seed-lock and this call forfeits only the leaver's own seat; every other auditor's batch and seat position is fixed by the registration list and the locked seed. + + The function: + 1. Shuffles the fixed per-GI list `dinAuditors[_GI]` in memory with `keccak256(seed, "AUD_ADDR")` and the model indexes with `keccak256(seed, "AUD_IDX")` (Fisher-Yates). + 2. Computes batch count from registered count and model count: `batchCnt = mLen / modelsPerBatch + (remainder ≥ MIN_MODELS_PER_BATCH ? 1 : 0)`, capped by `rLen / auditorsPerBatch`. + 3. Position-stable seat fill: for each primary seat i (positions `0..batchCnt*auditorsPerBatch-1`), if the entry is inactive, replaces it with the first active reserve entry (positions `batchCnt*auditorsPerBatch..rLen-1`). Reverts `TA_NotEnoughAuditors` if reserve exhausted. + 4. Forms batches of `auditorsPerBatch` auditors × `modelsPerBatch` models each (last batch may take `MIN_MODELS_PER_BATCH`). Leftover auditors and models are unused. + + Emits `AuditorsBatchAuto` per batch and `AuditorsBatchesCreated`. Residual trust assumptions (sequencer trust, re-roll by declining to lock) are described in [DINTaskCoordinator §6.3](DINTaskCoordinator.md#63-batch-assignment-seed-lock). - **`assignAuditTestDataset(gi, batchId, testDataCID, encryptedKeys[], commitment)`** (owner) — stores the encrypted test-data CID (`AES-256-GCM(K, rawCID ‖ Sign(ownerSK, rawCID))`) and one encrypted copy of `K` per auditor, in the batch's auditor order. Every auditor must have an X25519 key on `DinValidatorStake`. Also stores `commitment = keccak256(gi, batchId, keccak256(K), keccak256(plaintext))`. Blocked while the batch awaits reassignment. - **`setTestDataAssignedFlag(gi, true)`** (from the coordinator, state `AuditorsBatchesCreated`) — reverts `TA_TestDataNotAssigned` unless every batch of the GI has a stored `testDataCommitments` entry. On success the coordinator moves to `AuditTestDataAssigned`, which `startLMsubmissionsEvaluation` requires; leaving `AuditorsBatchesCreated` is what makes the call one-shot. diff --git a/Documentation/technical/contracts/DINTaskCoordinator.md b/Documentation/technical/contracts/DINTaskCoordinator.md index 1ded10f..c276954 100644 --- a/Documentation/technical/contracts/DINTaskCoordinator.md +++ b/Documentation/technical/contracts/DINTaskCoordinator.md @@ -172,19 +172,23 @@ Both batch-creation steps shuffle with a seed that must be locked first (issue # A lock call reverts if the seed isn't anchored yet (`TC_*SeedNotAnchored`), is already locked (`TC_*SeedAlreadyLocked`), or the anchor block isn't mined yet (`TC_*SeedBlockNotMined`). If more than 256 blocks have passed and `blockhash(seedBlock)` is zero, the call re-anchors to `block.number + disputeSeedDelay` and emits the `…Reanchored` event; it never stores a zero-derived seed. -**Residual trust and gaps** (from the PR #191 review; tracked as BL-26 in [`Developer/BACK_LOG.md`](../../../Developer/BACK_LOG.md)): +**Residual trust and gaps** (as of issue #243 / PR #; tracked as BL-26 in [`Developer/BACK_LOG.md`](../../../Developer/BACK_LOG.md)): - **Sequencer trust.** `blockhash` is sequencer-produced on OP Stack, so this design trusts the sequencer not to grind. VRF is the mainnet-grade follow-up (issue #178). - **Re-roll by declining to lock.** Once `seedBlock` is mined, anyone can compute the resulting shuffle off-chain. A model owner who dislikes it can simply not lock, wait out the 256-block window, and get a fresh anchor on the next lock call. The permissionless lock only prevents this if another party locks first. -- **Post-lock pool reshaping.** The active pools are read when `createAuditorsBatches` / `autoCreateTier1AndTier2` run, after the seed is public. A validator can unstake between the lock and the create call, which changes the pool size and so reshuffles everyone. An attacker with several registered addresses can compute each outcome off-chain and unstake whichever address gives the batch they want. +- ~~**Post-lock pool reshaping.**~~ **Fixed by issue #243.** Batch layout is now derived from the fixed per-GI registration list (`dinAggregators[_GI]`) instead of the live active pool. An unstake between seed-lock and batch-creation forfeits only the leaver's own seat; every other validator's batch and seat position is determined solely by the fixed list and the locked seed. See §6.4 for the position-stable seat assignment algorithm. ### 6.4 `autoCreateTier1AndTier2` Requires `AggSeedLocked`, i.e. a locked `aggSeed[gi]`. -1. Filters the registered aggregators to those still `Active` at call time (`_activeAggregatorPool`); needs ≥ 3 (`TC_NotEnoughValidators`). -2. Shuffles the aggregators with `keccak256(seed, "AGG_ADDR")` and the approved model indexes with `keccak256(seed, "AGG_IDX")` (Fisher-Yates, in memory). Needs ≥ `T1_MODELS_PER_BATCH` approved models (`TC_NotEnoughApprovedModels`). -3. Greedily fills T1 batches with 3 aggregators and 3 models each (the last batch may take 2 models). -4. If ≥ 3 aggregators remain, forms the single T2 batch (id 0) from the next 3. -Leftover aggregators and models are simply not used this GI. If fewer than 3 aggregators remain for T2, no T2 batch exists and `finalizeT2Aggregation` has nothing to finalize. +**Position-stable seat assignment** (issue #243): The batch layout is derived from the fixed per-GI registration list (`dinAggregators[_GI]`), not the live active pool. An unstake between seed-lock and this call can only forfeit the leaver's own seat — every other validator's batch and seat position is fixed. + +1. Shuffles the fixed per-GI registration list `dinAggregators[_GI]` in memory with `keccak256(seed, "AGG_ADDR")`. Also shuffles approved model indexes with `keccak256(seed, "AGG_IDX")`. +2. Computes the batch layout from the registered count (`rLen`) and model count (`mLen`): T1 batch count = `mLen / T1_MODELS_PER_BATCH + (remainder ≥ MIN_T1_MODELS_PER_BATCH ? 1 : 0)`, capped by `rLen / T1_AGGREGATORS_PER_BATCH`; T2 exists if the remaining registered validators ≥ `T1_AGGREGATORS_PER_BATCH`. Total primary seats = `(t1cnt + hasT2) * T1_AGGREGATORS_PER_BATCH`. +3. Position-stable seat fill: for each primary seat i, if `regList[i]` is inactive, scan forward in the reserve section (positions `totalSeats..rLen-1`) for the first active entry and substitute it. Reverts with `TC_NotEnoughValidators` if the reserve is exhausted. +4. Builds T1 batches from the position-stable validator array (positions `0..3*t1cnt-1`) and the model array. The last T1 batch may receive `MIN_T1_MODELS_PER_BATCH` models if the model count is not divisible by 3. +5. If T2 exists, builds the T2 batch from positions `3*t1cnt..totalSeats-1`. + +Leftover aggregators and models (beyond `totalSeats`) are not used this GI. If fewer than `T1_AGGREGATORS_PER_BATCH` aggregators remain beyond the T1 seats, no T2 batch is created and `finalizeT2Aggregation` has nothing to finalize. ### 6.5 Aggregation (T1 / T2): commit, reveal, finalize diff --git a/foundry/src/DINTaskAuditor.sol b/foundry/src/DINTaskAuditor.sol index ea11dfb..af30d11 100644 --- a/foundry/src/DINTaskAuditor.sol +++ b/foundry/src/DINTaskAuditor.sol @@ -854,15 +854,16 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { return (scores[count / 2 - 1] + scores[count / 2]) / 2; } - /// @notice Partitions active auditors and submitted models into audit batches. + /// @notice Partitions registered auditors and submitted models into audit batches. /// @dev Called by the paired DINTaskCoordinator, which has already locked /// and passed in `seed` (issue #156 H-2) -- anchored at /// DINTaskCoordinator.closeLMsubmissions, before this function could - /// possibly run, so nobody can steer batch assignment by timing the - /// call. Rejects a zero seed independently of the coordinator's own - /// check (defense-in-depth, same principle as the M-3 fix elsewhere - /// in this contract). Returns false is never reached; reverts on any - /// failure condition. + /// possibly run. Rejects a zero seed independently of the coordinator's + /// own check (defense-in-depth). Shuffles the fixed per-GI registration + /// list (dinAuditors[_GI]); batch layout is derived from the registered + /// count so an unstake between lockAuditSeed and this call only forfeits + /// the leaver's own seat (issue #243). Returns false is never reached; + /// reverts on any failure condition. /// @param _GI Current GI index. /// @param seed Locked, ungrindable seed from DINTaskCoordinator.auditSeed. /// @return True on success. @@ -874,42 +875,52 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { revert TA_CannotCreateAuditorsBatches(); if (seed == bytes32(0)) revert TA_AuditSeedNotLocked(); - // Filter the historical registration list down to currently active auditors. - address[] memory auditorPool = _activeAuditorPool(_GI); - uint aLen = auditorPool.length; - - if (aLen < params.auditorsPerBatch) revert TA_NotEnoughAuditors(); + // ▸ 1. Shuffle the fixed per-GI registration list (issue #243). + address[] memory regList = dinAuditors[_GI]; + uint rLen = regList.length; + if (rLen < params.auditorsPerBatch) revert TA_NotEnoughAuditors(); // Domain-separated derived seeds, same reasoning as the coordinator's // AGG_ADDR/AGG_IDX split -- independent of aggSeed since this derives // from the separately-locked auditSeed. - _shuffleAddressArray(auditorPool, keccak256(abi.encodePacked(seed, "AUD_ADDR"))); + _shuffleAddressArray(regList, keccak256(abi.encodePacked(seed, "AUD_ADDR"))); - // ▸ 2. Build list of local model indexes + // ▸ 2. Build list of local model indexes (unchanged). LMSubmission[] storage lmlist = lmSubmissions[_GI]; uint[] memory modelIdx = new uint[](lmlist.length); + for (uint i = 0; i < lmlist.length; i++) modelIdx[i] = i; + _shuffleUintArray(modelIdx, keccak256(abi.encodePacked(seed, "AUD_IDX"))); - for (uint i = 0; i < lmlist.length; i++) { - modelIdx[i] = i; + // ▸ 3. Compute batch count from registered count (not live active count). + uint mLen = modelIdx.length; + uint batchCnt = mLen / params.modelsPerBatch + + (mLen % params.modelsPerBatch >= params.MIN_MODELS_PER_BATCH ? 1 : 0); + if (batchCnt * params.auditorsPerBatch > rLen) + batchCnt = rLen / params.auditorsPerBatch; + uint vPre = batchCnt * params.auditorsPerBatch; + + // ▸ 4. Position-stable seat fill. Inactive seat-holder at position i is + // replaced by the first active reserve entry (positions vPre..rLen-1). + uint resPtr = vPre; + for (uint i = 0; i < vPre; i++) { + if (!dinvalidatorStakeContract.isValidatorActive(regList[i])) { + while (resPtr < rLen && !dinvalidatorStakeContract.isValidatorActive(regList[resPtr])) { + resPtr++; + } + if (resPtr >= rLen) revert TA_NotEnoughAuditors(); + regList[i] = regList[resPtr++]; + } } - _shuffleUintArray(modelIdx, keccak256(abi.encodePacked(seed, "AUD_IDX"))); - // ▸ 3. Create batches, Greedily fill Auditors batches + // ▸ 5. Build audit batches from the position-stable validator array. uint vPtr; uint mPtr; - uint Batchcnt; - - while ( - vPtr + params.auditorsPerBatch <= auditorPool.length && - (mPtr + params.modelsPerBatch <= modelIdx.length || - (mPtr + params.MIN_MODELS_PER_BATCH <= modelIdx.length && - mPtr + params.modelsPerBatch > modelIdx.length)) - ) { - AuditBatch storage b = auditBatches[_GI].push(); - b.batchId = Batchcnt++; + for (uint b = 0; b < batchCnt; b++) { + AuditBatch storage batch = auditBatches[_GI].push(); + batch.batchId = b; for (uint256 k = 0; k < params.auditorsPerBatch; k++) { - b.auditors.push(auditorPool[vPtr + k]); - isBatchAuditor[_GI][b.batchId][auditorPool[vPtr + k]] = true; + batch.auditors.push(regList[vPtr + k]); + isBatchAuditor[_GI][b][regList[vPtr + k]] = true; } uint modelsToAssign = params.modelsPerBatch; @@ -918,17 +929,17 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { } for (uint256 k = 0; k < modelsToAssign; k++) { - b.modelIndexes.push(modelIdx[mPtr + k]); - isBatchModelIndex[_GI][b.batchId][modelIdx[mPtr + k]] = true; + batch.modelIndexes.push(modelIdx[mPtr + k]); + isBatchModelIndex[_GI][b][modelIdx[mPtr + k]] = true; } - emit AuditorsBatchAuto(_GI, b.batchId); + emit AuditorsBatchAuto(_GI, b); vPtr += params.auditorsPerBatch; mPtr += modelsToAssign; } - emit AuditorsBatchesCreated(_GI, Batchcnt); + emit AuditorsBatchesCreated(_GI, batchCnt); return true; } diff --git a/foundry/src/DINTaskCoordinator.sol b/foundry/src/DINTaskCoordinator.sol index 56aca44..3350a17 100644 --- a/foundry/src/DINTaskCoordinator.sol +++ b/foundry/src/DINTaskCoordinator.sol @@ -574,16 +574,21 @@ contract DINTaskCoordinator is Ownable, ReentrancyGuardTransient { _setGIstate(GIstates.LMSevaluationClosed); } - /// @notice Partitions active aggregators and approved models into Tier-1 batches - /// and creates a single Tier-2 batch from the remaining validators. - /// @dev Aggregators are filtered to those still Active at call time and shuffled - /// using the locked aggSeed (issue #156 H-2) -- fixed at + /// @notice Partitions registered aggregators and approved models into Tier-1 batches + /// and creates a single Tier-2 batch from the remaining slots. + /// @dev Shuffles the fixed per-GI registration list (dinAggregators[_GI]) using + /// the locked aggSeed (issue #156 H-2) -- anchored at /// closeLMsubmissionsEvaluation, before this function could possibly be /// called, so the caller (model owner) cannot steer batch assignment by - /// choosing when to call this. Reverts if fewer than - /// T1_AGGREGATORS_PER_BATCH active validators remain, or if fewer than - /// T1_MODELS_PER_BATCH models passed, or if the seed isn't locked yet - /// (call lockAggSeed first, which moves the GI to AggSeedLocked). + /// choosing when to call this. Batch layout is derived from the registered + /// count (not the live active count) so that an unstake between lockAggSeed + /// and this call can only forfeit the leaver's own seat; every other + /// validator's batch and seat position is determined solely by the fixed list + /// and the locked seed (issue #243). Inactive seat-holders are replaced from + /// the first active reserve entry (shuffled positions beyond the last assigned + /// seat). Reverts if fewer than T1_AGGREGATORS_PER_BATCH registered validators + /// exist, if models are insufficient, or if the seed isn't locked yet (call + /// lockAggSeed first, which moves the GI to AggSeedLocked). /// @param _GI Current GI index. function autoCreateTier1AndTier2( uint _GI @@ -591,35 +596,58 @@ contract DINTaskCoordinator is Ownable, ReentrancyGuardTransient { if (GIstate != GIstates.AggSeedLocked) revert TC_AggSeedNotLocked(); bytes32 seed = aggSeed[_GI]; - // Filter the historical registration list down to currently active validators. - address[] memory valPool = _activeAggregatorPool(_GI); - uint vLen = valPool.length; - if (vLen < T1_AGGREGATORS_PER_BATCH) revert TC_NotEnoughValidators(); + // ▸ 1. Shuffle the fixed per-GI registration list (issue #243: pool-reshaping fix). + // Storage → memory copy so we can shuffle in-place without touching state. + address[] memory regList = dinAggregators[_GI]; + uint rLen = regList.length; + if (rLen < T1_AGGREGATORS_PER_BATCH) revert TC_NotEnoughValidators(); // Domain-separated derived seeds so the address and index shuffles // don't correlate with each other or with DINTaskAuditor's shuffles // (which derive from the independently-locked auditSeed). - _shuffleAddressArray(valPool, keccak256(abi.encodePacked(seed, "AGG_ADDR"))); + _shuffleAddressArray(regList, keccak256(abi.encodePacked(seed, "AGG_ADDR"))); - // ▸ 2. Build list of approved model indexes + // ▸ 2. Build list of approved model indexes (unchanged). uint[] memory modelIdx = _collectApprovedModelIndexes(_GI); _shuffleUintArray(modelIdx, keccak256(abi.encodePacked(seed, "AGG_IDX"))); - // ▸ 3. Greedily fill Tier-1 batches + // ▸ 3. Compute batch layout from registered count (not live active count) so the + // batch structure is fixed before active-status is checked. + // t1cnt = batches from model dimension, capped by the validator dimension. + uint mLen = modelIdx.length; + uint t1cnt = mLen / T1_MODELS_PER_BATCH + + (mLen % T1_MODELS_PER_BATCH >= MIN_T1_MODELS_PER_BATCH ? 1 : 0); + if (t1cnt * T1_AGGREGATORS_PER_BATCH > rLen) + t1cnt = rLen / T1_AGGREGATORS_PER_BATCH; + bool hasT2 = (rLen - t1cnt * T1_AGGREGATORS_PER_BATCH >= T1_AGGREGATORS_PER_BATCH); + uint totalSeats = (t1cnt + (hasT2 ? 1 : 0)) * T1_AGGREGATORS_PER_BATCH; + + // ▸ 4. Position-stable seat fill. For each primary seat i (0..totalSeats-1), + // if the registered validator at that position is inactive, replace them + // with the first active entry from the reserve (positions totalSeats..rLen-1). + // Any unstake only forfeits the leaver's own seat; no one else shifts. + uint resPtr = totalSeats; + for (uint i = 0; i < totalSeats; ) { + if (!dinvalidatorStakeContract.isValidatorActive(regList[i])) { + while (resPtr < rLen && !dinvalidatorStakeContract.isValidatorActive(regList[resPtr])) { + unchecked { resPtr++; } + } + if (resPtr >= rLen) revert TC_NotEnoughValidators(); + unchecked { regList[i] = regList[resPtr++]; } + } + unchecked { i++; } + } + + // ▸ 5. Build Tier-1 batches from the position-stable validator array. uint vPtr; uint mPtr; - uint t1cnt; - while ( - vPtr + T1_AGGREGATORS_PER_BATCH <= valPool.length && - (mPtr + T1_MODELS_PER_BATCH <= modelIdx.length || - (mPtr + MIN_T1_MODELS_PER_BATCH <= modelIdx.length && - mPtr + T1_MODELS_PER_BATCH > modelIdx.length)) - ) { - Tier1Batch storage b = tier1Batches[_GI].push(); - b.batchId = t1cnt++; - - for (uint256 k = 0; k < T1_AGGREGATORS_PER_BATCH; k++) { - b.aggregators.push(valPool[vPtr + k]); - isTier1Aggregator[_GI][b.batchId][valPool[vPtr + k]] = true; + for (uint b = 0; b < t1cnt; ) { + Tier1Batch storage batch = tier1Batches[_GI].push(); + batch.batchId = b; + + for (uint256 k = 0; k < T1_AGGREGATORS_PER_BATCH; ) { + batch.aggregators.push(regList[vPtr + k]); + isTier1Aggregator[_GI][b][regList[vPtr + k]] = true; + unchecked { k++; } } uint modelsToAssign = T1_MODELS_PER_BATCH; @@ -627,26 +655,26 @@ contract DINTaskCoordinator is Ownable, ReentrancyGuardTransient { modelsToAssign = modelIdx.length - mPtr; } - for (uint256 k = 0; k < modelsToAssign; k++) { - b.modelIndexes.push(modelIdx[mPtr + k]); + for (uint256 k = 0; k < modelsToAssign; ) { + batch.modelIndexes.push(modelIdx[mPtr + k]); + unchecked { k++; } } - emit Tier1BatchAuto(_GI, b.batchId); + emit Tier1BatchAuto(_GI, b); - vPtr += T1_AGGREGATORS_PER_BATCH; - mPtr += modelsToAssign; + unchecked { vPtr += T1_AGGREGATORS_PER_BATCH; } + unchecked { mPtr += modelsToAssign; b++; } } - // ▸ 4. Create Tier-2 batch with EXACTLY T1_AGGREGATORS_PER_BATCH validators if enough remain - if (valPool.length - vPtr >= T1_AGGREGATORS_PER_BATCH) { + // ▸ 6. Create Tier-2 batch from positions immediately after the T1 seats. + if (hasT2) { Tier2Batch storage t2 = tier2Batches[_GI].push(); t2.batchId = 0; for (uint256 k = 0; k < T1_AGGREGATORS_PER_BATCH; k++) { - t2.aggregators.push(valPool[vPtr + k]); - isTier2Aggregator[_GI][t2.batchId][valPool[vPtr + k]] = true; + t2.aggregators.push(regList[vPtr + k]); + isTier2Aggregator[_GI][0][regList[vPtr + k]] = true; } - - emit Tier2BatchAuto(_GI, t2.batchId); + emit Tier2BatchAuto(_GI, 0); } _setGIstate(GIstates.T1nT2Bcreated); @@ -664,24 +692,26 @@ contract DINTaskCoordinator is Ownable, ReentrancyGuardTransient { // ──────────── internal shuffle helpers ──────────── function _shuffleAddressArray(address[] memory arr, bytes32 seed) internal pure { if (arr.length < 2) return; - for (uint i = arr.length - 1; i > 0; i--) { + for (uint i = arr.length - 1; i > 0; ) { uint j = uint( keccak256( abi.encodePacked(seed, i, arr.length) ) ) % (i + 1); (arr[i], arr[j]) = (arr[j], arr[i]); + unchecked { i--; } } } function _shuffleUintArray(uint[] memory arr, bytes32 seed) internal pure { - for (uint i = arr.length - 1; i > 0; i--) { + for (uint i = arr.length - 1; i > 0; ) { uint j = uint( keccak256( abi.encodePacked(seed, i, arr.length) ) ) % (i + 1); (arr[i], arr[j]) = (arr[j], arr[i]); + unchecked { i--; } } } diff --git a/foundry/test/BatchAssignmentStability.t.sol b/foundry/test/BatchAssignmentStability.t.sol new file mode 100644 index 0000000..3d790d1 --- /dev/null +++ b/foundry/test/BatchAssignmentStability.t.sol @@ -0,0 +1,414 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +// ───────────────────────────────────────────────────────────────────────────── +// Position-stable batch seat assignment (issue #243 / BL-26). +// +// Invariant under test: when the autoCreateTier1AndTier2 or createAuditorsBatches +// seed is already locked, an unstake by a PRIMARY-SEAT holder changes ONLY that +// seat (filled by the first active reserve entry). Every other validator's +// batch and seat position is identical whether or not the unstake happened. +// +// Run: forge test --match-contract BatchAssignmentStabilityTest -vv +// ───────────────────────────────────────────────────────────────────────────── + +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"; +import {DINTaskCoordinator} from "../src/DINTaskCoordinator.sol"; +import {DINTaskAuditor} from "../src/DINTaskAuditor.sol"; +import {auditCommitHash} from "./utils/AuditCommitHash.sol"; + +contract BatchAssignmentStabilityTest is Test { + // ───────────────────────────────────────────────────────────────────── + // Platform contracts + // ───────────────────────────────────────────────────────────────────── + + DinToken token; + DinCoordinator coordinator; + DinValidatorStake stake; + DINModelRegistry registry; + DinTreasury treasury; + DinFeeRouter feeRouter; + + DINTaskCoordinator tc; + DINTaskAuditor ta; + + address admin = makeAddr("admin"); + address modelOwner = makeAddr("modelOwner"); + + bytes32 constant SALT = bytes32(uint256(0xB47C)); + + // 9 aggregators: 3 T1 seats + 3 T2 seats + 3 reserve + address agg0 = makeAddr("agg0"); + address agg1 = makeAddr("agg1"); + address agg2 = makeAddr("agg2"); + address agg3 = makeAddr("agg3"); + address agg4 = makeAddr("agg4"); + address agg5 = makeAddr("agg5"); + address agg6 = makeAddr("agg6"); + address agg7 = makeAddr("agg7"); + address agg8 = makeAddr("agg8"); + + // 3 auditors (auditorsPerBatch default = 3) + address aud0 = makeAddr("aud0"); + address aud1 = makeAddr("aud1"); + address aud2 = makeAddr("aud2"); + + // ───────────────────────────────────────────────────────────────────── + // Deployment helpers (identical pattern to SecurityFindings.t.sol) + // ───────────────────────────────────────────────────────────────────── + + function _deployPlatform() internal { + vm.startPrank(admin); + treasury = DinTreasury(payable(address(new TransparentUpgradeableProxy( + address(new DinTreasury()), admin, abi.encodeCall(DinTreasury.initialize, ()) + )))); + + DinToken tokenImpl = new DinToken(); + token = DinToken(address(new TransparentUpgradeableProxy( + address(tokenImpl), admin, abi.encodeCall(DinToken.initialize, ()) + ))); + + DinCoordinator coordinatorImpl = new DinCoordinator(); + coordinator = DinCoordinator(address(new TransparentUpgradeableProxy( + address(coordinatorImpl), admin, abi.encodeCall(DinCoordinator.initialize, (address(token))) + ))); + token.setCoordinator(address(coordinator)); + + feeRouter = DinFeeRouter(address(new TransparentUpgradeableProxy( + address(new DinFeeRouter()), admin, + abi.encodeCall(DinFeeRouter.initialize, (address(token), address(treasury))) + ))); + coordinator.setFeeRouter(address(feeRouter)); + feeRouter.addFeeSource(address(coordinator)); + + DinValidatorStake stakeImpl = new DinValidatorStake(); + stake = DinValidatorStake(address(new TransparentUpgradeableProxy( + address(stakeImpl), admin, + abi.encodeCall(DinValidatorStake.initialize, (address(token), address(coordinator))) + ))); + coordinator.updateValidatorStakeContract(address(stake)); + + DINModelRegistry registryImpl = new DINModelRegistry(); + registry = DINModelRegistry(address(new TransparentUpgradeableProxy( + address(registryImpl), admin, + abi.encodeCall(DINModelRegistry.initialize, (address(stake))) + ))); + registry.setFeeRouter(address(feeRouter)); + feeRouter.addFeeSource(address(registry)); + stake.setSlashTreasury(address(treasury)); + vm.stopPrank(); + } + + function _deployTaskPair() internal { + vm.startPrank(modelOwner); + tc = new DINTaskCoordinator(address(stake), 1); + ta = new DINTaskAuditor(address(stake), address(tc), 1); + tc.setDINTaskAuditorContract(address(ta)); + vm.stopPrank(); + + vm.startPrank(admin); + coordinator.addSlasherContract(address(tc)); + coordinator.addSlasherContract(address(ta)); + vm.stopPrank(); + + vm.startPrank(modelOwner); + tc.setDINTaskCoordinatorAsSlasher(); + tc.setDINTaskAuditorAsSlasher(); + tc.setGenesisModelIpfsHash(bytes32(uint256(1))); + + ta.setDinToken(address(token)); + vm.deal(modelOwner, 1 ether); + coordinator.depositAndMint{value: 0.001 ether}(); + token.approve(address(ta), type(uint256).max); + ta.depositRewards(1, 1 ether); + tc.startGI(1); + vm.stopPrank(); + } + + function _fundAndStake(address who) internal { + vm.deal(who, 1 ether); + vm.prank(who); + coordinator.depositAndMint{value: 0.001 ether}(); + vm.startPrank(who); + token.approve(address(stake), type(uint256).max); + stake.stake(10 ether); + vm.stopPrank(); + } + + // ───────────────────────────────────────────────────────────────────── + // Drives the GI up to the point just after lockAggSeed, before + // autoCreateTier1AndTier2 is called. + // + // Setup: 9 aggregators, 3 auditors, 3 models. + // T1_MODELS_PER_BATCH=3 → t1cnt=1, T1 seats = 3 + // rLen=9, rLen-3=6 >= 3 → hasT2=true, T2 seats = 3 + // reserve = positions 6..8 in the shuffled list + // ───────────────────────────────────────────────────────────────────── + function _runToSeedLocked() internal { + _deployPlatform(); + _deployTaskPair(); + + address[9] memory aggs = [agg0,agg1,agg2,agg3,agg4,agg5,agg6,agg7,agg8]; + for (uint i = 0; i < 9; i++) _fundAndStake(aggs[i]); + _fundAndStake(aud0); + _fundAndStake(aud1); + _fundAndStake(aud2); + + // ── aggregator registration ────────────────────────────────────── + vm.prank(modelOwner); + tc.startDINaggregatorsRegistration(1); + + for (uint i = 0; i < 9; i++) { + vm.prank(aggs[i]); + tc.registerDINaggregator(1); + } + + vm.startPrank(modelOwner); + tc.closeDINaggregatorsRegistration(1); + + // ── auditor registration ───────────────────────────────────────── + tc.startDINauditorsRegistration(1); + vm.stopPrank(); + + vm.prank(aud0); ta.registerDINAuditor(1); + vm.prank(aud1); ta.registerDINAuditor(1); + vm.prank(aud2); ta.registerDINAuditor(1); + + // ── LMS: 3 models (gives exactly 1 T1 batch + T2) ─────────────── + vm.startPrank(modelOwner); + tc.closeDINauditorsRegistration(1); + tc.startLMsubmissions(1); + vm.stopPrank(); + + address client1 = makeAddr("client1"); + address client2 = makeAddr("client2"); + address client3 = makeAddr("client3"); + vm.prank(client1); ta.submitLocalModel(bytes32(uint256(100)), 1); + vm.prank(client2); ta.submitLocalModel(bytes32(uint256(200)), 1); + vm.prank(client3); ta.submitLocalModel(bytes32(uint256(300)), 1); + + // ── audit seed, batches, evaluation ───────────────────────────── + vm.startPrank(modelOwner); + tc.closeLMsubmissions(1); + vm.roll(block.number + tc.disputeSeedDelay() + 1); + tc.lockAuditSeed(1); + tc.createAuditorsBatches(1); + tc.setTestDataAssignedFlag(1, true); + tc.startLMsubmissionsEvaluation(1); + vm.stopPrank(); + + (, address[] memory batchAuds, uint[] memory modelIdxs,) = ta.getAuditorsBatch(1, 0); + for (uint i = 0; i < batchAuds.length; i++) { + for (uint m = 0; m < modelIdxs.length; m++) { + vm.prank(batchAuds[i]); + ta.commitAuditScore( + 1, 0, modelIdxs[m], + auditCommitHash(uint256(100), true, SALT, batchAuds[i], 1, 0, modelIdxs[m]) + ); + } + } + + vm.prank(modelOwner); + tc.startLMsubmissionsEvaluationReveal(1); + + for (uint i = 0; i < batchAuds.length; i++) { + for (uint m = 0; m < modelIdxs.length; m++) { + vm.prank(batchAuds[i]); + ta.revealAuditScore(1, 0, modelIdxs[m], 100, true, SALT); + } + } + + vm.startPrank(modelOwner); + tc.closeLMsubmissionsEvaluation(1); + vm.roll(block.number + tc.disputeSeedDelay() + 1); + tc.lockAggSeed(1); + vm.stopPrank(); + // ← caller may now call tc.autoCreateTier1AndTier2(1) + } + + // ───────────────────────────────────────────────────────────────────── + // Aggregator-side: position-stable seat assignment + // ───────────────────────────────────────────────────────────────────── + + /// @dev With seed locked, discover the baseline assignment, then revert, + /// unstake the primary-seat holder at T1[0], and assert only that + /// seat changes — every other validator stays in their exact position. + function test_aggregator_unstakeOnlydisplacesOwnSeat() public { + _runToSeedLocked(); + + // ── snapshot: seed is locked, batches not yet created ────────── + uint256 snap = vm.snapshot(); + + // ── baseline run: discover who occupies each seat ─────────────── + vm.prank(modelOwner); + tc.autoCreateTier1AndTier2(1); + + (, address[] memory t1agg,,, ) = tc.getTier1Batch(1, 0); + (, address[] memory t2agg,, ) = tc.getTier2Batch(1, 0); + + address seat0 = t1agg[0]; // this seat will be vacated + address seat1 = t1agg[1]; + address seat2 = t1agg[2]; + address t2s0 = t2agg[0]; + address t2s1 = t2agg[1]; + address t2s2 = t2agg[2]; + + // ── revert, unstake the seat-0 holder ─────────────────────────── + vm.revertTo(snap); + + vm.prank(seat0); + stake.unstake(10 ether); // drops to Exiting — isValidatorActive = false + + // ── re-run with seat0 inactive ─────────────────────────────────── + vm.prank(modelOwner); + tc.autoCreateTier1AndTier2(1); + + (, address[] memory t1agg2,,, ) = tc.getTier1Batch(1, 0); + (, address[] memory t2agg2,, ) = tc.getTier2Batch(1, 0); + + // seat0 must be replaced (by first active reserve) — but NOT by seat0 + assertTrue(t1agg2[0] != seat0, "vacated seat must be filled by reserve"); + // all other primary seats are unchanged + assertEq(t1agg2[1], seat1, "T1 seat1 must not move"); + assertEq(t1agg2[2], seat2, "T1 seat2 must not move"); + // T2 batch is entirely unchanged + assertEq(t2agg2[0], t2s0, "T2 seat0 must not move"); + assertEq(t2agg2[1], t2s1, "T2 seat1 must not move"); + assertEq(t2agg2[2], t2s2, "T2 seat2 must not move"); + // replaced seat is active + assertTrue(stake.isValidatorActive(t1agg2[0]), "replacement must be active"); + } + + /// @dev Same check for a T2 seat vacancy: only the T2 seat changes. + function test_aggregator_t2SeatUnstakeOnlyDisplacesT2Seat() public { + _runToSeedLocked(); + + uint256 snap = vm.snapshot(); + + vm.prank(modelOwner); + tc.autoCreateTier1AndTier2(1); + + (, address[] memory t1agg,,, ) = tc.getTier1Batch(1, 0); + (, address[] memory t2agg,, ) = tc.getTier2Batch(1, 0); + + address t1s0 = t1agg[0]; address t1s1 = t1agg[1]; address t1s2 = t1agg[2]; + address t2s0 = t2agg[0]; // this seat will be vacated + + vm.revertTo(snap); + + vm.prank(t2s0); + stake.unstake(10 ether); + + vm.prank(modelOwner); + tc.autoCreateTier1AndTier2(1); + + (, address[] memory t1agg2,,, ) = tc.getTier1Batch(1, 0); + (, address[] memory t2agg2,, ) = tc.getTier2Batch(1, 0); + + // T1 batch is entirely unchanged + assertEq(t1agg2[0], t1s0, "T1 seat0 must not move"); + assertEq(t1agg2[1], t1s1, "T1 seat1 must not move"); + assertEq(t1agg2[2], t1s2, "T1 seat2 must not move"); + // only T2 seat0 changed + assertTrue(t2agg2[0] != t2s0, "vacated T2 seat must be filled by reserve"); + assertTrue(stake.isValidatorActive(t2agg2[0]), "T2 replacement must be active"); + } + + // ───────────────────────────────────────────────────────────────────── + // Auditor-side: position-stable seat assignment + // + // With 6 auditors (3 primary + 3 reserve) and 3 models, + // batchCnt = 1, vPre = 3, reserve = positions 3..5. + // ───────────────────────────────────────────────────────────────────── + + function _runToAuditSeedLocked() internal { + _deployPlatform(); + _deployTaskPair(); + + // 6 auditors: 3 primary seats + 3 reserve + address[6] memory auds = [ + makeAddr("aud_s0"), makeAddr("aud_s1"), makeAddr("aud_s2"), + makeAddr("aud_r0"), makeAddr("aud_r1"), makeAddr("aud_r2") + ]; + for (uint i = 0; i < 6; i++) _fundAndStake(auds[i]); + + // Need at least 3 aggregators to drive the GI forward + address a0 = makeAddr("driveAgg0"); + address a1 = makeAddr("driveAgg1"); + address a2 = makeAddr("driveAgg2"); + _fundAndStake(a0); _fundAndStake(a1); _fundAndStake(a2); + + vm.prank(modelOwner); + tc.startDINaggregatorsRegistration(1); + vm.prank(a0); tc.registerDINaggregator(1); + vm.prank(a1); tc.registerDINaggregator(1); + vm.prank(a2); tc.registerDINaggregator(1); + + vm.startPrank(modelOwner); + tc.closeDINaggregatorsRegistration(1); + tc.startDINauditorsRegistration(1); + vm.stopPrank(); + + for (uint i = 0; i < 6; i++) { + vm.prank(auds[i]); + ta.registerDINAuditor(1); + } + + vm.startPrank(modelOwner); + tc.closeDINauditorsRegistration(1); + tc.startLMsubmissions(1); + vm.stopPrank(); + + // 3 models: 1 audit batch of 3 models + vm.prank(makeAddr("c0")); ta.submitLocalModel(bytes32(uint256(10)), 1); + vm.prank(makeAddr("c1")); ta.submitLocalModel(bytes32(uint256(20)), 1); + vm.prank(makeAddr("c2")); ta.submitLocalModel(bytes32(uint256(30)), 1); + + vm.startPrank(modelOwner); + tc.closeLMsubmissions(1); + vm.roll(block.number + tc.disputeSeedDelay() + 1); + tc.lockAuditSeed(1); + vm.stopPrank(); + // ← caller may now call tc.createAuditorsBatches(1) + } + + /// @dev With audit seed locked, discover baseline auditor assignment, revert, + /// unstake the seat-0 auditor, and assert only that seat changes. + function test_auditor_unstakeOnlyDisplacesOwnSeat() public { + _runToAuditSeedLocked(); + + uint256 snap = vm.snapshot(); + + // baseline + vm.prank(modelOwner); + tc.createAuditorsBatches(1); + + (, address[] memory auds, ,) = ta.getAuditorsBatch(1, 0); + address aseat0 = auds[0]; + address aseat1 = auds[1]; + address aseat2 = auds[2]; + + vm.revertTo(snap); + + vm.prank(aseat0); + stake.unstake(10 ether); + + vm.prank(modelOwner); + tc.createAuditorsBatches(1); + + (, address[] memory auds2, ,) = ta.getAuditorsBatch(1, 0); + + assertTrue(auds2[0] != aseat0, "vacated auditor seat must be filled by reserve"); + assertEq(auds2[1], aseat1, "auditor seat1 must not move"); + assertEq(auds2[2], aseat2, "auditor seat2 must not move"); + assertTrue(stake.isValidatorActive(auds2[0]), "replacement auditor must be active"); + } +} From cbcf31b4ec1c30af77118067a964310894ab5c2d Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Thu, 15 Oct 2026 03:36:52 +0100 Subject: [PATCH 2/4] docs: fill in PR #253 in BL-26 tracking references --- Developer/BACK_LOG.md | 2 +- Developer/design/adversarial-threat-model.md | 4 ++-- Documentation/technical/contracts/DINTaskCoordinator.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Developer/BACK_LOG.md b/Developer/BACK_LOG.md index 1001163..f8a3ac6 100644 --- a/Developer/BACK_LOG.md +++ b/Developer/BACK_LOG.md @@ -32,7 +32,7 @@ | BL-23 | DP / Privacy (cache_model_0) | `clip_scope: global` default paired with an unreviewed `clipping_norm: 1.0` may over-clip real weights | [PR #109](https://github.com/InfiniteZeroFoundation/DevNet/pull/109) (`fix/dp-sensitivity`) correctly ties noise to the clip (`noise_scale = noise_multiplier * clipping_norm`) and defaults `clip_scope` to `global` so the clip actually bounds the combined L2 norm the noise is calibrated against — the sensitivity-calibration fix itself is sound (`per_layer` only bounds the combined norm at `clipping_norm * sqrt(n)`, not `clipping_norm`, so pairing it with the new noise formula would under-noise as tensor count grows). But `cache_model_0/manifest.json`'s `clipping_norm: 1.0` was never revisited: under the old `per_layer` scope it bounded each tensor independently to norm 1.0; under `global` it bounds the *entire model's combined* L2 norm to 1.0, a much tighter constraint for any model with more than one floating tensor (`sqrt(Σ‖tensor_i‖²)` grows with tensor count even if no individual tensor's norm changes). No test in PR #109 exercises this against `cache_model_0`'s real trained `state_dict` (the new tests use synthetic tensors), so it's unverified whether the new default clips — and thus degrades — the reference model's weights far more aggressively than before. Follow-up: run `apply_dp_mechanism`/`clip_state_dict` against `cache_model_0`'s actual trained weights under the new default and retune `clipping_norm` if the combined-norm bound turns out to be destructive. | P3 (verify before/at merge) | PR #109 review, Sep 8, 2026 | 🆕 New — unaddressed; `cache_model_0/manifest.json`'s `clipping_norm` still `1.0`, and `tests/test_cache_client_dp.py` still exercises synthetic tensors (`build_small_state_dict`), not real trained weights | | BL-24 | DP / Privacy (cache_model_0) | F4 — Laplace noise calibrated against an L2 clip, not the L1 sensitivity it's normally calibrated against | `post_training_laplace`'s `laplace_scale` is scaled by the same `clipping_norm` as the Gaussian mechanisms (`clip_state_dict`'s L2-norm clip), but the Laplace mechanism's textbook privacy guarantee is calibrated against L1 sensitivity, not L2. [PR #109](https://github.com/InfiniteZeroFoundation/DevNet/pull/109) flagged this explicitly rather than silently assuming it's fine (`Documentation/technical/services/clients.md:210-212`: "Laplace noise calibrated against an L2 clip is an open question tracked separately"), and [#99](https://github.com/InfiniteZeroFoundation/DevNet/issues/99)'s F4 (`Documentation/technical/audits/dp-mechanism-review.md` §F4) called the structural mismatch confirmed but left the exact magnitude/severity for a DP specialist's sign-off — explicitly excluded from #99's scope rather than fixed. [#119](https://github.com/InfiniteZeroFoundation/DevNet/pull/119) (which closed #99's remaining F8 items) left F4 untouched, so nothing currently tracks it now that #99 is closed. Needs either a DP specialist's opinion that L2-calibrated Laplace is acceptable as-is (and the open-question note downgraded to an explanation), or a fix (e.g. recalibrating against an actual L1 clip, or reclassifying `post_training_laplace` as experimental alongside F5's `per_layer` treatment) before any ε is claimed for it. | P3/P4 (needs DP specialist input first) | [#99](https://github.com/InfiniteZeroFoundation/DevNet/issues/99) F4 (excluded from scope), `Documentation/technical/audits/dp-mechanism-review.md` §F4, Sep 10, 2026 | 🆕 New — unaddressed; `Documentation/technical/services/clients.md`'s open-question note on L2-calibrated Laplace noise is unchanged since PR #109/#119 | | BL-25 | Tooling / reference model (cache_model_0) | `cache_model_0/abis/` task-contract artifacts are stale vs. `foundry/src` | `cache_model_0/abis/DINTaskCoordinator.json` and `DINTaskAuditor.json` are full hardhat artifacts (abi + bytecode) last regenerated 2026-06-09, missing 67 ABI entries present in `foundry/src` for the coordinator (104 vs 170) and 96 for the auditor (68 vs 158), with pre-foundry constructors. [PR #171](https://github.com/InfiniteZeroFoundation/DevNet/pull/171) regenerated the six bundled `dincli/abis/` files from `foundry/out` but deliberately left these alone: [cache_model_0/manifest.json](../cache_model_0/manifest.json)'s `task_contracts` block pins each artifact to an IPFS CID, records `"type": "hardhat-artifact"` and a hardhat/cancun/optimizer-off `compilation` block, and carries deployed addresses, and dincli resolves task artifacts by CID. Regenerating the files locally would leave the manifest describing artifacts it no longer points to. Needs one pass: regenerate both from `foundry/out` (with bytecode, e.g. `dincli system dump-abi --bytecode --output cache_model_0/abis`), update the manifest's artifact type and compilation metadata to foundry, re-pin to IPFS and update the CIDs. Addresses only change on a redeploy. | P3/P4 | PR #171 merge (2026-09-26): out of scope for the PR's ABI refresh | 🆕 New — unaddressed | -| BL-26 | Solidity/foundry (task contracts) | Batch-assignment seed lock (H-2) shares BL-11's re-roll residual, plus a new pool-reshaping gap | `DINTaskCoordinator.autoCreateTier1AndTier2`/`createAuditorsBatches` (task_240926_18 Part B, issue #156 H-2 aggregation side) reuse BL-11's future-block-seed + permissionless-lock pattern (`lockAggSeed`/`lockAuditSeed`), so they inherit its residual (2): the model owner can decline to lock a seed they dislike and wait out the ~256-block `blockhash` window for a fresh draw — repeatable, since nothing locks automatically today. A second, distinct gap not present in BL-11's `_assignFreshSubgroup` (which shuffles a fixed pool and only lets an exiting validator drop itself, not re-roll the rest): `_activeAggregatorPool`/`_activeAuditorPool` were evaluated at `autoCreateTier1AndTier2`/`createAuditorsBatches` call time, *after* the seed was already public, and Fisher-Yates re-rolled the whole assignment on any pool-size change — so a validator could unstake between lock and create to reshape everyone else's batch. Cost to the attacker is real but modest (their own Sybils sit out the GI and enter unbonding). | P3/P4 | [PR #191](https://github.com/InfiniteZeroFoundation/DevNet/pull/191) review, Sep 29, 2026 | ✅ Fixed — PR # (issue [#243](https://github.com/InfiniteZeroFoundation/DevNet/issues/243)). Pool-reshaping gap closed: batch layout is now derived from the fixed per-GI registration list (`dinAggregators[_GI]` / `dinAuditors[_GI]`) rather than the live active pool; an unstake after seed-lock forfeits only the leaver's own seat (position-stable seat assignment). Re-roll-by-declining-to-lock residual remains, narrowed by validator-side dincli lock. Sequencer-trust residual tracked in [issue #178](https://github.com/InfiniteZeroFoundation/DevNet/issues/178). | +| BL-26 | Solidity/foundry (task contracts) | Batch-assignment seed lock (H-2) shares BL-11's re-roll residual, plus a new pool-reshaping gap | `DINTaskCoordinator.autoCreateTier1AndTier2`/`createAuditorsBatches` (task_240926_18 Part B, issue #156 H-2 aggregation side) reuse BL-11's future-block-seed + permissionless-lock pattern (`lockAggSeed`/`lockAuditSeed`), so they inherit its residual (2): the model owner can decline to lock a seed they dislike and wait out the ~256-block `blockhash` window for a fresh draw — repeatable, since nothing locks automatically today. A second, distinct gap not present in BL-11's `_assignFreshSubgroup` (which shuffles a fixed pool and only lets an exiting validator drop itself, not re-roll the rest): `_activeAggregatorPool`/`_activeAuditorPool` were evaluated at `autoCreateTier1AndTier2`/`createAuditorsBatches` call time, *after* the seed was already public, and Fisher-Yates re-rolled the whole assignment on any pool-size change — so a validator could unstake between lock and create to reshape everyone else's batch. Cost to the attacker is real but modest (their own Sybils sit out the GI and enter unbonding). | P3/P4 | [PR #191](https://github.com/InfiniteZeroFoundation/DevNet/pull/191) review, Sep 29, 2026 | ✅ Fixed — PR [#253](https://github.com/InfiniteZeroFoundation/DevNet/pull/253) (issue [#243](https://github.com/InfiniteZeroFoundation/DevNet/issues/243)). Pool-reshaping gap closed: batch layout is now derived from the fixed per-GI registration list (`dinAggregators[_GI]` / `dinAuditors[_GI]`) rather than the live active pool; an unstake after seed-lock forfeits only the leaver's own seat (position-stable seat assignment). Re-roll-by-declining-to-lock residual remains, narrowed by validator-side dincli lock. Sequencer-trust residual tracked in [issue #178](https://github.com/InfiniteZeroFoundation/DevNet/issues/178). | | BL-27 | dincli (model owner) | `dincli model-owner deploy` still uses the pre-foundry task-contract constructors | `dincli/cli/modelownerd/deploy.py` sends `DINTaskCoordinator.constructor(stake)` (`:35`) and `DINTaskAuditor.constructor(stake, coordinator)` (`:77`), but `foundry/src` takes `(stake, modelId)` and `(stake, coordinator, modelId)`. Against foundry artifacts, which are the bundled `dincli/abis/` since PR #171, the deploy fails at ABI encoding, so model owners can't deploy task contracts from dincli. The `tests/dincli/` harness hides this because it deploys task contracts from **hardhat** artifacts. The fix also has to settle where `modelId` comes from: the registry assigns it only at approval, after the contracts exist (`DINTaskCoordinator.md` §10 No. 4). | P3 (before any model-owner onboarding on DevNet 3.0) | PR No. 204 review, Sep 30, 2026 (caveats `DINTaskCoordinator.md` §10 No. 10, `DINTaskAuditor.md` §13 No. 8) | 🆕 New — unaddressed; tracked in [#223](https://github.com/InfiniteZeroFoundation/DevNet/issues/223) | | BL-28 | dincli (model owner) | No dincli command for `releaseGIRegistrationSlots` | `DINTaskCoordinator.releaseGIRegistrationSlots(gi)` (owner-only, once per ended GI) is the only thing that decrements validators' `activeRegistrationCount`; `endGI` deliberately doesn't (BL-10). dincli ships the function in its ABI but has no command that calls it, so under a non-zero `maxConcurrentRegistrationsPerStakeUnit` every GI permanently consumes its validators' concurrent-registration slots until they hit `TC_/TA_ConcurrentRegistrationCapReached`. Harmless while the cap is 0 (the default). | P3 (before the cap is set on any network) | task_100926_12 / issue #37 fast-follow; PR No. 204 review | 🆕 New — unaddressed; tracked in [#223](https://github.com/InfiniteZeroFoundation/DevNet/issues/223) | | BL-29 | Solidity/foundry (fees) | `DinFeeRouter`'s non-treasury shares accrue with no way out | `routeFeeETH`/`routeFeeDIN` pay the treasury share out immediately but only *count* the `validatorPool`, `storage` and `publicGoods` shares in `accruedEth`/`accruedDin`. No function withdraws or distributes them, so with the default 95% validator-pool split, 95% of every swept faucet/registry fee stays in the router until an upgrade adds a path. Needs a decision on who can pull each bucket and where the validator-pool share goes (reward pools? `DinEmission`?). | P3/P4 | PR No. 204 review (`DinCoordinator.md` §14 No. 2), Sep 30, 2026; related to issue #43's open items | 🆕 New — unaddressed; tracked in [#221](https://github.com/InfiniteZeroFoundation/DevNet/issues/221) | diff --git a/Developer/design/adversarial-threat-model.md b/Developer/design/adversarial-threat-model.md index b2e2e0e..1160d1a 100644 --- a/Developer/design/adversarial-threat-model.md +++ b/Developer/design/adversarial-threat-model.md @@ -32,7 +32,7 @@ entry in `foundry/test/P3Adversarial.t.sol`. |---|---| | **Attacker** | Sybil operator: k identities, each staking MIN_STAKE, targeting k-of-n seats in one batch | | **Capability** | k × MIN_STAKE total stake; no control of randomness — batch assignment is `_shuffleAddressArray(valPool, keccak256(seed, "AGG_ADDR"))` (`DINTaskCoordinator.sol:582`) and auditor seat assignment is `_shuffleAddressArray(auditorPool, keccak256(seed, "AUD_ADDR"))` (`DINTaskAuditor.sol:871`), where `seed` is the per-GI `aggSeed`/`auditSeed`: anchored to a future block (`block.number + disputeSeedDelay`) at the preceding GI-state transition and locked permissionlessly via `lockAggSeed`/`lockAuditSeed` (issue #156 H-2, PR #191). `autoCreateTier1AndTier2` and `createAuditorsBatches` are still `onlyOwner`, but revert unless the seed is locked, so the block the owner calls in no longer changes the shuffle. | -| **Protocol response today** | No Sybil-specific defence beyond the per-identity stake requirement. Owner block-picking of the seat shuffle is closed by the H-2 fix: the seed is fixed at a block not yet mined when it is anchored, and batch creation reads the stored seed (`test_createAuditorsBatches_timingIndependent` / `test_autoCreateTier1AndTier2_timingIndependent` in `SecurityFindings.t.sol`). Post-lock pool reshaping is closed by issue #243 (PR #): batch layout is now derived from the fixed per-GI registration list (`dinAggregators[_GI]`, `dinAuditors[_GI]`) rather than the live active pool, so an unstake between seed-lock and batch-creation forfeits only the leaver's own seat — every other validator's batch and seat position is determined solely by the fixed list and the locked seed (`test_aggregator_unstakeOnlyDisplacesOwnSeat` in `BatchAssignmentStability.t.sol`). Residual: the owner can still decline to lock a seed they dislike and let it re-anchor after the ~256-block `blockhash` window (a repeatable re-roll while nobody else locks — aggregator/auditor dincli now locks the seed from the validator side to narrow this). The seed also trusts the sequencer not to grind `blockhash` (TA-4). None of this changes the Sybil-cost framing below — k identities still win seats at the hypergeometric rate. | +| **Protocol response today** | No Sybil-specific defence beyond the per-identity stake requirement. Owner block-picking of the seat shuffle is closed by the H-2 fix: the seed is fixed at a block not yet mined when it is anchored, and batch creation reads the stored seed (`test_createAuditorsBatches_timingIndependent` / `test_autoCreateTier1AndTier2_timingIndependent` in `SecurityFindings.t.sol`). Post-lock pool reshaping is closed by issue #243 (PR [#253](https://github.com/InfiniteZeroFoundation/DevNet/pull/253)): batch layout is now derived from the fixed per-GI registration list (`dinAggregators[_GI]`, `dinAuditors[_GI]`) rather than the live active pool, so an unstake between seed-lock and batch-creation forfeits only the leaver's own seat — every other validator's batch and seat position is determined solely by the fixed list and the locked seed (`test_aggregator_unstakeOnlyDisplacesOwnSeat` in `BatchAssignmentStability.t.sol`). Residual: the owner can still decline to lock a seed they dislike and let it re-anchor after the ~256-block `blockhash` window (a repeatable re-roll while nobody else locks — aggregator/auditor dincli now locks the seed from the validator side to narrow this). The seed also trusts the sequencer not to grind `blockhash` (TA-4). None of this changes the Sybil-cost framing below — k identities still win seats at the hypergeometric rate. | | **Expected test outcome** | **COST-BOUNDED** — the test sets up n registered identities, k of which are Sybil. P(Sybil controls ≥ majority of a 3-seat batch) is hypergeometric: ≈ 3p² − 2p³ where p = k/n (not k/n). The test asserts the fraction of batches where Sybil controls majority and the total stake cost is k × MIN_STAKE. | | **Trust assumption** | n (total registered validators) large enough that k × MIN_STAKE to control majority is economically prohibitive relative to poisoned-model gain (TA-3); seat-shuffle seed not ground by the sequencer (TA-4). | @@ -166,7 +166,7 @@ entry in `foundry/test/P3Adversarial.t.sol`. | T1 wrong CID — finalCID not replaced, dissenter not refunded | [#194](https://github.com/InfiniteZeroFoundation/DevNet/issues/194) (#181 covers who adjudicates, not repairing the outcome) | Row 5 (T1 wrong CID) | `settleRecomputation(confirmed=true)` replaces `finalCID` and refunds the honest dissenter | | Dual-role registration guard | [#180](https://github.com/InfiniteZeroFoundation/DevNet/issues/180) | Row 6 (cross-role + owner block-selection) | **Flipped by task_061026_20 Part B:** per-address guard in `registerDINAuditor` (`TA_DualRoleNotAllowed`). The Sybil variant (second funded address) remains, bounded by stake cost | | Stronger batch-shuffle seed | [#156](https://github.com/InfiniteZeroFoundation/DevNet/issues/156) | Rows 2, 6 (owner block-selection) | **Flipped by PR #191:** shuffles seeded by the locked, future-block `aggSeed`/`auditSeed` rather than at owner-controlled call time. Residuals (decline-to-lock re-roll, post-lock pool reshaping) tracked in BL-26 | -| Post-lock pool-reshaping gap | [#243](https://github.com/InfiniteZeroFoundation/DevNet/issues/243) | Row 2 (Sybil batch control) | **Flipped by PR #:** batch layout derived from fixed registration list (`dinAggregators[_GI]`) not the live active pool; an unstake after seed-lock forfeits only the leaver's own seat (position-stable seat assignment). Residual: decline-to-lock re-roll still open (TA-4). Covered by `BatchAssignmentStability.t.sol` | +| Post-lock pool-reshaping gap | [#243](https://github.com/InfiniteZeroFoundation/DevNet/issues/243) | Row 2 (Sybil batch control) | **Flipped by PR [#253](https://github.com/InfiniteZeroFoundation/DevNet/pull/253):** batch layout derived from fixed registration list (`dinAggregators[_GI]`) not the live active pool; an unstake after seed-lock forfeits only the leaver's own seat (position-stable seat assignment). Residual: decline-to-lock re-roll still open (TA-4). Covered by `BatchAssignmentStability.t.sol` | | Decentralized dispute adjudication | [#181](https://github.com/InfiniteZeroFoundation/DevNet/issues/181) | Row 7 (owner suppresses dispute) and bond-locked-forever variant | `resolveDispute` gated by multi-sig, DAO committee, or fraud-proof; timeout added for unresolved disputes | | Dispute timeout for unresolved disputes | [#181](https://github.com/InfiniteZeroFoundation/DevNet/issues/181) | Row 7 (owner never resolves → bond locked) | `expireDispute` (or a new function) reachable when `d.resolved == false` after a deadline | | BlockFLow duplicate-update scoring | [#39](https://github.com/InfiniteZeroFoundation/DevNet/issues/39) | Row 10 (duplicate-update gaming) | BlockFLow fold-in scoring implemented | diff --git a/Documentation/technical/contracts/DINTaskCoordinator.md b/Documentation/technical/contracts/DINTaskCoordinator.md index c276954..d1dae1d 100644 --- a/Documentation/technical/contracts/DINTaskCoordinator.md +++ b/Documentation/technical/contracts/DINTaskCoordinator.md @@ -172,7 +172,7 @@ Both batch-creation steps shuffle with a seed that must be locked first (issue # A lock call reverts if the seed isn't anchored yet (`TC_*SeedNotAnchored`), is already locked (`TC_*SeedAlreadyLocked`), or the anchor block isn't mined yet (`TC_*SeedBlockNotMined`). If more than 256 blocks have passed and `blockhash(seedBlock)` is zero, the call re-anchors to `block.number + disputeSeedDelay` and emits the `…Reanchored` event; it never stores a zero-derived seed. -**Residual trust and gaps** (as of issue #243 / PR #; tracked as BL-26 in [`Developer/BACK_LOG.md`](../../../Developer/BACK_LOG.md)): +**Residual trust and gaps** (as of issue #243 / PR [#253](https://github.com/InfiniteZeroFoundation/DevNet/pull/253); tracked as BL-26 in [`Developer/BACK_LOG.md`](../../../Developer/BACK_LOG.md)): - **Sequencer trust.** `blockhash` is sequencer-produced on OP Stack, so this design trusts the sequencer not to grind. VRF is the mainnet-grade follow-up (issue #178). - **Re-roll by declining to lock.** Once `seedBlock` is mined, anyone can compute the resulting shuffle off-chain. A model owner who dislikes it can simply not lock, wait out the 256-block window, and get a fresh anchor on the next lock call. The permissionless lock only prevents this if another party locks first. - ~~**Post-lock pool reshaping.**~~ **Fixed by issue #243.** Batch layout is now derived from the fixed per-GI registration list (`dinAggregators[_GI]`) instead of the live active pool. An unstake between seed-lock and batch-creation forfeits only the leaver's own seat; every other validator's batch and seat position is determined solely by the fixed list and the locked seed. See §6.4 for the position-stable seat assignment algorithm. From 09eaab838681e254a253623253ceea3a5ed1f85d Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Mon, 26 Oct 2026 12:18:19 +0100 Subject: [PATCH 3/4] test(contracts): add missing assignPlaceholderTestData call in BatchAssignmentStabilityTest PR No. 272 added a per-batch testDataCommitments check to setTestDataAssignedFlag; the stability test's _runToSeedLocked helper was missing the placeholder assignment step before that call. --- foundry/test/BatchAssignmentStability.t.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/foundry/test/BatchAssignmentStability.t.sol b/foundry/test/BatchAssignmentStability.t.sol index 3d790d1..1fc0361 100644 --- a/foundry/test/BatchAssignmentStability.t.sol +++ b/foundry/test/BatchAssignmentStability.t.sol @@ -24,6 +24,7 @@ import {DinFeeRouter} from "../src/DinFeeRouter.sol"; import {DINTaskCoordinator} from "../src/DINTaskCoordinator.sol"; import {DINTaskAuditor} from "../src/DINTaskAuditor.sol"; import {auditCommitHash} from "./utils/AuditCommitHash.sol"; +import {assignPlaceholderTestData} from "./utils/PlaceholderTestData.sol"; contract BatchAssignmentStabilityTest is Test { // ───────────────────────────────────────────────────────────────────── @@ -201,6 +202,7 @@ contract BatchAssignmentStabilityTest is Test { vm.roll(block.number + tc.disputeSeedDelay() + 1); tc.lockAuditSeed(1); tc.createAuditorsBatches(1); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); From edc88f40a86dacc33b174e44c66456c5a0d5a4a8 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Tue, 27 Oct 2026 14:34:17 +0100 Subject: [PATCH 4/4] fix(test): remove PlaceholderTestData import deleted by revert of task_061026_21 PlaceholderTestData.sol was removed in PR No. 273 (revert of task_061026_21 GI-states richer states). BatchAssignmentStability.t.sol imported it for a per-batch commitment check that only existed in that reverted feature; setTestDataAssignedFlag no longer requires per-batch assignAuditTestDataset calls, so the import and call are simply removed. --- foundry/test/BatchAssignmentStability.t.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/foundry/test/BatchAssignmentStability.t.sol b/foundry/test/BatchAssignmentStability.t.sol index 1fc0361..3d790d1 100644 --- a/foundry/test/BatchAssignmentStability.t.sol +++ b/foundry/test/BatchAssignmentStability.t.sol @@ -24,7 +24,6 @@ import {DinFeeRouter} from "../src/DinFeeRouter.sol"; import {DINTaskCoordinator} from "../src/DINTaskCoordinator.sol"; import {DINTaskAuditor} from "../src/DINTaskAuditor.sol"; import {auditCommitHash} from "./utils/AuditCommitHash.sol"; -import {assignPlaceholderTestData} from "./utils/PlaceholderTestData.sol"; contract BatchAssignmentStabilityTest is Test { // ───────────────────────────────────────────────────────────────────── @@ -202,7 +201,6 @@ contract BatchAssignmentStabilityTest is Test { vm.roll(block.number + tc.disputeSeedDelay() + 1); tc.lockAuditSeed(1); tc.createAuditorsBatches(1); - assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank();