diff --git a/Developer/BACK_LOG.md b/Developer/BACK_LOG.md index daa3afa..8a387b0 100644 --- a/Developer/BACK_LOG.md +++ b/Developer/BACK_LOG.md @@ -17,7 +17,7 @@ | BL-8 | dincli | `import-deployments` still imports only four ProxyAdmin keys | `_DEPLOYMENTS_TO_DIN_INFO` ([dincli/cli/system.py](../dincli/cli/system.py)) maps four contracts + four `proxyAdmin*` keys; the foundry deployments JSON emits six of each since PR #51 (fee router). `dinTreasury`, `dinFeeRouter`, `proxyAdminTreasury`, `proxyAdminFeeRouter` are silently dropped by `import-deployments`. Nothing breaks today (dincli doesn't use those addresses yet). Also fix the docstring claim that "both flows write the same address schema" — hardhat still writes the four-proxy shape. | P3 | [PR #51](https://github.com/InfiniteZeroFoundation/DevNet/pull/51) review follow-up (merge d2b7383 extended the foundry deploy script to six proxies but not the dincli import side), Jul 24, 2026 | ✅ Resolved — [PR #116](https://github.com/InfiniteZeroFoundation/DevNet/pull/116) (merged 2026-09-09, `develop`) | | BL-9 | Daemon / SDK | `sdk/operations/` layer + real `dind` job handlers | `to_envelope()` (`dincli/sdk/serialize.py`, PR #31) is built and tested but has zero production call sites — no SDK operation's dataclass return is ever routed through it, and `dind`'s `JOB_HANDLERS` registry (PR #32) has exactly one job type (`"demo"`, a no-op). The `sdk/operations/` layer that would call real SDK functions (task registration, GI lifecycle, aggregator/auditor scoring, etc.) and feed their dataclass returns through `to_envelope()` for both `dind`'s job queue and the CLI's future `--json` output doesn't exist yet in either PR. Expected sequencing, not a regression — `operations/` is gated behind the wallet/session/tx keystone (BL-2); real job types (client/auditor/aggregator role automation) get wired in once that lands. | P4-1.2 | PR #31/#32 code review follow-up, Jul 30, 2026 | 🟡 Started, unmerged — [PR #31](https://github.com/InfiniteZeroFoundation/DevNet/pull/31)'s head now has `dincli/sdk/operations/` (`platform.py`: `get_platform_addresses`, `get_stake`, …) and [PR #32](https://github.com/InfiniteZeroFoundation/DevNet/pull/32) registers a first real job type, `read_stake`, next to `"demo"`. The role operations (task registration, GI lifecycle, aggregator/auditor scoring) and their `to_envelope()` routing still don't exist. Checked 2026-09-30 | | BL-10 | Solidity/foundry (rewards) | Reward settlement doubles the C-1 unbounded-loop gas-DoS surface | `DINTaskAuditor.settleRewards` (called from `DINTaskCoordinator.endGI`, task_210726_6 §3) iterates every client/auditor/aggregator to compute and credit shares, stacking on top of the existing per-GI iteration in `slashAuditors`/`slashAggregators`. Extends the already-tracked C-1 finding (2026-07 security review, `Developer/audits/2026-07_foundry-src-security-review.md`) to roughly 2x its worst measured cost at spec scale (10–50 validators / 100–500 clients). Needs a paginated or claim-time-computed settlement redesign before spec-scale deployment — same treatment C-1 itself is waiting on. | P3/P4 | PR #64 review, Jul 30, 2026 | ✅ Resolved — [PR #134](https://github.com/InfiniteZeroFoundation/DevNet/pull/134) (client/auditor claim-pull) + [PR #131](https://github.com/InfiniteZeroFoundation/DevNet/pull/131) (aggregator weighted extension) + [PR #136](https://github.com/InfiniteZeroFoundation/DevNet/pull/136) (gas benchmark), all merged 2026-09-10, `develop` | -| BL-11 | Solidity/foundry (slashing, dispute resolution) | Dispute-redo aggregator selection reuses the grindable blockhash shuffle (H-2) | `DINTaskCoordinator._assignFreshSubgroup` (task_210726_6 §4c dispute scaffold) shuffles via the same `blockhash(block.number - 1)`-based entropy flagged as finding H-2 in the 2026-07 security review. Materially worse here: the grinding party (model owner, via `resolveDispute`) is also the sole adjudicator and faces no deadline, so they can wait indefinitely for a favorable hash — undermining the one mechanism meant to catch owner/aggregator collusion. Should be resolved together with H-2 (commit-reveal or VRF-based randomness) before dispute resolution graduates past scaffold. | P3/P4 | PR #66 review, linked to H-2 (2026-07 security review), Jul 30, 2026 | ✅ Resolved — [PR #171](https://github.com/InfiniteZeroFoundation/DevNet/pull/171) (merged 2026-09-26, `develop`; task_240926_16 Part A, issue #151): future-block seed. `openDispute` anchors `seedBlock = block.number + disputeSeedDelay` (default 7, owner-settable 1–256); permissionless `lockDisputeSeed` stores `keccak256(blockhash(seedBlock), GI, tierKind, batchId)`; `resolveDispute(upheld=true)` requires a locked seed, so the draw no longer depends on when the owner resolves. `_assignFreshSubgroup` shuffles the GI's fixed registered aggregator pool and takes the first still-active, non-accused entries, so an aggregator exiting after the seed is public can only drop itself from the draw, not re-roll it. Residuals: (1) sequencer-produced blockhash is trusted — acceptable for testnet, VRF / multi-party commit-reveal is the mainnet-grade follow-up, tracked in [issue #178](https://github.com/InfiniteZeroFoundation/DevNet/issues/178); (2) if nobody calls `lockDisputeSeed` within ~256 blocks (~8.5 min on OP) of `seedBlock`, the lock re-anchors to a fresh future block, giving a second draw — nothing locks automatically today and dincli has no dispute commands yet. | +| BL-11 | Solidity/foundry (slashing, dispute resolution) | Dispute-redo aggregator selection reuses the grindable blockhash shuffle (H-2) | `DINTaskCoordinator._assignFreshSubgroup` (task_210726_6 §4c dispute scaffold) shuffles via the same `blockhash(block.number - 1)`-based entropy flagged as finding H-2 in the 2026-07 security review. Materially worse here: the grinding party (model owner, via `resolveDispute`) is also the sole adjudicator and faces no deadline, so they can wait indefinitely for a favorable hash — undermining the one mechanism meant to catch owner/aggregator collusion. Should be resolved together with H-2 (commit-reveal or VRF-based randomness) before dispute resolution graduates past scaffold. | P3/P4 | PR #66 review, linked to H-2 (2026-07 security review), Jul 30, 2026 | ✅ Resolved — [PR #171](https://github.com/InfiniteZeroFoundation/DevNet/pull/171) (merged 2026-09-26, `develop`; task_240926_16 Part A, issue #151): future-block seed. `openDispute` anchors `seedBlock = block.number + disputeSeedDelay` (default 7, owner-settable 1–256); permissionless `lockDisputeSeed` stores `keccak256(blockhash(seedBlock), GI, tierKind, batchId)`; `resolveDispute(upheld=true)` requires a locked seed, so the draw no longer depends on when the owner resolves. `_assignFreshSubgroup` shuffles the GI's fixed registered aggregator pool and takes the first still-active, non-accused entries, so an aggregator exiting after the seed is public can only drop itself from the draw, not re-roll it. Residuals: (1) sequencer-produced blockhash is trusted — acceptable for testnet, VRF / multi-party commit-reveal is the mainnet-grade follow-up, tracked in [issue #178](https://github.com/InfiniteZeroFoundation/DevNet/issues/178); (2) if nobody calls `lockDisputeSeed` within ~256 blocks (~8.5 min on OP) of `seedBlock`, the lock re-anchors to a fresh future block, giving a second draw — nothing locks automatically today. dincli's `model-owner disputes resolve-aggregation --upheld` locks the seed itself before resolving ([PR #302](https://github.com/InfiniteZeroFoundation/DevNet/pull/302)). | | BL-12 | Protocol / Mechanism design | Dispute bond denomination (DIN) vs. MECHANISM_DESIGN's ETH-denominated validator-fee bonds | `DINTaskCoordinator`'s dispute bond (task_210726_6 §4c) is DIN-denominated, while `MECHANISM_DESIGN.md`'s resolved decision (2026-07-21) places validator-network-fee bonds in ETH. Not necessarily wrong — dispute bonds and network-fee bonds may reasonably use different denominations — but wasn't flagged as a deliberate choice anywhere, including the slashing taxonomy doc. Needs an explicit decision recorded either way. | P3 | PR #66 review, Jul 30, 2026 | ✅ Resolved — [PR #133](https://github.com/InfiniteZeroFoundation/DevNet/pull/133) (merged 2026-09-10, `develop`); decision recorded as DIN-denominated | | BL-13 | dincli / Auditing | Encrypted test-data key correspondence enforced only by array length, not identity | `DINTaskAuditor.assignAuditTestDataset` checks `encryptedKeys.length == batch.auditors.length` but has no way to verify `encryptedKeys[i]` actually corresponds to `auditors[i]` — a misordered off-chain script could silently hand an auditor the wrong encrypted key. Fix likely belongs on the dincli side (keying the off-chain encryption step explicitly by auditor address rather than positional order) once real per-validator encryption is implemented — PR #63 only wired the on-chain plumbing, per task scope. | P3/P4 | PR #63 review, Jul 30, 2026 | ✅ Resolved — [PR #110](https://github.com/InfiniteZeroFoundation/DevNet/pull/110) (merged 2026-09-09, `develop`); `encryptedKeys` now built via explicit per-auditor address lookup, not array position | | BL-14 | Solidity/foundry (rewards) | Top-level bps-split rounding dust never swept | `DINTaskAuditor.settleRewards`'s integer-division 4-way bps split (client/auditor/aggregator/treasury) leaves a small rounding remainder uncollected — no fund-safety impact (funds stay in the contract), but nothing swept it to treasury either. Minor cleanup once the treasury-forwarding TODO from Part 3 (task_210726_6 §3) is resolved. | P3/P4 | PR #64 review, Jul 30, 2026 | ✅ Resolved, for the top-level split only — folded into [PR #134](https://github.com/InfiniteZeroFoundation/DevNet/pull/134)/[PR #131](https://github.com/InfiniteZeroFoundation/DevNet/pull/131)'s reward-engine rework: the 4-way split's remainder now forwards to the slash-treasury via `_forwardToTreasury` (burned if unset; counted in `treasuryAccrued`) (merged 2026-09-10, `develop`). The original title said "per-recipient," but the fix only ever covered this one top-level split; it was never about the second, per-participant rounding layer inside `claimReward` (each role's pool divided again per claimant) — that layer is untouched by this fix and is tracked separately as accepted-by-design in [issue #250](https://github.com/InfiniteZeroFoundation/DevNet/issues/250) / [DINTaskAuditor.md §13 No. 4](../Documentation/technical/contracts/DINTaskAuditor.md#13-review-notes--open-caveats) | @@ -34,7 +34,7 @@ | 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 | ✅ Fixed — PR No. 261 | | 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 pool-reshaping gap is now [#243](https://github.com/InfiniteZeroFoundation/DevNet/issues/243) (fix in review, [PR #253](https://github.com/InfiniteZeroFoundation/DevNet/pull/253)); the re-roll-by-declining-to-lock gap has no dedicated issue yet, same as BL-11's own un-issued residual (2) | | 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) | ✅ Fixed — PR No. 261 | -| 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) Part 2. Planned as `dincli model-owner gi release-slots --gi N` in [task-plan-051026-2](tasks-plan/umermjd11/task-plan-051026-2.md) TP-3; not in [PR #261](https://github.com/InfiniteZeroFoundation/DevNet/pull/261) | +| 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 | 🟡 Fix in review — [PR #302](https://github.com/InfiniteZeroFoundation/DevNet/pull/302) adds `dincli model-owner gi release-slots [--gi N]` (task_061026_21 Part C); tracked in [#223](https://github.com/InfiniteZeroFoundation/DevNet/issues/223) Part 2 | | 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) | | BL-30 | Solidity/foundry (task contracts) | No recovery from a stalled GI | If a T1/T2 batch never reaches the reveal quorum, `finalizeT1Aggregation`/`finalizeT2Aggregation` revert forever (`TC_NoSubmissions`/`TC_InsufficientSubmissions`), and there is no owner path to skip the batch or abort the GI. `endGI` needs `AggregatorsSlashed` and `startGI` needs `GIended`, so the model is stuck: its reward pool and its validators' registration slots stay locked. This is also why BL-26's contract-side re-anchor cap was deferred. Needs a GI-abort design: who may abort, when, and where the reward pool goes. | P3/P4 | PR No. 191 review (BL-26); PR No. 204 review (`DINTaskCoordinator.md` §10 No. 6) | 🆕 Decided, unimplemented — Abraham's GI-abort policy (Oct 26, [#222](https://github.com/InfiniteZeroFoundation/DevNet/issues/222)): anyone may abort after a stall timeout, locked rewards go back to their funders, slots are released, non-revealers are still penalised; the timeout value is set with the other testnet parameters in [#155](https://github.com/InfiniteZeroFoundation/DevNet/issues/155). Adds code to `DINTaskCoordinator`, which has 55 B above the size gate ([#201](https://github.com/InfiniteZeroFoundation/DevNet/issues/201)) | | BL-31 | Solidity/foundry (slashing) | S6 no-participation slashing is implemented but not wired | `DinValidatorStake.recordNoParticipation` (escalating 10%-per-breach slash past `s6NoParticipationThreshold`) exists and is tested, but no task contract calls it. They deliberately skip it where `slashPartial` (S1/S2, with S5 escalation) already penalises the same missed work, to avoid stacking past `MIN_STAKE`. So S6 never fires. Decide whether S6 should cover something S1/S2 don't (e.g. registered but never assigned or never active), or whether to remove it and its parameters. | P3 | PR No. 204 review (`DinValidatorStake.md` §14 No. 1), Sep 30, 2026; slashing taxonomy S6 (issue #38) | ⏸️ Parked for testnet — Abraham (Oct 26, [#224](https://github.com/InfiniteZeroFoundation/DevNet/issues/224)): S1/S2 plus S5 already cover missed work; leave S6 unwired (or remove it and its parameters) and revisit after testnet. Needs a removal PR or a "parked" note in the docs | diff --git a/dincli/abis/DinEmission.json b/dincli/abis/DinEmission.json new file mode 100644 index 0000000..b3d3769 --- /dev/null +++ b/dincli/abis/DinEmission.json @@ -0,0 +1,553 @@ +{ + "abi": [ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "coordinator", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDinCoordinator" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "decayBps", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "dinToken", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "emissionAtEpoch", + "inputs": [ + { + "name": "epoch", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "emissionForCurrentEpoch", + "inputs": [ + { + "name": "taskAuditor", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "emissionState", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "currentEpoch", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "gisInCurrentEpoch", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "currentEmissionPerGI", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "started", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "epochLength", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "fundGI", + "inputs": [ + { + "name": "gi", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "taskAuditor", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "giEmissionFunded", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialEmissionPerGI", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "coordinator_", + "type": "address", + "internalType": "address" + }, + { + "name": "dinToken_", + "type": "address", + "internalType": "address" + }, + { + "name": "modelRegistry_", + "type": "address", + "internalType": "address" + }, + { + "name": "initialEmissionPerGI_", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "decayBps_", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "epochLength_", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxEpochs_", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "maxEpochs", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "modelRegistry", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDINModelRegistry" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setEmissionParams", + "inputs": [ + { + "name": "initialEmissionPerGI_", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "decayBps_", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "epochLength_", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxEpochs_", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setModelRegistry", + "inputs": [ + { + "name": "modelRegistry_", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "totalEmitted", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "EmissionParamsUpdated", + "inputs": [ + { + "name": "initialEmissionPerGI", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "decayBps", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "epochLength", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "maxEpochs", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "GIFunded", + "inputs": [ + { + "name": "gi", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "taskAuditor", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "epoch", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ModelRegistryUpdated", + "inputs": [ + { + "name": "registry", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "EmissionExhausted", + "inputs": [] + }, + { + "type": "error", + "name": "GIAlreadyFunded", + "inputs": [ + { + "name": "gi", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InvalidAddress", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidParams", + "inputs": [] + }, + { + "type": "error", + "name": "ModelDisabledForEmission", + "inputs": [ + { + "name": "modelId", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ReentrancyGuardReentrantCall", + "inputs": [] + }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "UnregisteredTaskAuditor", + "inputs": [ + { + "name": "taskAuditor", + "type": "address", + "internalType": "address" + } + ] + } + ] +} \ No newline at end of file diff --git a/dincli/cli/aggregator.py b/dincli/cli/aggregator.py index 269bbe4..fcd32a2 100644 --- a/dincli/cli/aggregator.py +++ b/dincli/cli/aggregator.py @@ -7,10 +7,12 @@ from rich.table import Table from web3 import Web3 from eth_abi import encode as abi_encode -from dincli.cli.dintoken import (buy_dintokens, read_din_per_eth_rate, - read_dintoken_stake, stake_dintokens) +from dincli.cli.dintoken import (approve_din, buy_dintokens, + read_din_per_eth_rate, read_dintoken_stake, + stake_dintokens) from dincli.cli.utils import (CACHE_DIR, MIN_STAKE, build_and_send_tx, get_manifest_key, lock_batch_seed_if_pending, + parse_tier_kind, require_custom_manifest_service, resolve_manifest_path) from dincli.cli.worker import ( @@ -688,3 +690,64 @@ def reveal_t2( ) except Exception as e: console.print(f"[bold red]✗ Error revealing T2 aggregation: {e}[/bold red]") + + +@app.command("dispute", help="Dispute a finalized T1/T2 batch's CID (posts the DIN bond; any active validator)") +def dispute( + ctx: typer.Context, + model_id: int = typer.Argument(..., help="Model ID"), + gi: int = typer.Option(None, "--gi", help="GI of the batch (default: current GI)"), + tier: str = typer.Option(..., "--tier", help="t1 or t2"), + batch_id: int = typer.Option(..., "--batch", help="Batch ID within its tier"), +): + tier_kind = parse_tier_kind(tier) + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console(model_id) + + task_coordinator_contract = ctx.obj.get_deployed_din_task_coordinator_contract(True, model_id) + + curr_GI, _ = ctx.obj.get_current_gi_and_state(task_coordinator_contract) + ref_gi = ctx.obj.validate_gi_LTE_curr_GI(gi, curr_GI) + + # disputes getter: (challenger, bond, openedAt, ...). One dispute per batch, ever. + if task_coordinator_contract.functions.disputes(ref_gi, tier_kind, batch_id).call()[0] != "0x0000000000000000000000000000000000000000": + console.print(f"[bold red]✗ GI {ref_gi} {tier.upper()} batch {batch_id} has already been disputed.[/bold red]") + raise typer.Exit(1) + + din_token_address = task_coordinator_contract.functions.dinToken().call() + if din_token_address == "0x0000000000000000000000000000000000000000": + console.print("[bold red]✗ DINTaskCoordinator has no DIN token set, so it can't take a bond. The model owner must call setDinToken first.[/bold red]") + raise typer.Exit(1) + + bond = task_coordinator_contract.functions.disputeBond().call() + approve_din(ctx, task_coordinator_contract.address, bond, purpose="the dispute bond") + + build_and_send_tx( + ctx, + task_coordinator_contract.functions.openDispute(ref_gi, tier_kind, batch_id), + f"Disputing GI {ref_gi} {tier.upper()} batch {batch_id} (bond {Web3.from_wei(bond, 'ether')} DIN)", + "Dispute opened. The model owner resolves it.", + "Could not open the dispute (TC_BatchNotFinalized / TC_DisputeWindowClosed if the batch isn't in its dispute window)", + ) + + +@app.command("claim-dispute-bond", help="Withdraw dispute bonds returned to you (DINTaskCoordinator.claimDisputeBond)") +def claim_dispute_bond( + ctx: typer.Context, + model_id: int = typer.Argument(..., help="Model ID"), +): + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console(model_id) + + task_coordinator_contract = ctx.obj.get_deployed_din_task_coordinator_contract(True, model_id) + + amount = task_coordinator_contract.functions.disputeBondClaimable(account.address).call() + if amount == 0: + console.print("[bold red]✗ No dispute bond to claim.[/bold red]") + raise typer.Exit(1) + + build_and_send_tx( + ctx, + task_coordinator_contract.functions.claimDisputeBond(), + f"Claiming {Web3.from_wei(amount, 'ether')} DIN of dispute bond", + "Dispute bond claimed.", + "Could not claim the dispute bond", + ) diff --git a/dincli/cli/auditor.py b/dincli/cli/auditor.py index 280c7c9..6ef7fa9 100644 --- a/dincli/cli/auditor.py +++ b/dincli/cli/auditor.py @@ -13,8 +13,9 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM from eth_account.messages import encode_defunct from eth_account import Account -from dincli.cli.dintoken import (buy_dintokens, read_din_per_eth_rate, - read_dintoken_stake, stake_dintokens) +from dincli.cli.dintoken import (approve_din, buy_dintokens, + read_din_per_eth_rate, read_dintoken_stake, + stake_dintokens) from dincli.cli.utils import (CACHE_DIR, CONFIG_DIR, MIN_STAKE, build_and_send_tx, get_manifest_key, lock_batch_seed_if_pending, require_custom_manifest_service, @@ -31,8 +32,15 @@ from dincli.services.cid_utils import get_cid_from_bytes32 from dincli.services.ipfs import retrieve_from_ipfs -def _load_auditor_x25519_key() -> PrivateKey: - key_path = Path(CONFIG_DIR) / "auditor_x25519.key" +# One X25519 key per wallet: a single shared file broke several auditors +# (e.g. --demokey accounts) on one machine, since each registers its own +# public key on DinValidatorStake. +def _auditor_x25519_key_path(address: str) -> Path: + return Path(CONFIG_DIR) / f"auditor_x25519_{Web3.to_checksum_address(address)}.key" + + +def _load_auditor_x25519_key(address: str) -> PrivateKey: + key_path = _auditor_x25519_key_path(address) if not key_path.exists(): raise FileNotFoundError( f"Auditor X25519 private key not found at {key_path}. " @@ -165,6 +173,80 @@ def register( console.print(f"[bold red]✗ Could not register auditor. {e}[/bold red]") raise typer.Exit() +@app.command("register-encryption-key", help="Generate this wallet's X25519 key and register its public key on DinValidatorStake") +def register_encryption_key(ctx: typer.Context): + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console() + + stake_contract = ctx.obj.get_deployed_din_stake_contract() + + key_path = _auditor_x25519_key_path(account.address) + if key_path.exists(): + privkey = PrivateKey(bytes.fromhex(key_path.read_text().strip())) + console.print(f"[dim]Using existing X25519 key {key_path}[/dim]") + else: + privkey = PrivateKey.generate() + key_path.parent.mkdir(parents=True, exist_ok=True) + key_path.write_text(privkey.encode(encoder=nacl.encoding.HexEncoder).decode()) + console.print(f"[yellow]Generated X25519 key → {key_path}[/yellow]") + key_path.chmod(0o600) + + pubkey = bytes(privkey.public_key) + if bytes(stake_contract.functions.getEncryptionKey(account.address).call()) == pubkey: + console.print("[bold green]✓ This key is already registered on-chain.[/bold green]") + return + + build_and_send_tx( + ctx, + stake_contract.functions.registerEncryptionKey(pubkey), + "Registering encryption key", + "Encryption key registered.", + "Could not register encryption key", + ) + + +@app.command("dispute-test-data", help="Dispute your batch's test data (posts the DIN bond; batch auditors only)") +def dispute_test_data( + ctx: typer.Context, + model_id: int = typer.Argument(..., help="Model ID"), + gi: int = typer.Option(None, "--gi", help="Global iteration number"), + batch_id: int = typer.Option(..., "--batch", help="Your auditor batch ID"), +): + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console(model_id) + + task_coordinator_contract = ctx.obj.get_deployed_din_task_coordinator_contract(True, model_id) + task_auditor_contract = ctx.obj.get_deployed_din_task_auditor_contract(True, model_id) + + curr_GI, _ = ctx.obj.get_current_gi_and_state(task_coordinator_contract) + ref_gi = ctx.obj.validate_gi_LTE_curr_GI(gi, curr_GI) + + # Mirrors openTestDataDispute's checks, so a doomed dispute costs no approval. + if not task_auditor_contract.functions.isBatchAuditor(ref_gi, batch_id, account.address).call(): + console.print(f"[bold red]✗ You aren't an auditor of GI {ref_gi} batch {batch_id}.[/bold red]") + raise typer.Exit(1) + if task_auditor_contract.functions.giRewardSnapshot(ref_gi).call()[4]: + console.print(f"[bold red]✗ GI {ref_gi}'s rewards are already settled; it can't be disputed.[/bold red]") + raise typer.Exit(1) + if task_auditor_contract.functions.testDataCommitments(ref_gi, batch_id).call() == b"\x00" * 32: + console.print(f"[bold red]✗ No test data has been assigned to batch {batch_id} yet.[/bold red]") + raise typer.Exit(1) + d = task_auditor_contract.functions.testDataDisputes(ref_gi, batch_id).call() + if d[3] or d[4]: + console.print(f"[bold red]✗ Batch {batch_id} already has an active dispute or is pending reassignment.[/bold red]") + raise typer.Exit(1) + + bond = task_auditor_contract.functions.disputeBondAmount().call() + if bond > 0: + approve_din(ctx, task_auditor_contract.address, bond, purpose="the dispute bond") + + build_and_send_tx( + ctx, + task_auditor_contract.functions.openTestDataDispute(ref_gi, batch_id), + f"Opening a test-data dispute on GI {ref_gi} batch {batch_id} (bond {Web3.from_wei(bond, 'ether')} DIN)", + "Dispute opened. The model owner must answer within the window, or anyone can close it as upheld.", + "Could not open the dispute", + ) + + @app.command("lock-seed", help="Lock the auditor batch-assignment seed for the current GI (permissionless)") def lock_seed( ctx: typer.Context, @@ -470,7 +552,7 @@ def evaluate_lms( # fetch ciphertext IPFS(rawCID) → AES-GCM-decrypt(K) → plaintext .pt raw_cid = None try: - auditor_privkey = _load_auditor_x25519_key() + auditor_privkey = _load_auditor_x25519_key(account.address) owner_pubkey = PublicKey(bytes.fromhex( get_manifest_key(effective_network, "owner_encryption_pubkey", model_id) )) diff --git a/dincli/cli/context.py b/dincli/cli/context.py index 40b3da2..ca143af 100644 --- a/dincli/cli/context.py +++ b/dincli/cli/context.py @@ -206,6 +206,15 @@ def get_deployed_din_registry_contract(self, verbose: bool = True): artifact_path = files("dincli").joinpath("abis", "DINModelRegistry.json") return get_contract_instance(str(artifact_path), self.network, dinregistry_address) + def get_deployed_din_emission_contract(self, verbose: bool = True): + dinemission_address = load_din_info()[self.network].get("emission") + if not dinemission_address: + raise ValueError(f"No DinEmission address in din_info for network '{self.network}'") + if verbose: + self.console.print("[bold green]✓ DIN Emission contract address:[/bold green] ", dinemission_address) + artifact_path = files("dincli").joinpath("abis", "DinEmission.json") + return get_contract_instance(str(artifact_path), self.network, dinemission_address) + def _get_task_manifest_context( self, model_id: Optional[int], diff --git a/dincli/cli/dinrep.py b/dincli/cli/dinrep.py index c7cb9a0..72b1747 100644 --- a/dincli/cli/dinrep.py +++ b/dincli/cli/dinrep.py @@ -131,9 +131,45 @@ def total_models(ctx: typer.Context, console.print(f"[bold green]Total models: {models_length}[/bold green]") +def _task_contract_model_ids(network: str, req) -> dict: + """modelId() of a registration request's task contracts, keyed by name.""" + abis = files("dincli").joinpath("abis") + return { + name: get_contract_instance(str(abis.joinpath(abi)), network, address).functions.modelId().call() + for name, abi, address in ( + ("DINTaskCoordinator", "DINTaskCoordinator.json", req[3]), + ("DINTaskAuditor", "DINTaskAuditor.json", req[4]), + ) + } + + @registry_app.command("approve-registration-request") -def approve_registration_request(ctx: typer.Context, request_id: int = typer.Argument(..., help="Model request ID to approve")): +def approve_registration_request( + ctx: typer.Context, + request_id: int = typer.Argument(..., help="Model request ID to approve"), + force: bool = typer.Option(False, "--force", help="Approve even if the task contracts' modelId() doesn't match the ID this approval assigns"), +): + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console() DINModelRegistry_Contract = ctx.obj.get_deployed_din_registry_contract() + + # The task contracts bake modelId into their constructors before the + # registry assigns one, and approveModel assigns totalModels(). A + # mismatch breaks per-model stake enforcement for the model's lifetime. + req = DINModelRegistry_Contract.functions.modelRequests(request_id).call() + assigned_model_id = DINModelRegistry_Contract.functions.totalModels().call() + mismatched = { + name: model_id + for name, model_id in _task_contract_model_ids(effective_network, req).items() + if model_id != assigned_model_id + } + if mismatched: + for name, model_id in mismatched.items(): + console.print(f"[bold red]✗ {name} has modelId() {model_id}, but this approval assigns model ID {assigned_model_id}.[/bold red]") + if not force: + console.print("[yellow]Refusing to approve. The model owner should redeploy with the right --model-id; pass --force to approve anyway.[/yellow]") + raise typer.Exit(1) + console.print("[yellow]--force given: approving despite the mismatch.[/yellow]") + build_and_send_tx( ctx, DINModelRegistry_Contract.functions.approveModel(request_id), diff --git a/dincli/cli/dintoken.py b/dincli/cli/dintoken.py index a32cd5a..ef5a786 100644 --- a/dincli/cli/dintoken.py +++ b/dincli/cli/dintoken.py @@ -8,6 +8,26 @@ app = typer.Typer(help="Commands for DIN Token in DIN.") +def approve_din(ctx: typer.Context, spender: str, amount: int, purpose: str = "this action"): + """Approves `spender` to pull `amount` (wei) DIN from the active wallet. + + Shared by every command that has a contract pull DIN with + safeTransferFrom: staking, reward deposits and dispute bonds. Exits on a + failed approval, since the call that follows would only revert. + """ + din_token_contract = ctx.obj.get_deployed_din_token_contract(verbose=False) + build_and_send_tx( + ctx, + din_token_contract.functions.approve(spender, amount), + f"Approving DINTokens for {purpose}", + f"DINTokens approved for {purpose}.", + f"Could not approve DINTokens for {purpose}.", + exit_on_failure=True, + ) + # Give a lagging RPC time to see the allowance before the pull. + time.sleep(5) + + def buy_dintokens( ctx: typer.Context, amount: float, @@ -106,16 +126,7 @@ def stake_dintokens( console.print(f"[bold green]Staking {amount} DINTokens...[/bold green]") try: - build_and_send_tx( - ctx, - din_token_contract.functions.approve(din_stake_contract.address, stake_amount), - "Approving DINTokens for staking", - "DINTokens approved for staking.", - "Could not approve DINTokens for staking.", - exit_on_failure=True, - ) - - time.sleep(5) + approve_din(ctx, din_stake_contract.address, stake_amount, purpose="staking") build_and_send_tx( ctx, diff --git a/dincli/cli/modelowner.py b/dincli/cli/modelowner.py index 1bcea68..df7ddbe 100644 --- a/dincli/cli/modelowner.py +++ b/dincli/cli/modelowner.py @@ -3,10 +3,12 @@ from dincli.cli.modelownerd.aggregation import aggregation_app from dincli.cli.modelownerd.auditor_batches import auditor_batches_app from dincli.cli.modelownerd.deploy import deploy_app +from dincli.cli.modelownerd.disputes import disputes_app from dincli.cli.modelownerd.gi import gi_app from dincli.cli.modelownerd.lms import lms_app from dincli.cli.modelownerd.lms_evaluation import lms_evaluation_app from dincli.cli.modelownerd.model import model_app +from dincli.cli.modelownerd.rewards import rewards_app from dincli.cli.modelownerd.setup import (add_slasher) from dincli.cli.modelownerd.slash import slash_app from dincli.cli.modelownerd.task import task_app @@ -23,6 +25,8 @@ app.add_typer(slash_app, name="slash") app.command("add-slasher")(add_slasher) app.add_typer(task_app, name="task") +app.add_typer(rewards_app, name="rewards") +app.add_typer(disputes_app, name="disputes") if __name__ == "__main__": app() \ No newline at end of file diff --git a/dincli/cli/modelownerd/auditor_batches.py b/dincli/cli/modelownerd/auditor_batches.py index 65e77ab..f4c1ff8 100644 --- a/dincli/cli/modelownerd/auditor_batches.py +++ b/dincli/cli/modelownerd/auditor_batches.py @@ -1,3 +1,4 @@ +import json import secrets from pathlib import Path import time @@ -34,6 +35,85 @@ def _is_flag_already_set_revert(exc: Exception) -> bool: data_hex = data[2:] if data.startswith("0x") else data return data_hex.lower().startswith(_TA_FLAG_ALREADY_SET_SELECTOR) + +def _load_owner_x25519_key(console) -> PrivateKey: + """Loads (or generates) the owner's dedicated X25519 key, owner-only (0600).""" + owner_key_path = Path(CONFIG_DIR) / "owner_x25519.key" + if not owner_key_path.exists(): + new_key = PrivateKey.generate() + owner_key_path.write_text( + new_key.encode(encoder=nacl.encoding.HexEncoder).decode() + ) + console.print(f"[yellow]Generated owner X25519 key → {owner_key_path}[/yellow]") + owner_key_path.chmod(0o600) + return PrivateKey(bytes.fromhex(owner_key_path.read_text().strip())) + + +# The owner answers a test-data dispute by revealing K and the plaintext hash +# (DINTaskAuditor.resolveTestDataDispute), so both are kept per (gi, batch) +# from the moment they're committed on-chain. K decrypts the batch's test +# data, so the file is owner-only. +def _testdata_secret_path(model_base_dir: Path, gi: int, batch_id: int) -> Path: + return Path(model_base_dir) / "audits" / "testdata_keys" / f"gi_{gi}_batch_{batch_id}.json" + + +def _save_testdata_secret(model_base_dir: Path, gi: int, batch_id: int, K: bytes, plaintext_keccak: bytes) -> None: + path = _testdata_secret_path(model_base_dir, gi, batch_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"K": K.hex(), "plaintext_keccak": bytes(plaintext_keccak).hex()})) + path.chmod(0o600) + + +def _load_testdata_secret(model_base_dir: Path, gi: int, batch_id: int): + path = _testdata_secret_path(model_base_dir, gi, batch_id) + if not path.exists(): + return None + data = json.loads(path.read_text()) + return bytes.fromhex(data["K"]), bytes.fromhex(data["plaintext_keccak"]) + + +def _auditors_missing_encryption_key(stake_contract, auditors) -> List[str]: + """Batch auditors with no X25519 key on DinValidatorStake; assigning test + data to their batch reverts with TA_AuditorEncryptionKeyNotRegistered.""" + return [a for a in auditors if len(stake_contract.functions.getEncryptionKey(a).call()) == 0] + + +def _encrypt_batch_testdata(account, stake_contract, owner_privkey, gi: int, batch_auditors, batch_info): + """Builds the assignAuditTestDataset / reassignAuditTestDataset payload for + one batch: (encrypted_cid, encrypted_keys, commitment).""" + raw_cid = batch_info["raw_cid"] + K = batch_info["K"] + plaintext_keccak = batch_info["plaintext_keccak"] + batch_id = batch_info["batch_id"] + + # encryptedCID = AES-256-GCM(K, rawCID_bytes || eth_sign(ownerSK, rawCID_bytes)) + raw_cid_bytes = Web3.to_bytes(hexstr=get_bytes32_from_cid(raw_cid)) + sig = account.sign_message(encode_defunct(raw_cid_bytes)).signature + nonce = secrets.token_bytes(12) + encrypted_cid = nonce + AESGCM(K).encrypt(nonce, raw_cid_bytes + sig, None) + + # K encrypted to each auditor's registered X25519 pubkey via authenticated Box. + # Build address-keyed first, then reorder to the authoritative on-chain ordering + # so a future RPC or contract change that returns auditors in a different order + # cannot silently mismatch keys to recipients (BL-13, closes #115). + encrypted_key_by_addr = {} + for auditor_addr in batch_auditors: + pubkey_bytes = stake_contract.functions.getEncryptionKey(auditor_addr).call() + encrypted_key_by_addr[auditor_addr.lower()] = ( + Box(owner_privkey, PublicKey(bytes(pubkey_bytes))).encrypt(K) + ) + encrypted_keys = [encrypted_key_by_addr[addr.lower()] for addr in batch_auditors] + + # commitment = keccak256(abi.encodePacked(gi, batchId, keccak256(K), plaintext_keccak)) + keccak_K = bytes(Web3.keccak(K)) + commitment = bytes(Web3.keccak( + encode_packed( + ["uint256", "uint256", "bytes32", "bytes32"], + [gi, batch_id, keccak_K, bytes(plaintext_keccak)], + ) + )) + return encrypted_cid, encrypted_keys, commitment + @auditor_batches_app.command() def create( ctx: typer.Context, @@ -225,15 +305,7 @@ def create_testdataset( console.print(f"[bold green]Assigning test dataset![/bold green]") - # Load (or generate) the owner's dedicated X25519 private key. - owner_key_path = Path(CONFIG_DIR) / "owner_x25519.key" - if not owner_key_path.exists(): - new_key = PrivateKey.generate() - owner_key_path.write_text( - new_key.encode(encoder=nacl.encoding.HexEncoder).decode() - ) - console.print(f"[yellow]Generated owner X25519 key → {owner_key_path}[/yellow]") - owner_privkey = PrivateKey(bytes.fromhex(owner_key_path.read_text().strip())) + owner_privkey = _load_owner_x25519_key(console) # Write owner's X25519 pubkey into the local manifest cache so auditors # can read it via get_manifest_key("owner_encryption_pubkey"). The owner @@ -254,42 +326,34 @@ def create_testdataset( batch_ids_to_process = list(batch) if batch else list(range(audtor_batch_count)) + # Fail before any tx: assignAuditTestDataset reverts with + # TA_AuditorEncryptionKeyNotRegistered for a batch with a keyless auditor. + batch_auditors_by_id = { + batch_id: taskauditor_contract.functions.getAuditorsBatch(ref_gi, batch_id).call()[1] + for batch_id in batch_ids_to_process + } + missing_keys = sorted({ + a for auditors in batch_auditors_by_id.values() + for a in _auditors_missing_encryption_key(stake_contract, auditors) + }) + if missing_keys: + console.print("[bold red]✗ These batch auditors haven't registered an encryption key:[/bold red]") + for a in missing_keys: + console.print(f" {a}") + console.print("[yellow]Each must run `dincli auditor register-encryption-key` before test data can be assigned.[/yellow]") + raise typer.Exit(1) + failed_batch_ids = [] try: for batch_id in batch_ids_to_process: - batch_info = audit_testDataCIDs[batch_id] - raw_cid = batch_info["raw_cid"] - K = batch_info["K"] - plaintext_keccak = batch_info["plaintext_keccak"] - - # encryptedCID = AES-256-GCM(K, rawCID_bytes || eth_sign(ownerSK, rawCID_bytes)) - raw_cid_bytes = Web3.to_bytes(hexstr=get_bytes32_from_cid(raw_cid)) - sig = account.sign_message(encode_defunct(raw_cid_bytes)).signature - nonce = secrets.token_bytes(12) - encrypted_cid = nonce + AESGCM(K).encrypt(nonce, raw_cid_bytes + sig, None) - - _, batch_auditors, _, _ = taskauditor_contract.functions.getAuditorsBatch(ref_gi, batch_id).call() - - # K encrypted to each auditor's registered X25519 pubkey via authenticated Box. - # Build address-keyed first, then reorder to the authoritative on-chain ordering - # so a future RPC or contract change that returns auditors in a different order - # cannot silently mismatch keys to recipients (BL-13, closes #115). - encrypted_key_by_addr = {} - for auditor_addr in batch_auditors: - pubkey_bytes = stake_contract.functions.getEncryptionKey(auditor_addr).call() - encrypted_key_by_addr[auditor_addr.lower()] = ( - Box(owner_privkey, PublicKey(bytes(pubkey_bytes))).encrypt(K) - ) - encrypted_keys = [encrypted_key_by_addr[addr.lower()] for addr in batch_auditors] - - # commitment = keccak256(abi.encodePacked(gi, batchId, keccak256(K), plaintext_keccak)) - keccak_K = bytes(Web3.keccak(K)) - commitment = bytes(Web3.keccak( - encode_packed( - ["uint256", "uint256", "bytes32", "bytes32"], - [ref_gi, batch_id, keccak_K, bytes(plaintext_keccak)], - ) - )) + batch_info = dict(audit_testDataCIDs[batch_id], batch_id=batch_id) + encrypted_cid, encrypted_keys, commitment = _encrypt_batch_testdata( + account, stake_contract, owner_privkey, ref_gi, + batch_auditors_by_id[batch_id], batch_info, + ) + # Saved before the tx: once the commitment is on-chain, K is + # what answers a dispute against it. + _save_testdata_secret(model_base_path, ref_gi, batch_id, batch_info["K"], batch_info["plaintext_keccak"]) time.sleep(5) tx_receipt = build_and_send_tx( diff --git a/dincli/cli/modelownerd/disputes.py b/dincli/cli/modelownerd/disputes.py new file mode 100644 index 0000000..6397361 --- /dev/null +++ b/dincli/cli/modelownerd/disputes.py @@ -0,0 +1,277 @@ +import time +from pathlib import Path +from typing import Optional + +import typer + +from dincli.cli.modelownerd.auditor_batches import ( + _auditors_missing_encryption_key, _encrypt_batch_testdata, + _load_owner_x25519_key, _load_testdata_secret, _save_testdata_secret) +from dincli.cli.utils import (build_and_send_tx, get_manifest_key, + parse_tier_kind, require_custom_manifest_service, + resolve_manifest_path) + +disputes_app = typer.Typer(help="Answer and settle test-data and aggregation disputes") + +ZERO_BYTES32 = b"\x00" * 32 + + +# ── Test-data disputes (DINTaskAuditor) ───────────────────────────────────── + +def _active_testdata_dispute(console, task_auditor, gi: int, batch_id: int): + # testDataDisputes getter: (disputer, bond, expiresAtBlock, active, pendingReassignment) + d = task_auditor.functions.testDataDisputes(gi, batch_id).call() + if not d[3]: + console.print(f"[bold red]✗ No active test-data dispute for GI {gi} batch {batch_id}.[/bold red]") + raise typer.Exit(1) + return d + + +@disputes_app.command("resolve-test-data", help="Answer a test-data dispute by revealing the batch's K and plaintext hash (owner only)") +def resolve_test_data( + ctx: typer.Context, + model_id: int = typer.Argument(..., help="Model ID"), + gi: int = typer.Option(..., "--gi", help="GI of the disputed batch"), + batch_id: int = typer.Option(..., "--batch", help="Disputed auditor batch ID"), +): + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console(model_id) + + task_auditor = ctx.obj.get_deployed_din_task_auditor_contract(True, model_id) + + d = _active_testdata_dispute(console, task_auditor, gi, batch_id) + if w3.eth.block_number > d[2]: + console.print(f"[bold red]✗ The answer window closed at block {d[2]}; the dispute is upheld. Close it with `close-expired`.[/bold red]") + raise typer.Exit(1) + + secret = _load_testdata_secret(ctx.obj.get_model_base_dir(model_id), gi, batch_id) + if secret is None: + console.print(f"[bold red]✗ No saved K for GI {gi} batch {batch_id}. It is written by `auditor-batches create-testdataset --submit` on this machine.[/bold red]") + raise typer.Exit(1) + K, plaintext_keccak = secret + + build_and_send_tx( + ctx, + task_auditor.functions.resolveTestDataDispute(gi, batch_id, K, plaintext_keccak), + f"Answering the test-data dispute on GI {gi} batch {batch_id}", + "Dispute answered.", + "Could not answer the dispute", + ) + + d = task_auditor.functions.testDataDisputes(gi, batch_id).call() + if d[4]: + console.print(f"[yellow]The reveal didn't match the commitment, so the dispute was upheld. Reassign the batch with: dincli model-owner disputes reassign-test-data {model_id} --gi {gi} --batch {batch_id}[/yellow]") + else: + console.print("[bold green]The reveal matched the commitment; the disputer's bond is forfeited.[/bold green]") + + +@disputes_app.command("close-expired", help="Close a test-data dispute the owner didn't answer in time; it is upheld (permissionless)") +def close_expired( + ctx: typer.Context, + model_id: int = typer.Argument(..., help="Model ID"), + gi: int = typer.Option(..., "--gi", help="GI of the disputed batch"), + batch_id: int = typer.Option(..., "--batch", help="Disputed auditor batch ID"), +): + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console(model_id) + + task_auditor = ctx.obj.get_deployed_din_task_auditor_contract(True, model_id) + + d = _active_testdata_dispute(console, task_auditor, gi, batch_id) + if w3.eth.block_number <= d[2]: + console.print(f"[bold red]✗ The answer window is open until block {d[2]} (now {w3.eth.block_number}).[/bold red]") + raise typer.Exit(1) + + build_and_send_tx( + ctx, + task_auditor.functions.closeExpiredDispute(gi, batch_id), + f"Closing the expired test-data dispute on GI {gi} batch {batch_id}", + "Dispute closed and upheld; the batch now needs reassignment.", + "Could not close the dispute", + ) + + +@disputes_app.command("reassign-test-data", help="Assign fresh test data to a batch after an upheld test-data dispute (owner only)") +def reassign_test_data( + ctx: typer.Context, + model_id: int = typer.Argument(..., help="Model ID"), + gi: Optional[int] = typer.Option(None, "--gi", help="GI of the batch (must be the current GI)"), + batch_id: int = typer.Option(..., "--batch", help="Auditor batch ID to reassign"), + test_data_path: str = typer.Option(None, "--test-data-path", help="Path to test dataset"), +): + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console(model_id) + + task_coordinator = ctx.obj.get_deployed_din_task_coordinator_contract(True, model_id) + task_auditor = ctx.obj.get_deployed_din_task_auditor_contract(True, model_id) + stake_contract = ctx.obj.get_deployed_din_stake_contract(verbose=False) + + curr_GI, _ = ctx.obj.get_current_gi_and_state(task_coordinator) + ref_gi = ctx.obj.validate_gi_ET_curr_GI(gi, curr_GI) + + if not task_auditor.functions.testDataDisputes(ref_gi, batch_id).call()[4]: + console.print(f"[bold red]✗ GI {ref_gi} batch {batch_id} isn't pending reassignment.[/bold red]") + raise typer.Exit(1) + + batch_auditors = task_auditor.functions.getAuditorsBatch(ref_gi, batch_id).call()[1] + missing_keys = _auditors_missing_encryption_key(stake_contract, batch_auditors) + if missing_keys: + console.print(f"[bold red]✗ Batch auditors without an encryption key: {', '.join(missing_keys)}[/bold red]") + raise typer.Exit(1) + + model_base_path = ctx.obj.get_model_base_dir(model_id) + manifest = get_manifest_key(effective_network, "create_audit_testDataCIDs", model_id) + require_custom_manifest_service(manifest, "create_audit_testDataCIDs") + service_path = resolve_manifest_path(model_base_path, manifest["path"], what="create_audit_testDataCIDs path") + ctx.obj.ensure_file_exists(service_path, manifest["ipfs"], "model owner service", base_dir=model_base_path) + fn = ctx.obj.load_custom_fn(service_path, "create_audit_testDataCIDs", base_dir=model_base_path) + + test_data_path = Path(test_data_path) if test_data_path else model_base_path / "dataset" / "test" / "test_dataset.pt" + if not test_data_path.exists(): + raise FileNotFoundError(f"Test dataset not found at {test_data_path.resolve()}") + + # The service builds every batch's data with a fresh K each call; only + # this batch's entry is used. + batch_count = task_auditor.functions.AuditorsBatchCount(ref_gi).call() + batch_info = dict(fn(batch_count, ref_gi, str(model_base_path), str(test_data_path))[batch_id], batch_id=batch_id) + + owner_privkey = _load_owner_x25519_key(console) + encrypted_cid, encrypted_keys, commitment = _encrypt_batch_testdata( + account, stake_contract, owner_privkey, ref_gi, batch_auditors, batch_info, + ) + _save_testdata_secret(model_base_path, ref_gi, batch_id, batch_info["K"], batch_info["plaintext_keccak"]) + + build_and_send_tx( + ctx, + task_auditor.functions.reassignAuditTestDataset(ref_gi, batch_id, encrypted_cid, encrypted_keys, commitment), + f"Reassigning test data for GI {ref_gi} batch {batch_id}", + f"Test data reassigned for batch {batch_id}.", + f"Could not reassign test data for batch {batch_id}", + ) + + +# ── Aggregation disputes (DINTaskCoordinator, S4) ─────────────────────────── + +def _get_dispute(console, task_coordinator, gi: int, tier_kind: int, batch_id: int): + # disputes getter: (challenger, bond, openedAt, resolutionDeadline, + # seedBlock, resolved, upheld, finalized, seed) + d = task_coordinator.functions.disputes(gi, tier_kind, batch_id).call() + if d[0] == "0x0000000000000000000000000000000000000000": + console.print(f"[bold red]✗ No dispute on GI {gi} {('T1', 'T2')[tier_kind]} batch {batch_id}.[/bold red]") + raise typer.Exit(1) + return d + + +def _lock_dispute_seed(ctx, task_coordinator, gi: int, tier_kind: int, batch_id: int, poll_interval: float = 2.0): + """resolveDispute(upheld=true) needs the dispute's future-block seed + locked first. lockDisputeSeed is permissionless and may re-anchor once + more than 256 blocks have passed, so re-check after every attempt.""" + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console() + while True: + d = task_coordinator.functions.disputes(gi, tier_kind, batch_id).call() + if d[8] != ZERO_BYTES32: + return + seed_block = d[4] + if w3.eth.block_number <= seed_block: + console.print(f"[cyan]Waiting for block {seed_block} to lock the dispute seed (current: {w3.eth.block_number})...[/cyan]") + while w3.eth.block_number <= seed_block: + time.sleep(poll_interval) + receipt = build_and_send_tx( + ctx, + task_coordinator.functions.lockDisputeSeed(gi, tier_kind, batch_id), + "Locking the dispute seed", + "Dispute seed lock sent.", + "Could not lock the dispute seed", + exit_on_failure=False, + ) + # A failed lock is fine only if someone else locked it meanwhile; + # the loop's re-check returns in that case. + if receipt is None and task_coordinator.functions.disputes(gi, tier_kind, batch_id).call()[8] == ZERO_BYTES32: + raise typer.Exit(1) + + +@disputes_app.command("resolve-aggregation", help="Uphold or reject an aggregation dispute (owner only). Upholding assigns a fresh aggregator subgroup") +def resolve_aggregation( + ctx: typer.Context, + model_id: int = typer.Argument(..., help="Model ID"), + gi: int = typer.Option(..., "--gi", help="GI of the disputed batch"), + tier: str = typer.Option(..., "--tier", help="t1 or t2"), + batch_id: int = typer.Option(..., "--batch", help="Disputed batch ID"), + upheld: bool = typer.Option(..., "--upheld/--rejected", help="Uphold the dispute (fresh subgroup recomputes) or reject it (bond forfeited)"), +): + tier_kind = parse_tier_kind(tier) + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console(model_id) + + task_coordinator = ctx.obj.get_deployed_din_task_coordinator_contract(True, model_id) + + d = _get_dispute(console, task_coordinator, gi, tier_kind, batch_id) + if d[5]: + console.print("[bold red]✗ This dispute is already resolved.[/bold red]") + raise typer.Exit(1) + + if upheld: + _lock_dispute_seed(ctx, task_coordinator, gi, tier_kind, batch_id) + + build_and_send_tx( + ctx, + task_coordinator.functions.resolveDispute(gi, tier_kind, batch_id, upheld), + f"{'Upholding' if upheld else 'Rejecting'} the dispute", + f"Dispute {'upheld: a fresh subgroup must recompute before the resolution deadline' if upheld else 'rejected: bond forfeited'}.", + "Could not resolve the dispute", + ) + + +@disputes_app.command("settle-recomputation", help="Final verdict on an upheld aggregation dispute after the fresh subgroup recomputed (owner only)") +def settle_recomputation( + ctx: typer.Context, + model_id: int = typer.Argument(..., help="Model ID"), + gi: int = typer.Option(..., "--gi", help="GI of the disputed batch"), + tier: str = typer.Option(..., "--tier", help="t1 or t2"), + batch_id: int = typer.Option(..., "--batch", help="Disputed batch ID"), + confirmed: bool = typer.Option(..., "--confirmed/--dismissed", help="Recomputation confirmed the original CID was wrong (slash its aggregators) or matched it (bond forfeited)"), +): + tier_kind = parse_tier_kind(tier) + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console(model_id) + + task_coordinator = ctx.obj.get_deployed_din_task_coordinator_contract(True, model_id) + + d = _get_dispute(console, task_coordinator, gi, tier_kind, batch_id) + if not (d[5] and d[6]) or d[7]: + console.print("[bold red]✗ This dispute isn't awaiting recomputation (it must be upheld and not yet finalized).[/bold red]") + raise typer.Exit(1) + + build_and_send_tx( + ctx, + task_coordinator.functions.settleRecomputation(gi, tier_kind, batch_id, confirmed), + "Settling the recomputation", + f"Recomputation settled ({'original aggregators slashed, bond claimable by the challenger' if confirmed else 'bond forfeited'}).", + "Could not settle the recomputation", + ) + + +@disputes_app.command("expire", help="Time out an upheld aggregation dispute whose fresh subgroup didn't recompute (permissionless)") +def expire( + ctx: typer.Context, + model_id: int = typer.Argument(..., help="Model ID"), + gi: int = typer.Option(..., "--gi", help="GI of the disputed batch"), + tier: str = typer.Option(..., "--tier", help="t1 or t2"), + batch_id: int = typer.Option(..., "--batch", help="Disputed batch ID"), +): + tier_kind = parse_tier_kind(tier) + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console(model_id) + + task_coordinator = ctx.obj.get_deployed_din_task_coordinator_contract(True, model_id) + + d = _get_dispute(console, task_coordinator, gi, tier_kind, batch_id) + if not (d[5] and d[6]) or d[7]: + console.print("[bold red]✗ This dispute isn't awaiting recomputation (it must be upheld and not yet finalized).[/bold red]") + raise typer.Exit(1) + now = w3.eth.get_block("latest")["timestamp"] + if now <= d[3]: + console.print(f"[bold red]✗ The resolution window is still open (deadline {d[3]}, now {now}).[/bold red]") + raise typer.Exit(1) + + build_and_send_tx( + ctx, + task_coordinator.functions.expireDispute(gi, tier_kind, batch_id), + "Expiring the dispute", + "Dispute expired: the fresh subgroup is slashed and the challenger's bond is claimable.", + "Could not expire the dispute", + ) diff --git a/dincli/cli/modelownerd/gi.py b/dincli/cli/modelownerd/gi.py index 89f42e8..3f57134 100644 --- a/dincli/cli/modelownerd/gi.py +++ b/dincli/cli/modelownerd/gi.py @@ -93,6 +93,14 @@ def start( console.print(f"[red]❌ Invalid GI request:[/red] Expected {curr_gi + 1}, got {target_gi}") raise typer.Exit(1) + # startGI reverts with TC_GIRewardPoolNotFunded on an empty pool; check + # before the pass-score evaluation below. + task_auditor = ctx.obj.get_deployed_din_task_auditor_contract(False, model_id) + if task_auditor.functions.giRewardPool(target_gi).call() == 0: + console.print(f"[red]❌ GI {target_gi}'s reward pool is empty, so it can't start.[/red]") + console.print(f"[yellow]Fund it first: dincli model-owner rewards deposit {model_id} --gi {target_gi} --amount (or `rewards fund-emission`).[/yellow]") + raise typer.Exit(1) + console.print(f"[bold green]🚀 Starting global iteration {target_gi}[/bold green]") # === 2. Manifest Policy Resolution === @@ -368,3 +376,34 @@ def end( except Exception as e: console.print(f"[red]❌ Transaction failed:[/red] {str(e)}") raise typer.Exit(1) + + +@gi_app.command("release-slots", help="Release an ended GI's validator registration slots (DINTaskCoordinator.releaseGIRegistrationSlots, once per GI)") +def release_slots( + ctx: typer.Context, + model_id: int = typer.Argument(..., help="Model ID"), + gi: int = typer.Option(None, "--gi", help="Ended GI (default: the current GI, once ended)"), +): + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console(model_id) + + task_coordinator = ctx.obj.get_deployed_din_task_coordinator_contract(True, model_id) + + curr_GI, GIstate = ctx.obj.get_current_gi_and_state(task_coordinator) + ref_gi = gi if gi is not None else curr_GI + + curr_state = GIstate if isinstance(GIstate, str) else GIstateToStr(GIstate) + if ref_gi < 1 or ref_gi > curr_GI or (ref_gi == curr_GI and curr_state != "GIended"): + console.print(f"[red]❌ GI {ref_gi} has not ended (current GI {curr_GI}, state {curr_state}).[/red]") + raise typer.Exit(1) + + if task_coordinator.functions.registrationSlotsReleased(ref_gi).call(): + console.print(f"[yellow]Registration slots for GI {ref_gi} are already released.[/yellow]") + return + + build_and_send_tx( + ctx, + task_coordinator.functions.releaseGIRegistrationSlots(ref_gi), + f"Releasing registration slots for GI {ref_gi}", + f"Registration slots for GI {ref_gi} released.", + f"Could not release registration slots for GI {ref_gi}", + ) diff --git a/dincli/cli/modelownerd/rewards.py b/dincli/cli/modelownerd/rewards.py new file mode 100644 index 0000000..197e3da --- /dev/null +++ b/dincli/cli/modelownerd/rewards.py @@ -0,0 +1,104 @@ +from decimal import Decimal +from typing import Optional + +import typer +from web3 import Web3 + +from dincli.cli.dintoken import approve_din +from dincli.cli.utils import build_and_send_tx + +rewards_app = typer.Typer(help="Fund GI reward pools") + +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" + + +def _resolve_funding_gi(console, task_coordinator, gi: Optional[int]) -> int: + """depositRewards accepts the current GI or a later one (TA_InvalidRewardGI + otherwise). Defaults to the next GI, the one `gi start` needs funded.""" + curr_gi = task_coordinator.functions.GI().call() + target_gi = gi if gi is not None else curr_gi + 1 + if target_gi == 0 or target_gi < curr_gi: + console.print(f"[bold red]✗ GI {target_gi} can't be funded: the current GI is {curr_gi}, and only it or a later GI can be.[/bold red]") + raise typer.Exit(1) + return target_gi + + +@rewards_app.command(help="Deposit DIN into a GI's reward pool (approve + DINTaskAuditor.depositRewards). Anyone can fund.") +def deposit( + ctx: typer.Context, + model_id: int = typer.Argument(..., help="Model ID"), + amount: float = typer.Option(..., "--amount", help="DIN to deposit"), + gi: Optional[int] = typer.Option(None, "--gi", help="GI to fund (default: the next GI)"), +): + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console(model_id) + + task_coordinator = ctx.obj.get_deployed_din_task_coordinator_contract(True, model_id) + task_auditor = ctx.obj.get_deployed_din_task_auditor_contract(True, model_id) + din_token = ctx.obj.get_deployed_din_token_contract(verbose=False) + + target_gi = _resolve_funding_gi(console, task_coordinator, gi) + amount_wei = Web3.to_wei(Decimal(str(amount)), "ether") + if amount_wei <= 0: + console.print("[bold red]✗ --amount must be positive.[/bold red]") + raise typer.Exit(1) + + if task_auditor.functions.dinToken().call() == ZERO_ADDRESS: + console.print("[bold red]✗ DINTaskAuditor has no DIN token set. The model owner must call setDinToken on it first.[/bold red]") + raise typer.Exit(1) + + balance = din_token.functions.balanceOf(account.address).call() + if balance < amount_wei: + console.print(f"[bold red]✗ Not enough DIN: balance {Web3.from_wei(balance, 'ether')}, deposit {amount}.[/bold red]") + raise typer.Exit(1) + + approve_din(ctx, task_auditor.address, amount_wei, purpose="the GI reward pool") + + build_and_send_tx( + ctx, + task_auditor.functions.depositRewards(target_gi, amount_wei), + f"Depositing {amount} DIN into the GI {target_gi} reward pool", + f"GI {target_gi} reward pool funded.", + f"Could not deposit into the GI {target_gi} reward pool", + ) + + pool = task_auditor.functions.giRewardPool(target_gi).call() + console.print(f"[bold green]GI {target_gi} reward pool:[/bold green] {Web3.from_wei(pool, 'ether')} DIN") + + +@rewards_app.command("fund-emission", help="Fund a GI's reward pool from the protocol emission schedule (DinEmission.fundGI)") +def fund_emission( + ctx: typer.Context, + model_id: int = typer.Argument(..., help="Model ID"), + gi: Optional[int] = typer.Option(None, "--gi", help="GI to fund (default: the next GI)"), +): + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console(model_id) + + task_coordinator = ctx.obj.get_deployed_din_task_coordinator_contract(True, model_id) + task_auditor = ctx.obj.get_deployed_din_task_auditor_contract(True, model_id) + emission = ctx.obj.get_deployed_din_emission_contract() + + target_gi = _resolve_funding_gi(console, task_coordinator, gi) + + # fundGI reverts for these; checking first names the cause. + exists, registry_model_id = ctx.obj.get_deployed_din_registry_contract(verbose=False).functions.getModelIdByTaskAuditor(task_auditor.address).call() + if not exists: + console.print("[bold red]✗ This DINTaskAuditor isn't registered in DINModelRegistry, so DinEmission won't fund it (UnregisteredTaskAuditor).[/bold red]") + raise typer.Exit(1) + if emission.functions.giEmissionFunded(task_auditor.address, target_gi).call(): + console.print(f"[bold red]✗ GI {target_gi} was already funded from emission (GIAlreadyFunded).[/bold red]") + raise typer.Exit(1) + amount = emission.functions.emissionForCurrentEpoch(task_auditor.address).call() + if amount == 0: + console.print("[bold red]✗ This model's emission schedule is exhausted (EmissionExhausted). Use `rewards deposit` instead.[/bold red]") + raise typer.Exit(1) + + build_and_send_tx( + ctx, + emission.functions.fundGI(target_gi, task_auditor.address), + f"Funding GI {target_gi} with {Web3.from_wei(amount, 'ether')} DIN of emission", + f"GI {target_gi} reward pool funded from emission.", + f"Could not fund GI {target_gi} from emission (ModelDisabledForEmission if model {registry_model_id} is disabled)", + ) + + pool = task_auditor.functions.giRewardPool(target_gi).call() + console.print(f"[bold green]GI {target_gi} reward pool:[/bold green] {Web3.from_wei(pool, 'ether')} DIN") diff --git a/dincli/cli/rewards.py b/dincli/cli/rewards.py new file mode 100644 index 0000000..ad149b4 --- /dev/null +++ b/dincli/cli/rewards.py @@ -0,0 +1,63 @@ +import typer +from web3 import Web3 + +from dincli.cli.utils import build_and_send_tx + +app = typer.Typer(help="Claim GI rewards (any role: clients, auditors, aggregators).") + + +@app.command(help="Credit your reward share for a settled GI (DINTaskAuditor.claimReward)") +def claim( + ctx: typer.Context, + model_id: int = typer.Argument(..., help="Model ID"), + gi: int = typer.Option(..., "--gi", help="GI to claim for (must have ended)"), +): + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console(model_id) + + task_auditor = ctx.obj.get_deployed_din_task_auditor_contract(True, model_id) + + # giRewardSnapshot getter: (clientPool, auditorPool, aggregatorPool, + # aggregatorTotalWeight, settled). settleRewards runs in endGI. + settled = task_auditor.functions.giRewardSnapshot(gi).call()[4] + if not settled: + console.print(f"[bold red]✗ GI {gi} rewards are not settled yet. They settle when the model owner ends the GI.[/bold red]") + raise typer.Exit(1) + + if task_auditor.functions.rewardClaimed(gi, account.address).call(): + console.print(f"[yellow]Reward for GI {gi} already claimed. Run `dincli rewards withdraw {model_id}` to transfer it.[/yellow]") + return + + build_and_send_tx( + ctx, + task_auditor.functions.claimReward(gi), + f"Claiming reward for GI {gi}", + f"Reward for GI {gi} credited.", + f"Could not claim reward for GI {gi} (TA_NoRewardEarned if you earned nothing in this GI)", + ) + + claimable = task_auditor.functions.claimable(account.address).call() + console.print(f"[bold green]Claimable balance:[/bold green] {Web3.from_wei(claimable, 'ether')} DIN") + console.print(f"[dim]Transfer it with: dincli rewards withdraw {model_id}[/dim]") + + +@app.command(help="Transfer your whole claimable reward balance to your wallet (DINTaskAuditor.claimRewards)") +def withdraw( + ctx: typer.Context, + model_id: int = typer.Argument(..., help="Model ID"), +): + effective_network, w3, account, console = ctx.obj.get_en_w3_account_console(model_id) + + task_auditor = ctx.obj.get_deployed_din_task_auditor_contract(True, model_id) + + claimable = task_auditor.functions.claimable(account.address).call() + if claimable == 0: + console.print(f"[bold red]✗ Nothing to withdraw. Credit a GI first with: dincli rewards claim {model_id} --gi N[/bold red]") + raise typer.Exit(1) + + build_and_send_tx( + ctx, + task_auditor.functions.claimRewards(), + f"Withdrawing {Web3.from_wei(claimable, 'ether')} DIN", + "Rewards withdrawn.", + "Could not withdraw rewards", + ) diff --git a/dincli/cli/utils.py b/dincli/cli/utils.py index 8f9b51e..0fd94cb 100644 --- a/dincli/cli/utils.py +++ b/dincli/cli/utils.py @@ -975,6 +975,17 @@ def resolve_task_coordinator_address( return None +# DINTaskCoordinator.TierKind ordinals. +TIER_KINDS = {"t1": 0, "t2": 1} + + +def parse_tier_kind(tier: str) -> int: + try: + return TIER_KINDS[tier.strip().lower()] + except KeyError: + raise typer.BadParameter("--tier must be t1 or t2") + + def build_and_send_tx( ctx, contract_function, diff --git a/dincli/main.py b/dincli/main.py index 9b71866..99d9e20 100644 --- a/dincli/main.py +++ b/dincli/main.py @@ -10,6 +10,7 @@ from dincli.cli.dinrep import app as dinrep_app from dincli.cli.ipfs import app as ipfs_app from dincli.cli.modelowner import app as model_owner_app +from dincli.cli.rewards import app as rewards_app # Import role-specific subcommands from dincli.cli.system import app as system_app from dincli.cli.task import app as task_app @@ -30,6 +31,7 @@ app.add_typer(dintoken_app, name="dintoken") app.add_typer(task_app, name="task") app.add_typer(ipfs_app, name="ipfs") +app.add_typer(rewards_app, name="rewards") @app.callback(invoke_without_command=True) def main( diff --git a/tests/_cli_fakes.py b/tests/_cli_fakes.py new file mode 100644 index 0000000..932b873 --- /dev/null +++ b/tests/_cli_fakes.py @@ -0,0 +1,122 @@ +"""Minimal web3/DinContext stand-ins for unit-testing dincli commands. + +A FakeContract answers `contract.functions.(*args).call()` from a +`views` dict (a value, or a callable taking the call's args), and the +bound function itself doubles as the "tx" handed to build_and_send_tx, so +tests can assert which calls were sent, with which args, in which order. +""" +from types import SimpleNamespace + +from dincli.cli.context import DinContext + + +class FakeFn: + def __init__(self, contract, name, args): + self.contract = contract + self.name = name + self.args = args + + def call(self, *_, **__): + value = self.contract.views[self.name] + return value(*self.args) if callable(value) else value + + def __eq__(self, other): + return (self.name, self.args) == other + + def __repr__(self): + return f"{self.name}{self.args}" + + +class _Functions: + def __init__(self, contract): + self._contract = contract + + def __getattr__(self, name): + return lambda *args: FakeFn(self._contract, name, args) + + +class FakeContract: + def __init__(self, address, **views): + self.address = address + self.views = views + self.functions = _Functions(self) + + +class DummyConsole: + def __init__(self): + self.messages = [] + + def print(self, *args, **kwargs): + self.messages.append(" ".join(str(a) for a in args)) + + def text(self): + return "\n".join(self.messages) + + +class FakeCtxObj: + """Duck-types the DinContext methods the commands use. The GI + validators are the real DinContext ones, bound to this object.""" + + validate_gi_ET_curr_GI = DinContext.validate_gi_ET_curr_GI + validate_gi_LTE_curr_GI = DinContext.validate_gi_LTE_curr_GI + + def __init__(self, *, account="0x000000000000000000000000000000000000A11c", block_number=100, + coordinator=None, auditor=None, token=None, stake=None, registry=None, + emission=None, din_coordinator=None, model_base_dir=None): + self.console = DummyConsole() + self.account = SimpleNamespace(address=account) + self.w3 = SimpleNamespace(eth=SimpleNamespace(block_number=block_number)) + self.coordinator = coordinator + self.auditor = auditor + self.token = token + self.stake = stake + self.registry = registry + self.emission = emission + self.din_coordinator = din_coordinator + self.model_base_dir = model_base_dir + + def get_en_w3_account_console(self, model_id=None): + return "local", self.w3, self.account, self.console + + def get_deployed_din_task_coordinator_contract(self, *args, **kwargs): + return self.coordinator + + def get_deployed_din_task_auditor_contract(self, *args, **kwargs): + return self.auditor + + def get_deployed_din_token_contract(self, *args, **kwargs): + return self.token + + def get_deployed_din_stake_contract(self, *args, **kwargs): + return self.stake + + def get_deployed_din_registry_contract(self, *args, **kwargs): + return self.registry + + def get_deployed_din_emission_contract(self, *args, **kwargs): + return self.emission + + def get_current_gi_and_state(self, task_coordinator_contract, *args, **kwargs): + return ( + task_coordinator_contract.functions.GI().call(), + task_coordinator_contract.functions.GIstate().call(), + ) + + def get_model_base_dir(self, model_id): + return self.model_base_dir + + +def record_txs(monkeypatch, *modules): + """Patch build_and_send_tx (and time.sleep) in each module; returns the + ordered list of sent FakeFn calls.""" + sent = [] + + def fake_build_and_send_tx(ctx, fn, *args, **kwargs): + sent.append(fn) + return SimpleNamespace(status=1) + + for module in modules: + monkeypatch.setattr(module, "build_and_send_tx", fake_build_and_send_tx) + if hasattr(module, "time"): + monkeypatch.setattr(module.time, "sleep", lambda s: None) + return sent diff --git a/tests/test_auditor_encryption_key.py b/tests/test_auditor_encryption_key.py new file mode 100644 index 0000000..9b8b797 --- /dev/null +++ b/tests/test_auditor_encryption_key.py @@ -0,0 +1,72 @@ +from types import SimpleNamespace + +import pytest +from nacl.public import PrivateKey + +from dincli.cli import auditor +from tests._cli_fakes import FakeContract, FakeCtxObj, record_txs + +AUDITOR_A = "0x000000000000000000000000000000000000A11c" +AUDITOR_B = "0x000000000000000000000000000000000000b0B0" + + +@pytest.fixture +def config_dir(monkeypatch, tmp_path): + monkeypatch.setattr(auditor, "CONFIG_DIR", str(tmp_path)) + return tmp_path + + +@pytest.fixture +def sent(monkeypatch): + return record_txs(monkeypatch, auditor) + + +def make_ctx(account, registered_key=b""): + stake = FakeContract("0x5A", getEncryptionKey=registered_key) + return SimpleNamespace(obj=FakeCtxObj(account=account, stake=stake)) + + +def test_register_generates_an_owner_only_key_per_wallet(config_dir, sent): + auditor.register_encryption_key(make_ctx(AUDITOR_A)) + + key_path = auditor._auditor_x25519_key_path(AUDITOR_A) + assert key_path.parent == config_dir + assert key_path.stat().st_mode & 0o777 == 0o600 + pubkey = bytes(auditor._load_auditor_x25519_key(AUDITOR_A).public_key) + assert sent == [("registerEncryptionKey", (pubkey,))] + + +def test_two_wallets_on_one_machine_get_different_keys(config_dir, sent): + auditor.register_encryption_key(make_ctx(AUDITOR_A)) + auditor.register_encryption_key(make_ctx(AUDITOR_B)) + + key_a = bytes(auditor._load_auditor_x25519_key(AUDITOR_A).public_key) + key_b = bytes(auditor._load_auditor_x25519_key(AUDITOR_B).public_key) + assert key_a != key_b + assert sent == [("registerEncryptionKey", (key_a,)), ("registerEncryptionKey", (key_b,))] + + +def test_register_reuses_the_existing_key(config_dir, sent): + auditor.register_encryption_key(make_ctx(AUDITOR_A)) + first = auditor._auditor_x25519_key_path(AUDITOR_A).read_text() + + auditor.register_encryption_key(make_ctx(AUDITOR_A)) + + assert auditor._auditor_x25519_key_path(AUDITOR_A).read_text() == first + assert sent[0] == sent[1] + + +def test_register_skips_the_tx_when_the_key_is_already_on_chain(config_dir, sent): + key = PrivateKey.generate() + auditor._auditor_x25519_key_path(AUDITOR_A).write_text(key.encode().hex()) + + ctx = make_ctx(AUDITOR_A, registered_key=bytes(key.public_key)) + auditor.register_encryption_key(ctx) + + assert sent == [] + assert "already registered" in ctx.obj.console.text() + + +def test_load_names_the_command_when_the_key_is_missing(config_dir): + with pytest.raises(FileNotFoundError, match="register-encryption-key"): + auditor._load_auditor_x25519_key(AUDITOR_A) diff --git a/tests/test_create_testdataset_submit.py b/tests/test_create_testdataset_submit.py index ae0fb05..abaff0e 100644 --- a/tests/test_create_testdataset_submit.py +++ b/tests/test_create_testdataset_submit.py @@ -506,3 +506,47 @@ def fake_build_and_send_tx(ctx, contract_function, action_msg, success_msg, erro result = runner.invoke(main_app, _cli_args(1, _make_test_data_file(tmp_path))) assert result.exit_code == 1, result.output + + +# --------------------------------------------------------------------------- +# task_061026_21 Part C: fail fast on a keyless batch auditor, and keep each +# batch's K so the owner can answer a test-data dispute later. +# --------------------------------------------------------------------------- + +class _KeylessStakeFunctions: + def getEncryptionKey(self, address): + return DummyCall(b"") + + +def test_missing_auditor_encryption_key_exits_before_any_tx(monkeypatch, tmp_path): + ctx, coordinator, auditor = make_ctx(tmp_path, batch_count=2) + ctx.obj.stake_contract.functions = _KeylessStakeFunctions() + + sent = [] + monkeypatch.setattr(atb, "build_and_send_tx", lambda ctx, fn, *a, **kw: sent.append(fn)) + + with pytest.raises(typer.Exit): + _invoke(tmp_path, ctx) + + assert sent == [] + out = "\n".join(ctx.obj.console.messages) + assert _AUDITOR_ADDRESS in out + assert "register-encryption-key" in out + + +def test_submit_saves_each_batch_secret_owner_only(monkeypatch, tmp_path): + ctx, coordinator, auditor = make_ctx(tmp_path, batch_count=2) + monkeypatch.setattr( + atb, "build_and_send_tx", + lambda *a, **kw: SimpleNamespace(transactionHash=b"\xab" * 32), + ) + + _invoke(tmp_path, ctx) + + for batch_id in (0, 1): + path = atb._testdata_secret_path(tmp_path, 0, batch_id) + assert path.stat().st_mode & 0o777 == 0o600 + K, plaintext_keccak = atb._load_testdata_secret(tmp_path, 0, batch_id) + assert len(K) == 32 + assert plaintext_keccak == b"\x00" * 32 + assert (tmp_path / "config" / "owner_x25519.key").stat().st_mode & 0o777 == 0o600 diff --git a/tests/test_dinrep_approve_model_id.py b/tests/test_dinrep_approve_model_id.py new file mode 100644 index 0000000..9149646 --- /dev/null +++ b/tests/test_dinrep_approve_model_id.py @@ -0,0 +1,60 @@ +from types import SimpleNamespace + +import pytest +import typer + +from dincli.cli import dinrep +from tests._cli_fakes import FakeContract, FakeCtxObj, record_txs + +COORDINATOR = "0x00000000000000000000000000000000000000C0" +AUDITOR = "0x00000000000000000000000000000000000000A0" +# modelRequests getter: (requester, isOpenSource, manifestCID, taskCoordinator, +# taskAuditor, feePaid, processed, approved, createdAt) +REQUEST = ("0xOwner", True, b"\x00" * 32, COORDINATOR, AUDITOR, 0, False, False, 0) + + +@pytest.fixture +def sent(monkeypatch): + return record_txs(monkeypatch, dinrep) + + +def make_ctx(monkeypatch, *, total_models, coordinator_model_id, auditor_model_id): + task_contracts = { + COORDINATOR: FakeContract(COORDINATOR, modelId=coordinator_model_id), + AUDITOR: FakeContract(AUDITOR, modelId=auditor_model_id), + } + monkeypatch.setattr(dinrep, "get_contract_instance", lambda abi, network, address: task_contracts[address]) + registry = FakeContract("0xR0", modelRequests=REQUEST, totalModels=total_models) + return SimpleNamespace(obj=FakeCtxObj(registry=registry)) + + +def test_approve_sends_when_both_model_ids_match(monkeypatch, sent): + ctx = make_ctx(monkeypatch, total_models=4, coordinator_model_id=4, auditor_model_id=4) + + dinrep.approve_registration_request(ctx, request_id=7, force=False) + + assert sent == [("approveModel", (7,))] + + +@pytest.mark.parametrize("coordinator_model_id, auditor_model_id, named", [ + (3, 4, "DINTaskCoordinator"), + (4, 0, "DINTaskAuditor"), +]) +def test_approve_refuses_a_model_id_mismatch(monkeypatch, sent, coordinator_model_id, auditor_model_id, named): + ctx = make_ctx(monkeypatch, total_models=4, coordinator_model_id=coordinator_model_id, + auditor_model_id=auditor_model_id) + + with pytest.raises(typer.Exit): + dinrep.approve_registration_request(ctx, request_id=7, force=False) + + assert sent == [] + assert named in ctx.obj.console.text() + assert "--force" in ctx.obj.console.text() + + +def test_force_approves_despite_a_mismatch(monkeypatch, sent): + ctx = make_ctx(monkeypatch, total_models=4, coordinator_model_id=3, auditor_model_id=3) + + dinrep.approve_registration_request(ctx, request_id=7, force=True) + + assert sent == [("approveModel", (7,))] diff --git a/tests/test_dintoken.py b/tests/test_dintoken.py index c7f6659..b632e86 100644 --- a/tests/test_dintoken.py +++ b/tests/test_dintoken.py @@ -109,7 +109,7 @@ def get_en_w3_account_console(self): self.console.print(f"[bold green]✓ Active Wallet:[/bold green] {self.account.address}") return "local", self.w3, self.account, self.console - def get_deployed_din_token_contract(self): + def get_deployed_din_token_contract(self, verbose=True): return self.token def get_deployed_din_stake_contract(self): diff --git a/tests/test_dispute_commands.py b/tests/test_dispute_commands.py new file mode 100644 index 0000000..74e38a8 --- /dev/null +++ b/tests/test_dispute_commands.py @@ -0,0 +1,186 @@ +from types import SimpleNamespace + +import pytest +import typer + +from dincli.cli import aggregator, auditor, dintoken +from dincli.cli.modelownerd import auditor_batches, disputes +from tests._cli_fakes import FakeContract, FakeCtxObj, record_txs + +ZERO = "0x0000000000000000000000000000000000000000" +ZERO32 = b"\x00" * 32 +CHALLENGER = "0x000000000000000000000000000000000000C4a1" +BOND = 100 * 10**18 + + +@pytest.fixture +def sent(monkeypatch): + return record_txs(monkeypatch, auditor, aggregator, disputes, dintoken) + + +# ── Test-data disputes ────────────────────────────────────────────────────── + +def make_testdata_ctx(tmp_path, *, is_batch_auditor=True, settled=False, commitment=b"\x11" * 32, + dispute=(ZERO, 0, 0, False, False), block_number=100): + task_auditor = FakeContract( + "0xA0", + isBatchAuditor=is_batch_auditor, + giRewardSnapshot=(0, 0, 0, 0, settled), + testDataCommitments=commitment, + testDataDisputes=dispute, + disputeBondAmount=BOND, + ) + coordinator = FakeContract("0xC0", GI=2, GIstate=0) + return SimpleNamespace(obj=FakeCtxObj(coordinator=coordinator, auditor=task_auditor, + token=FakeContract("0xD1"), model_base_dir=tmp_path, + block_number=block_number)) + + +def test_dispute_test_data_approves_the_bond_then_opens(tmp_path, sent): + auditor.dispute_test_data(make_testdata_ctx(tmp_path), model_id=0, gi=None, batch_id=1) + + assert sent == [("approve", ("0xA0", BOND)), ("openTestDataDispute", (2, 1))] + + +@pytest.mark.parametrize("kwargs", [ + {"is_batch_auditor": False}, + {"settled": True}, + {"commitment": ZERO32}, + {"dispute": (CHALLENGER, BOND, 200, True, False)}, + {"dispute": (CHALLENGER, BOND, 200, False, True)}, +]) +def test_dispute_test_data_fails_fast_without_approving(tmp_path, sent, kwargs): + with pytest.raises(typer.Exit): + auditor.dispute_test_data(make_testdata_ctx(tmp_path, **kwargs), model_id=0, gi=None, batch_id=1) + + assert sent == [] + + +def test_resolve_test_data_reveals_the_saved_k(tmp_path, sent): + K, plaintext = b"\x22" * 32, b"\x33" * 32 + auditor_batches._save_testdata_secret(tmp_path, 2, 1, K, plaintext) + ctx = make_testdata_ctx(tmp_path, dispute=(CHALLENGER, BOND, 200, True, False)) + + disputes.resolve_test_data(ctx, model_id=0, gi=2, batch_id=1) + + assert sent == [("resolveTestDataDispute", (2, 1, K, plaintext))] + + +def test_resolve_test_data_without_a_saved_k_sends_nothing(tmp_path, sent): + ctx = make_testdata_ctx(tmp_path, dispute=(CHALLENGER, BOND, 200, True, False)) + + with pytest.raises(typer.Exit): + disputes.resolve_test_data(ctx, model_id=0, gi=2, batch_id=1) + + assert sent == [] + + +def test_resolve_test_data_after_the_window_points_to_close_expired(tmp_path, sent): + auditor_batches._save_testdata_secret(tmp_path, 2, 1, b"\x22" * 32, b"\x33" * 32) + ctx = make_testdata_ctx(tmp_path, dispute=(CHALLENGER, BOND, 50, True, False), block_number=100) + + with pytest.raises(typer.Exit): + disputes.resolve_test_data(ctx, model_id=0, gi=2, batch_id=1) + + assert sent == [] + assert "close-expired" in ctx.obj.console.text() + + +def test_close_expired_waits_for_the_window(tmp_path, sent): + open_ctx = make_testdata_ctx(tmp_path, dispute=(CHALLENGER, BOND, 200, True, False), block_number=100) + with pytest.raises(typer.Exit): + disputes.close_expired(open_ctx, model_id=0, gi=2, batch_id=1) + assert sent == [] + + expired_ctx = make_testdata_ctx(tmp_path, dispute=(CHALLENGER, BOND, 50, True, False), block_number=100) + disputes.close_expired(expired_ctx, model_id=0, gi=2, batch_id=1) + assert sent == [("closeExpiredDispute", (2, 1))] + + +# ── Aggregation disputes ──────────────────────────────────────────────────── + +def _dispute(challenger=CHALLENGER, deadline=0, seed_block=90, resolved=False, upheld=False, + finalized=False, seed=ZERO32): + return (challenger, BOND, 0, deadline, seed_block, resolved, upheld, finalized, seed) + + +def make_agg_ctx(dispute, din_token="0xD1", block_number=100): + coordinator = FakeContract("0xC0", GI=2, GIstate=0, disputes=dispute, dinToken=din_token, disputeBond=BOND) + return SimpleNamespace(obj=FakeCtxObj(coordinator=coordinator, token=FakeContract("0xD1"), + block_number=block_number)) + + +def test_aggregator_dispute_approves_the_bond_then_opens(sent): + aggregator.dispute(make_agg_ctx(_dispute(challenger=ZERO)), model_id=0, gi=None, tier="t2", batch_id=0) + + assert sent == [("approve", ("0xC0", BOND)), ("openDispute", (2, 1, 0))] + + +def test_aggregator_dispute_refuses_a_disputed_batch(sent): + with pytest.raises(typer.Exit): + aggregator.dispute(make_agg_ctx(_dispute()), model_id=0, gi=None, tier="t1", batch_id=0) + + assert sent == [] + + +def test_aggregator_dispute_refuses_without_a_din_token(sent): + with pytest.raises(typer.Exit): + aggregator.dispute(make_agg_ctx(_dispute(challenger=ZERO), din_token=ZERO), model_id=0, gi=None, tier="t1", batch_id=0) + + assert sent == [] + + +def test_aggregator_dispute_rejects_an_unknown_tier(): + with pytest.raises(typer.BadParameter): + aggregator.dispute(make_agg_ctx(_dispute(challenger=ZERO)), model_id=0, gi=None, tier="t3", batch_id=0) + + +def test_resolve_aggregation_upheld_locks_the_seed_first(sent): + ctx = make_agg_ctx(_dispute()) + # The seed reads as locked once lockDisputeSeed has been sent. + ctx.obj.coordinator.views["disputes"] = lambda *a: _dispute( + seed=b"\x01" * 32 if any(fn.name == "lockDisputeSeed" for fn in sent) else ZERO32 + ) + + disputes.resolve_aggregation(ctx, model_id=0, gi=2, tier="t1", batch_id=0, upheld=True) + + assert sent == [("lockDisputeSeed", (2, 0, 0)), ("resolveDispute", (2, 0, 0, True))] + + +def test_resolve_aggregation_rejected_needs_no_seed(sent): + disputes.resolve_aggregation(make_agg_ctx(_dispute()), model_id=0, gi=2, tier="t1", batch_id=0, upheld=False) + + assert sent == [("resolveDispute", (2, 0, 0, False))] + + +def test_settle_recomputation_requires_an_upheld_unfinalized_dispute(sent): + with pytest.raises(typer.Exit): + disputes.settle_recomputation(make_agg_ctx(_dispute(resolved=True)), model_id=0, gi=2, tier="t1", batch_id=0, confirmed=True) + assert sent == [] + + disputes.settle_recomputation(make_agg_ctx(_dispute(resolved=True, upheld=True)), model_id=0, gi=2, tier="t1", batch_id=0, confirmed=True) + assert sent == [("settleRecomputation", (2, 0, 0, True))] + + +def test_expire_waits_for_the_resolution_deadline(sent): + ctx = make_agg_ctx(_dispute(resolved=True, upheld=True, deadline=1_000)) + ctx.obj.w3.eth.get_block = lambda _: {"timestamp": 1_000} + with pytest.raises(typer.Exit): + disputes.expire(ctx, model_id=0, gi=2, tier="t1", batch_id=0) + assert sent == [] + + ctx.obj.w3.eth.get_block = lambda _: {"timestamp": 1_001} + disputes.expire(ctx, model_id=0, gi=2, tier="t1", batch_id=0) + assert sent == [("expireDispute", (2, 0, 0))] + + +def test_claim_dispute_bond(sent): + ctx = make_agg_ctx(_dispute()) + ctx.obj.coordinator.views["disputeBondClaimable"] = 0 + with pytest.raises(typer.Exit): + aggregator.claim_dispute_bond(ctx, model_id=0) + assert sent == [] + + ctx.obj.coordinator.views["disputeBondClaimable"] = BOND + aggregator.claim_dispute_bond(ctx, model_id=0) + assert sent == [("claimDisputeBond", ())] diff --git a/tests/test_gi_release_slots_and_funding.py b/tests/test_gi_release_slots_and_funding.py new file mode 100644 index 0000000..8995c8a --- /dev/null +++ b/tests/test_gi_release_slots_and_funding.py @@ -0,0 +1,63 @@ +from types import SimpleNamespace + +import pytest +import typer + +from dincli.cli.modelownerd import gi +from dincli.cli.utils import GIstatestrToIndex +from tests._cli_fakes import FakeContract, FakeCtxObj, record_txs + + +@pytest.fixture +def sent(monkeypatch): + return record_txs(monkeypatch, gi) + + +def make_ctx(curr_gi, state, released=False, pool=0): + coordinator = FakeContract("0xC0", GI=curr_gi, GIstate=GIstatestrToIndex(state), + registrationSlotsReleased=released) + task_auditor = FakeContract("0xA0", giRewardPool=pool) + return SimpleNamespace(obj=FakeCtxObj(coordinator=coordinator, auditor=task_auditor)) + + +def test_release_slots_defaults_to_the_current_ended_gi(sent): + gi.release_slots(make_ctx(3, "GIended"), model_id=0, gi=None) + + assert sent == [("releaseGIRegistrationSlots", (3,))] + + +def test_release_slots_accepts_an_earlier_gi(sent): + gi.release_slots(make_ctx(3, "GIstarted"), model_id=0, gi=2) + + assert sent == [("releaseGIRegistrationSlots", (2,))] + + +@pytest.mark.parametrize("curr_gi, state, ref_gi", [ + (3, "GIstarted", None), # current GI still running + (3, "GIended", 4), # future GI + (0, "GenesisModelCreated", None), +]) +def test_release_slots_refuses_a_gi_that_has_not_ended(sent, curr_gi, state, ref_gi): + with pytest.raises(typer.Exit): + gi.release_slots(make_ctx(curr_gi, state), model_id=0, gi=ref_gi) + + assert sent == [] + + +def test_release_slots_skips_an_already_released_gi(sent): + ctx = make_ctx(3, "GIended", released=True) + + gi.release_slots(ctx, model_id=0, gi=None) + + assert sent == [] + assert "already released" in ctx.obj.console.text() + + +def test_gi_start_refuses_an_unfunded_gi_and_names_the_command(sent): + ctx = make_ctx(1, "GIended", pool=0) + + with pytest.raises(typer.Exit): + gi.start(ctx, model_id=0, gi=None, threshold=None) + + assert sent == [] + assert "rewards deposit 0 --gi 2" in ctx.obj.console.text() diff --git a/tests/test_rewards_commands.py b/tests/test_rewards_commands.py new file mode 100644 index 0000000..e3f5a2a --- /dev/null +++ b/tests/test_rewards_commands.py @@ -0,0 +1,177 @@ +from types import SimpleNamespace + +import pytest +import typer + +from dincli.cli import dintoken, rewards +from dincli.cli.modelownerd import rewards as owner_rewards +from tests._cli_fakes import FakeContract, FakeCtxObj, record_txs + +ZERO = "0x0000000000000000000000000000000000000000" +DIN = 10**18 + + +@pytest.fixture +def sent(monkeypatch): + return record_txs(monkeypatch, rewards, owner_rewards, dintoken) + + +def make_ctx(*, gi=3, pool=0, din_token="0xD1", balance=100 * DIN, settled=True, + claimed=False, claimable=0, registered=True, emission_funded=False, emission_amount=5 * DIN): + coordinator = FakeContract("0xC0", GI=gi, GIstate=0) + auditor = FakeContract( + "0xA0", + dinToken=din_token, + giRewardPool=pool, + giRewardSnapshot=(1, 2, 3, 4, settled), + rewardClaimed=claimed, + claimable=claimable, + ) + token = FakeContract("0xD1", balanceOf=balance) + registry = FakeContract("0xR0", getModelIdByTaskAuditor=(registered, 7)) + emission = FakeContract("0xE0", giEmissionFunded=emission_funded, emissionForCurrentEpoch=emission_amount) + return SimpleNamespace(obj=FakeCtxObj(coordinator=coordinator, auditor=auditor, token=token, + registry=registry, emission=emission)) + + +# ── model-owner rewards deposit ───────────────────────────────────────────── + +def test_deposit_approves_then_deposits_into_next_gi_by_default(sent): + ctx = make_ctx(gi=3) + + owner_rewards.deposit(ctx, model_id=0, amount=1.5, gi=None) + + assert sent == [("approve", ("0xA0", 15 * DIN // 10)), ("depositRewards", (4, 15 * DIN // 10))] + + +def test_deposit_accepts_the_current_gi(sent): + ctx = make_ctx(gi=3) + + owner_rewards.deposit(ctx, model_id=0, amount=2, gi=3) + + assert sent[-1] == ("depositRewards", (3, 2 * DIN)) + + +def test_deposit_rejects_a_past_gi_before_any_tx(sent): + ctx = make_ctx(gi=3) + + with pytest.raises(typer.Exit): + owner_rewards.deposit(ctx, model_id=0, amount=1, gi=2) + + assert sent == [] + + +def test_deposit_refuses_when_task_auditor_has_no_din_token(sent): + ctx = make_ctx(din_token=ZERO) + + with pytest.raises(typer.Exit): + owner_rewards.deposit(ctx, model_id=0, amount=1, gi=None) + + assert sent == [] + assert "setDinToken" in ctx.obj.console.text() + + +def test_deposit_refuses_more_than_the_balance(sent): + ctx = make_ctx(balance=DIN) + + with pytest.raises(typer.Exit): + owner_rewards.deposit(ctx, model_id=0, amount=2, gi=None) + + assert sent == [] + + +# ── model-owner rewards fund-emission ─────────────────────────────────────── + +def test_fund_emission_calls_fund_gi_with_the_task_auditor(sent): + ctx = make_ctx(gi=0) + + owner_rewards.fund_emission(ctx, model_id=0, gi=None) + + assert sent == [("fundGI", (1, "0xA0"))] + + +@pytest.mark.parametrize("kwargs, message", [ + ({"registered": False}, "UnregisteredTaskAuditor"), + ({"emission_funded": True}, "GIAlreadyFunded"), + ({"emission_amount": 0}, "EmissionExhausted"), +]) +def test_fund_emission_fails_fast(sent, kwargs, message): + ctx = make_ctx(**kwargs) + + with pytest.raises(typer.Exit): + owner_rewards.fund_emission(ctx, model_id=0, gi=None) + + assert sent == [] + assert message in ctx.obj.console.text() + + +# ── rewards claim / withdraw ──────────────────────────────────────────────── + +def test_claim_sends_claim_reward(sent): + ctx = make_ctx(claimable=3 * DIN) + + rewards.claim(ctx, model_id=0, gi=2) + + assert sent == [("claimReward", (2,))] + + +def test_claim_refuses_an_unsettled_gi(sent): + ctx = make_ctx(settled=False) + + with pytest.raises(typer.Exit): + rewards.claim(ctx, model_id=0, gi=2) + + assert sent == [] + + +def test_claim_skips_an_already_claimed_gi(sent): + ctx = make_ctx(claimed=True) + + rewards.claim(ctx, model_id=0, gi=2) + + assert sent == [] + assert "already claimed" in ctx.obj.console.text() + + +def test_withdraw_sends_claim_rewards(sent): + ctx = make_ctx(claimable=3 * DIN) + + rewards.withdraw(ctx, model_id=0) + + assert sent == [("claimRewards", ())] + + +def test_withdraw_refuses_a_zero_balance(sent): + ctx = make_ctx(claimable=0) + + with pytest.raises(typer.Exit): + rewards.withdraw(ctx, model_id=0) + + assert sent == [] + + +@pytest.mark.parametrize("path", [ + ["rewards", "claim"], + ["rewards", "withdraw"], + ["model-owner", "rewards", "deposit"], + ["model-owner", "rewards", "fund-emission"], + ["model-owner", "gi", "release-slots"], + ["model-owner", "disputes", "resolve-test-data"], + ["model-owner", "disputes", "reassign-test-data"], + ["model-owner", "disputes", "close-expired"], + ["model-owner", "disputes", "resolve-aggregation"], + ["model-owner", "disputes", "settle-recomputation"], + ["model-owner", "disputes", "expire"], + ["auditor", "register-encryption-key"], + ["auditor", "dispute-test-data"], + ["aggregator", "dispute"], + ["aggregator", "claim-dispute-bond"], +]) +def test_command_is_registered(path): + from typer.testing import CliRunner + + from dincli.main import app as main_app + + result = CliRunner().invoke(main_app, [*path, "--help"]) + + assert result.exit_code == 0, result.output