Skip to content

V10 contracts: Codex fixes on top of rebased PR #202#231

Merged
46 commits merged intomainfrom
pr202-codex-fixes
Apr 24, 2026
Merged

V10 contracts: Codex fixes on top of rebased PR #202#231
46 commits merged intomainfrom
pr202-codex-fixes

Conversation

@branarakic
Copy link
Copy Markdown
Contributor

Summary

Successor to #202 — takes branarakic/v10-contracts-redesign (Zvonimir's V10 staking overhaul), rebases it onto current v10-rc, and applies three targeted fixes for the four Codex 🔴 comments left on StakingV10.sol during the April-16 review.

Original PR #202 is 187 commits behind v10-rc and has a failed CI run on the retired matrix; merging as-is isn't viable. The rebase picked up two minor conflicts (dead-test assertions Zvonimir already intended to remove + an evm-adapter.ts shim he simplified) and two ABI-json auto-regens, all resolved cleanly.

The three fixes

1. Walker uses settled + unsettled score per epoch (commit 2a55ffba)

Codex flag: "walker uses live Position for historical epochs — delegatorScore18 = effStake * scorePerStake36 / 1e18 uses the CURRENT post-mutation effStake for all walk epochs, so a mid-epoch stake/relock/redelegate/withdrawal mutation mis-prices the pre-mutation portion of that epoch."

_prepareForStakeChangeV10 already persists the pre-mutation delegator contribution into RandomSamplingStorage.epochNodeDelegatorScore[e,id,key] and bumps lastSettledNodeEpochScorePerStake[e,id,key] to the mid-epoch index when the position is mutated. The walker now reads both and composes:

delegatorScore = settled + unsettled
settled   = epochNodeDelegatorScore[e, id, key]              // pre-mutation contribution
unsettled = effStake * (scorePerStake36 - lastSettledIndex)  // post-mutation contribution at current effStake

_requireFullyClaimed (StakingV10.sol:283) ensures only the first walk-window epoch (claimFromEpoch = lastClaimedEpoch + 1) can have seen a mutation — all later epochs are pure, so settled == 0, lastSettledIndex == 0, and the formula collapses to the original walker effStake * scorePerStake36 / 1e18.

V10's bytes32(tokenId) delegator key is disjoint from V8's keccak256(address), so these reads don't interfere with any V8 claim path.

2. claim() runs sharding-table + Ask recalc post-compound (commit a7adc54e)

Codex flag: "claim() restakes rewardTotal into nodeStake/totalStake but skips the sharding-table + Ask recalc that every other stake-changing entry point performs."

Mirrors stake() (StakingV10.sol:436-442):

  • shardingTable.insertNode(id) if not yet in the table and new stake ≥ minimumStake.
  • ask.recalculateActiveSet() unconditionally.

Reward is always an increase, so no eviction path is needed — only the cross-above-minimum insert.

3. convertToNFT preserves V8 current-epoch score metadata (commit b7b391ed)

Codex flag: "prepareForStakeChange(currentEpoch, ...) just above can settle a non-zero current-epoch V8 score, but this unconditional removeDelegator drops the only metadata that lets V8 claim that score later."

Capture the prepareForStakeChange return (current-epoch delegator score) and mirror V8's _handleDelegatorRemovalOnZeroStake pattern (Staking.sol:884-898):

  • If non-zero: setLastStakeHeldEpoch(id, staker, currentEpoch) — V8's claimDelegatorRewards post-claim cleanup will remove the delegator once the stranded epoch is claimed.
  • If zero: original removeDelegator path is safe.

V8 TRAC is already drained (setDelegatorStakeBase(v8Key, 0) + decreaseNodeStake / decreaseTotalStake), so the retained DelegatorsInfo entry is purely a claim-lookup key.

Codex bug 4 — not a code bug

Codex flagged claim() as growing nodeStake/totalStake past the vault balance since claim() doesn't move TRAC into StakingStorage. After tracing the token flow:

  • V8 Staking.claim (Staking.sol:630-632) has the identical pattern — just increaseNodeStake / increaseTotalStake / increaseDelegatorStakeBase, no transferFrom.
  • TRAC enters StakingStorage via publisher deposits (KnowledgeAssetsV10.sol:628 does token.transferFrom(msg.sender, address(stakingStorage), tokenAmount), orchestrated by Paymaster.sol).
  • The author's existing NatSpec on claim() (StakingV10.sol:1094-1103) correctly documents the invariant.

This is worth a Codex reply on the PR rather than a code change.

Testing

306 existing tests still pass across:

  • test/unit/Staking.test.ts
  • test/unit/DelegatorsInfo.test.ts
  • test/unit/DKGStakingConvictionNFT.test.ts
  • test/unit/ConvictionStakingStorage.test.ts
  • test/v10-conviction.test.ts
  • test/v10-e2e-conviction.test.ts
  • test/v10-reward-flywheel.test.ts (3-way mixed V10+V8 same-node flywheel)

No regressions from the three fixes.

Test plan

  • @zsculac review each of the three fixes against his design intent (especially the walker split — the settled/unsettled composition is a non-trivial addition to Phase 11 semantics).
  • Add targeted regression tests:
    • Bug 1: Position mutated mid-epoch (redelegate/createWithdrawal) → claim next epoch pays out both pre- and post-mutation score contributions at their respective effStakes.
    • Bug 2: Node at sub-minimumStake → claim compound crosses threshold → shardingTableStorage.nodeExists returns true + active set reflects new weight.
    • Bug 3: convertToNFT called mid-epoch with non-zero V8 score → after epoch close, Staking.claimDelegatorRewards(id, epoch, staker) still finds the delegator and pays out the stranded score.
  • Full CI pass on the new Bura/Kosava/Tornado matrix (PR V10 contracts Part 2 #202 never ran on it).
  • Security review on the NFT-backed staking model + V8→V10 migration path (was flagged as deferred in PR V10 contracts Part 2 #202's original description).

Relationship to #202

Once this merges to v10-rc, #202 can be closed. Attribution of the bulk of the work (43 commits / 9k+ LOC / Phase 5/11 design) remains with zsculac via the rebased commits; the three new fix commits are authored by me.

Made with Cursor

Zvonimir and others added 30 commits April 21, 2026 17:46
Rewrite the Phase 4 placeholder into a Phase 5 scaffold base:
imports, Hub-wired dependency fields, constants (SCALE18,
WITHDRAWAL_DELAY = 15 days), storage (nextTokenId), events
(PositionCreated, PositionRelocked, PositionRedelegated,
WithdrawalRequested, WithdrawalProcessed, RewardsAccrued,
ConvertedFromV8), error types (InvalidLockEpochs, LockNotExpired,
LockStillActive, WithdrawalNotRequested, WithdrawalAlreadyRequested,
WithdrawalDelayPending, V8StakeNotFullyClaimed, NotPositionOwner,
PositionNotFound, ZeroAmount, ProfileDoesNotExist, SameIdentity,
MaxStakeExceeded), constructor, initialize wiring the full V10
dependency set (Staking, StakingStorage, ConvictionStakingStorage,
DelegatorsInfo, Chronos, RandomSamplingStorage, ShardingTableStorage,
ShardingTable, Ask, ParametersStorage, ProfileStorage, Token), and
ERC721Enumerable overrides (supportsInterface, _increaseBalance,
_update pure pass-through for the accrued-interest transfer model
per Phase 5 Q8).

No entry-point logic yet — S2 (createConviction) through S7
(convertToNFT) layer on top. Tier table helper and revert-stubs
arrive in the next two commits of S1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add `_convictionMultiplier(uint256 lockEpochs) internal pure` strict
tier helper. Exact-match {0, 2, 3, 6, 12} only; any other input
reverts `InvalidLockEpochs()`. `0 → 1e18` so reward-math callers
can re-invoke after post-expiry rest-state transitions.

NOTE: The S1 brief's Q5 table listed `lockEpochs == 1 → 1.5e18`,
which would clash with the authoritative storage-side tier ladder
in `ConvictionStakingStorage.expectedMultiplier18` (where
`expectedMultiplier18(1) == 1e18` and 1.5x lives at `lockEpochs == 2`).
Aligned here with the storage-side truth to avoid "Tier mismatch"
reverts on every `createConviction(_, _, 1)` call; the helper's
valid set is therefore `{0, 2, 3, 6, 12}`. In-file NOTE comment
captures the deviation for S2 and the main agent to review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add scaffolded external entry points for S2..S7 to fill in:
`createConviction`, `relock`, `redelegate`, `requestWithdrawal`,
`processWithdrawal`, `claimRewards`, `convertToNFT`. Each is marked
`external pure` and reverts `"NotImplemented"` — the `pure` modifier
is a scaffold-only hack to suppress solc's "can be restricted to
pure" warning and will be dropped by each downstream subagent when
they add real state reads/writes. The `@dev` line on every stub
calls this out so S2..S7 remember to remove it.

Ownership mapping (mirrors the Phase 5 plan):
  S2 → createConviction (Flow A atomic)
  S3 → relock
  S4 → redelegate
  S5 → requestWithdrawal / processWithdrawal
  S6 → claimRewards (stubbed TRAC transfer, Phase 11 finishes it)
  S7 → convertToNFT (V8 → V10 migration)

Compile clean (only the pre-existing KnowledgeAssetsStorage warning
remains) and the 054 deploy script runs green with no changes —
`DKGStakingConvictionNFT` initializes against the full V10 dep set
even though the existing deploy script only lists `Hub` + `Token`
(the other deps are pulled in transitively via earlier scripts).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ds bucket + mutators)

Phase 5 extends Phase 2 storage to support the split-bucket Position model
and tightens the conviction tier ladder to the Phase 5 discrete set.

Tier table (Item 1): `expectedMultiplier18` now returns only for the
discrete lock set {0,1,3,6,12}. `lock == 1 → 1.5x` replaces the old
`lock == 2 → 1.5x` mapping. Every other lock reverts with
"Invalid lock" instead of snap-down. `updateOnRelock`'s `>= 2` guard
relaxes to `>= 1` so the lock-1 bootstrap tier is a valid re-commit
target. NOTE: `Staking.convictionMultiplier` and
`DKGStakingConvictionNFT._convictionMultiplier` still carry the legacy
snap-down tables; downstream subagents must align all three in a single
symmetric change before mainnet.

Position struct (Item 2): adds `uint96 rewards` for the compounded
rewards bucket. Slot 1 (`raw + lockEpochs + expiryEpoch + identityId`)
is unchanged — `rewards` lives in slot 2 alongside `multiplier18` and
`lastClaimedEpoch` (96 + 64 + 64 = 224 bits), so there is zero
storage-layout churn for the existing fields.

New mutators (Item 3): `increaseRewards`, `decreaseRewards`,
`decreaseRaw`. All three use `identityId != 0` as the existence
sentinel so a `raw == 0, rewards > 0` position after a full principal
drain remains valid for rewards withdrawal. Each mirrors the Phase 2
diff pattern: write `diff[currentEpoch]`, mark dirty, finalize
`[last+1, currentEpoch-1]`. `decreaseRaw` also shrinks the pending
expiry delta by `amount*(mult-1)/SCALE18` (since the smaller raw has a
correspondingly smaller boost drop pending at expiry), mirroring
`deletePosition`'s cancel path.

Effective-stake math (Item 4): `updateOnRedelegate` and `deletePosition`
now include `+ rewards` in the per-node / global contribution they
move or remove. Rewards always contribute at 1x — no multiplier, no
expiry drop — so no expiry delta is installed for the rewards bucket.

Tests (Item 5): every test that used an out-of-set lock (2/4/5/10/11)
remaps to {1,3,6,12}. New `Phase 5 rewards bucket` describe block
covers default-0 initialization, all three mutators, mixed-bucket
math across boosted and post-expiry windows, and per-node aggregation
with rewards layered on multiple NFTs.

Version bumped to 1.1.0.

Verification (all green):
  - `npx hardhat compile` — 2 contracts, 0 errors
  - `ConvictionStakingStorage` suite — 57 passing (30 pre-existing + 27 new/updated)
  - `DelegatorsInfo` regression — 35 passing
  - `Staking` full suite (incl. `_recordStake`) — 85 passing
  - `npx hardhat deploy --network hardhat` — clean

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tion

The S1 scaffold's `_convictionMultiplier` had been aligned to the pre-P2
storage bug and used the tier set {0, 2, 3, 6, 12}, where `lockEpochs == 2`
mapped to 1.5x. That has been corrected to the roadmap-authoritative set
{0, 1, 3, 6, 12} per `04_TOKEN_ECONOMICS §4.1` — `lockEpochs == 1` is now
the 1.5x tier. This matches `ConvictionStakingStorage.expectedMultiplier18`
after the Phase 5 P2 hotfix (commit `e7528d38`); the stale NOTE comment
justifying the old set and the top-of-file NatSpec tier list have both
been updated. Pure tier-table fix — no other behavior changed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…+ deploy script)

Introduces `StakingV10.sol`, the V10 NFT-backed staking orchestrator. Owns
all V10 conviction logic (stake / relock / redelegate / withdraw / claim /
convertToNFT) while leaving the legacy V8 `Staking.sol` untouched. All 8
external entry points are stubbed as `revert("NotImplemented")` behind the
`onlyConvictionNFT` gate — downstream implementation subagents will wire
up the bodies one entry at a time against this clean revert surface.

Key design choices:
- Split-contract architecture: `StakingV10` is Hub-registered and gated so
  only the Hub-registered `DKGStakingConvictionNFT` can call its external
  entries. Pattern mirrors Phase 4's `Staking._recordStake` gate exactly.
- Every entry takes `address staker` as its first parameter — the NFT
  wrapper passes `msg.sender` explicitly so StakingV10 never trusts
  `tx.origin`. Ownership must be verified against `nft.ownerOf(tokenId)`
  inside each implementation.
- 12 Hub dependencies wired in `initialize()`, including a `Staking`
  reference so V10 settlement paths can call the existing
  `Staking.prepareForStakeChange(...)` helper (single score-per-stake
  math source for both V8 and V10 paths).
- Stubs use `external view` as a scaffold-only mutability modifier to
  keep compile clean — downstream subagents drop `view` when wiring
  state writes. Pattern mirrors `DKGStakingConvictionNFT`'s `pure`-stub
  convention from Phase 5 S1.
- New V10 errors declared locally (`InvalidLockEpochs`, `LockStillActive`,
  etc.) — NOT added to `StakingLib.sol`. Reuses `StakingLib.OnlyConvictionNFT`
  from Phase 4 for the gate.

Deploy script `055_deploy_staking_v10.ts` mirrors the existing
`helpers.deploy` pattern used by all Hub-registered contracts (same as
`023_deploy_staking.ts`). Tagged `StakingV10` + `v10`, with 13
dependencies matching the 12 Hub reads in `initialize()` + Hub itself.

Also fixes the `DKGStakingConvictionNFT` test fixture's
`ContractDoesNotExist("Staking")` failure by expanding
`054_deploy_dkg_staking_conviction_nft.ts`'s dependency list from
`['Hub', 'Token']` to the full 14 contracts its `initialize()` resolves
via `hub.getContractAddress(...)`, including the new `StakingV10`. The
old two-element list skipped deploy of `Staking`, so any test loading
this fixture reverted at NFT initialize time.

Verification:
- `npx hardhat compile` — clean (no new warnings, sole pre-existing
  warning is an unrelated `KnowledgeAssetsStorage` one).
- `npx hardhat deploy --network hardhat` — green; StakingV10 and
  DKGStakingConvictionNFT both deploy and initialize successfully.
- `npx hardhat test --grep 'ConvictionStakingStorage'` — 57 passing
  (Phase 2 regression holds).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ints forward to StakingV10

Rewrites the 8 entry points in DKGStakingConvictionNFT to thin wrappers
that mint/burn ERC-721 tokens and forward business logic to StakingV10
(passing msg.sender explicitly as the `staker` arg). The NFT contract is
now a dumb ownership receipt: no direct StakingStorage/ConvictionStorage
mutations, no TRAC handling. Only reads for the redelegate mirror event
and the burn-on-drain check in finalizeWithdrawal.

Renames:
  requestWithdrawal → createWithdrawal(tokenId, amount)
  processWithdrawal → finalizeWithdrawal(tokenId)
  claimRewards      → claim(tokenId)

Adds:
  cancelWithdrawal(tokenId) — new entry point missing from the S1 stub set.

Event renames + cleanup:
  WithdrawalRequested → WithdrawalCreated(tokenId, amount)
  WithdrawalProcessed → WithdrawalFinalized(tokenId)
  + WithdrawalCancelled(tokenId)
  - RewardsAccrued (moved to StakingV10.RewardsClaimed)
  PositionCreated + ConvertedFromV8 shape tweaks to match wrapper-layer
  mirror semantics (authoritative events live at StakingV10 / storage).

Error surface trimmed to the three errors actually thrown at the wrapper
layer: InvalidLockEpochs, NotPositionOwner, ZeroAmount. All other
position-lifecycle errors are now StakingV10's responsibility.

Runtime behaviour: StakingV10 stubs still revert "NotImplemented", so
every wrapper forwards and reverts until implementation subagents fill
the StakingV10 bodies. Expected and correct for this step.

Gates: compile green, hardhat deploy green (DKGStakingConvictionNFT +
StakingV10 deploy clean), Phase 2 ConvictionStakingStorage regression
still 57 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rewrites the stale DKGStakingConvictionNFT test suite to target the
Phase 5 split-contract architecture. The previous file referenced
`NFT.stake()` / `NFT.getPosition()` / `NFT.getMultiplier()` — all removed
in the Phase 5 NFT rewrite (commit 33d009d), so every test was a
compile-level reference to nonexistent methods.

New suite exercises the full end-to-end path
`createConviction` → `StakingV10.stake` → `{StakingStorage,
ConvictionStakingStorage}` via the real Hub-wired NFT wrapper (no
impersonation), covering:

  - revert: amount == 0 (NFT wrapper fail-fast)
  - revert: lock tier 0 / 2 / 13 (rest-state + between-tier rejects)
  - revert: non-existent profile (StakingV10 ProfileDoesNotExist)
  - revert: nodeStake + amount > maxStake (StakingV10 MaxStakeExceeded)
  - revert: no ERC20 allowance for StakingV10
  - happy path: 12-month tier writes Staking/Conviction storage,
    staker→StakingStorage TRAC flow, NFT wrapper balance == 0
  - tier matrix: {1, 3, 6, 12} → {1.5x, 2x, 3.5x, 6x} multipliers
  - tokenId monotonic: 0, 1, 2 for multi-mint single user
  - two-user path: distinct tokenIds on same node
  - wrapper TRAC balance invariant
  - direct `StakingV10.stake` from non-NFT caller → OnlyConvictionNFT
  - sharding table: node crosses minStake → added

StakingV10.stake is still a `revert("NotImplemented")` stub so 12 of 17
tests fail — this commit is the red phase of the TDD cycle; the
implementation lands in the next commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…green)

Replaces the `revert("NotImplemented")` stub on `StakingV10.stake` with
the full Phase 5 NFT-backed stake path. Drops the `view` modifier.

Mirrors the Phase 4 `Staking._recordStake` layout step-for-step:

  1. `amount > 0` / `profileExists(identityId)` guards — same shape as
     V8 `_stake` + Phase 4 `_recordStake`.
  2. Resolve tier via `ConvictionStakingStorage.expectedMultiplier18`
     (the canonical Phase 5 {0,1,3,6,12} ladder). Reject lockEpochs==0
     at the policy layer since it's the post-expiry rest state, not a
     valid fresh-mint tier — the storage helper stays tolerant of 0
     for reward-math callers.
  3. `maxStake` cap on the destination node (parametersStorage.maximumStake).
  4. `Staking.prepareForStakeChange(currentEpoch, identityId,
     bytes32(tokenId))` to baseline the fresh NFT delegator key to the
     node's current score-per-stake index, preventing a later reward
     claim from collecting score the node earned before the NFT existed.
     Same cross-helper the V8 `_stake` and Phase 4 `_recordStake` paths
     use.
  5. `token.transferFrom(staker, stakingStorage, amount)` — TRAC flows
     user → StakingStorage in one hop; NFT wrapper never holds funds
     (Phase 5 Q4).
  6. StakingStorage writes: setDelegatorStakeBase / setNodeStake /
     increaseTotalStake, targeting `bytes32(tokenId)` (disjoint from V8
     `keccak256(address)` key space).
  7. `ConvictionStakingStorage.createPosition` — position record with
     raw, tier, and expiry (expiry computed internally as
     currentEpoch + lockEpochs).
  8. Sharding-table insert if node crossed minimumStake. Guarded by
     `!nodeExists && stake >= min` so we don't make an unnecessary
     external call for sub-minimum stakes.
  9. `ask.recalculateActiveSet()` so the new effective stake is
     reflected in the next sampling window.
  10. Emit `Staked(tokenId, staker, identityId, amount, lockEpochs)`.

No `TokenIdAlreadyRecorded` freshness guard: the calling path
`DKGStakingConvictionNFT.createConviction` does `_mint(msg.sender,
tokenId)` first, and ERC721 `_mint` reverts on any already-minted
tokenId — the guard would be pure defense-in-depth against a
hypothetical future caller. The scope for this subagent explicitly
forbids adding new errors to StakingLib, so keeping the body minimal
matches both the architectural intent and the scope.

Test results (packages/evm-module/test/unit/DKGStakingConvictionNFT.test.ts):
  17 passing / 17 total (all red-phase failures now green)

Regression (all passing):
  - test/unit/Staking.test.ts — 85 tests (incl. 11 `_recordStake`)
  - test/unit/ConvictionStakingStorage.test.ts — 68 tests
  - test/unit/DelegatorsInfo.test.ts — 92 tests

Deploy smoke: `npx hardhat deploy` — green, DKGStakingConvictionNFT
deployed at 0xAD29... on hardhat network.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 9 tests to `DKGStakingConvictionNFT.test.ts` under
`describe('relock (→ StakingV10.relock)')`: NFT ownership gate,
wrapper-layer tier fail-fast (2, 13), `LockStillActive` guard,
non-existent tokenId (owner check), three happy paths (tier 1 → 12,
tier 0 rest state, expiryEpoch shift), and the
`onlyConvictionNFT` gate for direct StakingV10 calls. Tests fail
today because `StakingV10.relock` still reverts `"NotImplemented"`.

Imports `time` from `@nomicfoundation/hardhat-network-helpers` and
captures `Chronos` in the outer `beforeEach` block. Adds a local
`advanceEpochs(n)` helper mirroring the pattern in
`ConvictionStakingStorage.test.ts`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ckup post-expiry

The original Phase 2 hotfix (commit `e7528d38`) rejected
`newLockEpochs == 0` with `require(newLockEpochs >= 1, "Lock too short")`,
on the theory that "relock must carry an actual boost". The roadmap
(`04_TOKEN_ECONOMICS §4.1`) lists tier 0 as a first-class user-facing
tier ("no lockup / 1 / 3 / 6 / 12 months"), and Phase 5 user stories
explicitly include a post-expiry holder choosing to remain at 1x
rather than re-committing. Drop the guard; the `expectedMultiplier18`
call is already the canonical discrete-set validator for {0,1,3,6,12},
and the tier-0 math is well-defined: `boost = raw*(1e18-1e18)/1e18 = 0`
so the `currentEpoch` and `newExpiry` diff writes are both zero-delta
no-ops. The only observable mutation is the `lockEpochs` /
`multiplier18` / `expiryEpoch` writeback that drives the position
back to the rest state — matching `createPosition`'s lock-0 branch
conceptually.

Scope: one surgical guard removal + the matching `ConvictionStakingStorage`
unit test (which previously asserted "Lock too short" for tier 0) is
flipped to a positive assertion that walks the new path end to end
(effective stake stays at the post-expiry baseline, Position struct
carries the rest-state tier).

Unblocks SV10-relock: `StakingV10.relock(_, _, 0)` now passes through
the storage mutator cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… (green)

Replace the `NotImplemented` stub with the full relock body:

1. `convictionStorage.getPosition(tokenId)` — no explicit "exists"
   guard; the NFT wrapper's `ownerOf(tokenId) != msg.sender` already
   rejects un-minted tokenIds, and a drained rewards-only position is
   caught by `updateOnRelock`'s own `raw > 0` precondition.
2. Strict post-expiry check (`currentEpoch > pos.expiryEpoch`) →
   `LockStillActive` on pre-expiry.
3. `staking.prepareForStakeChange(currentEpoch, identityId, bytes32(tokenId))`
   — settle the score-per-stake index before mutating any effective
   stake, mirroring Phase 4 `Staking._recordStake` conventions.
4. `convictionStorage.expectedMultiplier18(newLockEpochs)` — reverts
   `"Invalid lock"` on any tier outside the discrete set `{0,1,3,6,12}`.
5. `convictionStorage.updateOnRelock(tokenId, newLockEpochs, newMultiplier18)`
   — owns the effective-stake diff + position field rewrite.
6. `emit Relocked(tokenId, newLockEpochs, currentEpoch + newLockEpochs)`.

Tier 0 (permanent rest state, 1x) is a valid relock target per the
V10 roadmap — enabled by the sister commit that relaxes the Phase 2
`updateOnRelock` guard. Raw principal stays put (no transfer); rewards
bucket is untouched (continues earning 1x, withdrawable on its own
rhythm).

The `staker` parameter is kept in the signature for call-shape
symmetry with every other `onlyConvictionNFT` entry point, but is
unused in the body (ownership is enforced by the NFT wrapper layer).

All 9 relock unit tests green; Staking/ConvictionStakingStorage/
DKGStakingConvictionNFT regression suites all green; hardhat deploy
smoke clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds `describe('redelegate (→ StakingV10.redelegate)')` block covering the
per-node stake move semantics, revert paths, happy paths mid-lock and
post-expiry, multi-NFT independence, and the `onlyConvictionNFT` gate.
Tests are red against the `revert("NotImplemented")` scaffold stub.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…entation (green)

Implements the per-node stake move for V10 NFT-backed positions. Both
raw and rewards buckets move together (`totalAmount = raw + rewards`);
global `totalStake` is invariant. Flow mirrors the V8
`Staking.redelegate` layout adapted to the V10 `bytes32(tokenId)`
delegator key scheme: same-node short-circuit → destination profile
existence → destination `maxStake` cap → settle indices on BOTH nodes
via `Staking.prepareForStakeChange` → move delegator stake base →
per-node `decrease/increaseNodeStake` → `updateOnRedelegate` (owns the
conviction effective-stake diff + `pos.identityId` mutation) → sharding
table maintenance on both sides → `Ask.recalculateActiveSet()`.

All 8 redelegate unit tests green. ConvictionStakingStorage (57) and
Staking broad regression (196) green. Deploy smoke green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… (red)

Adds createWithdrawal / cancelWithdrawal / finalizeWithdrawal describe
blocks under the DKGStakingConvictionNFT suite. Covers ownership gates,
amount validation, pre/post-expiry withdrawable splits, duplicate-request
guards, 15-day delay enforcement, TRAC refund math, StakingStorage /
ConvictionStakingStorage mutation, NFT burn-on-drain, and sharding-table
removal on sub-minimumStake finalization.

Rewards injection uses the hub-owner privilege on
ConvictionStakingStorage + StakingStorage mutators (onlyContracts permits
hub owner) to simulate the SV10-claim path without depending on its
implementation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…s (green)

Implements createWithdrawal / cancelWithdrawal / finalizeWithdrawal on
StakingV10, replacing the Phase 5 scaffold stubs.

createWithdrawal
- Pre-expiry: amount ≤ pos.rewards (raw is locked).
- Post-expiry: amount ≤ pos.raw + pos.rewards.
- 15-day delay timer hardcoded via WITHDRAWAL_DELAY constant.
- One pending request per NFT (bytes32(tokenId) key in StakingStorage
  withdrawals mapping, disjoint from V8 keccak256(address) keys).
- No stake mutation here — the delegator base, node stake, and total
  stake all stay put during the delay window. Request-time decrement
  is the V8 pattern; V10 scope intentionally defers to finalize.

cancelWithdrawal
- Clears the pending request slot. Because createWithdrawal does not
  mutate stake, there is nothing to re-stake — cancel is a pure
  withdrawal-request delete.

finalizeWithdrawal
- Settles the delegator's score-per-stake index for currentEpoch via
  Staking.prepareForStakeChange (mirrors stake/relock/redelegate).
- Drain split: rewards first (decreaseRewards), then raw
  (decreaseRaw) — each storage mutator owns its own effective-stake
  diff (1x for rewards, multiplier-scaled for raw pre-expiry).
- StakingStorage: setDelegatorStakeBase(newBase) triggers the
  inactive-transition when newBase == 0; decreaseNodeStake and
  decreaseTotalStake by the full request amount.
- Transfer TRAC from the StakingStorage vault to the NFT owner.
- Sharding table removal if nodeStake drops below minimumStake.
- Ask.recalculateActiveSet to refresh the active-set composition.
- NO call to ConvictionStakingStorage.deletePosition on full drain:
  the split-bucket mutators already handle the effective-stake diff,
  and deletePosition requires pos.raw > 0 (unreachable after a raw
  drain). The NFT wrapper's burn-on-drain check reads
  getPosition(tokenId) and burns when raw == 0 && rewards == 0,
  which is functionally equivalent to deleted for the NFT layer.

Adds the PositionNotFound error (defense-in-depth guard for direct
callers past the wrapper's ownerOf check) to StakingV10's local error
surface. No StakingLib edits.

Delegator base composition decision: StakingStorage.delegatorStakeBase
tracks raw + rewards (the full composite per-delegator balance).
Precedent: Phase 4 StakingV10.redelegate writes
setDelegatorStakeBase(newId, key, pos.raw + pos.rewards). A future
SV10-claim must compound rewards into this base to stay consistent.

Tests wrap the 15-day time advance with a chunked pre-finalize helper
(finalizeEffectiveStakeUpTo / finalizeNodeEffectiveStakeUpTo in slices
of 50 epochs, hub-owner privileged via onlyContracts) to keep the
downstream decreaseRaw/decreaseRewards under the hardhat 15M tx gas cap.

Suite: 56 DKGStakingConvictionNFT tests passing (23 new + 33 prior).
ConvictionStakingStorage (57) and Staking (218) regressions all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds an 11-test `claim` describe block to DKGStakingConvictionNFT.test.ts
covering the Phase 5 auto-compound flow: no-op paths (fresh position,
zero score delta, multi-claim idempotence), owner/gate rejection, the
single-epoch and multi-epoch happy paths, pre-expiry multiplier
application (6x on a 12-epoch lock), post-expiry 1x downgrade, a
mixed pre/post-expiry split across three epochs, and a claim-then-
withdraw integration that verifies TRAC reaches the user wallet after
the 15-day delay.

Tests inject `nodeEpochScorePerStake36` at specific epochs via the
hub-owner-privileged `setNodeEpochScorePerStake` setter (onlyContracts
admits `hub.owner()`) and pre-fund the StakingStorage vault with
`Token.mint(...)` so the node-stake bookkeeping stays consistent with
the vault balance. The reward formula follows the V8
`_prepareForStakeChange` scale: `reward = effStake * scorePerStake36
/ 1e18`, matching the Phase 5 stub semantics (Phase 11 replaces the
formula with the actual Paymaster-sourced epoch-pool flow).

Red phase: 9/11 fail with `NotImplemented` (the two gate-revert tests
pass because the `onlyConvictionNFT` guard fires before the
`revert("NotImplemented")`).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nto rewards, stubbed TRAC source) (green)

Implements `StakingV10.claim`, the Phase 5 auto-compound flow that walks
`[pos.lastClaimedEpoch + 1 .. currentEpoch - 1]`, computes per-epoch
effective stake with the tier multiplier applied pre-expiry and flat
post-expiry (plus the `rewards` bucket always at 1x), and banks the
score-weighted sum into the `ConvictionStakingStorage` rewards bucket
via `increaseRewards`. No wallet transfer — the claim auto-compounds
into the NFT position and users must call `createWithdrawal` +
`finalizeWithdrawal` (15-day delay) to extract TRAC.

Reward formula mirrors V8's `_prepareForStakeChange` scale:
  reward_e = effStake_e * nodeEpochScorePerStake[e][identityId] / 1e18
where `nodeEpochScorePerStake` is read from `RandomSamplingStorage`
at its native 1e36 scale (populated per-epoch by
`RandomSampling.submitProof`). The per-epoch value is NOT a cumulative
index — each epoch holds the score-per-stake earned within that
single epoch, so no `[e] - [e-1]` subtraction is needed.

Bookkeeping after the walk: (i) `increaseRewards(tokenId, amount)`,
(ii) `setDelegatorStakeBase(id, bytes32(tokenId), raw + rewardsNew)` to
honor the Phase 5 decision that the delegator base tracks the
composite (withdrawal/redelegate precedent), (iii) `increaseNodeStake`
+ `increaseTotalStake` by `rewardTotal`, (iv) `setLastClaimedEpoch`
to the walked boundary, (v) emit `RewardsClaimed(tokenId, amount)`.

No-op contract: the function returns silently when the claim window
is empty (fresh position, same epoch) or when all walked epochs had
zero score (e.g., Paymaster-empty window) — the zero-score case
still advances `lastClaimedEpoch` so future calls don't redo the
walk, but emits nothing.

`prepareForStakeChange(currentEpoch, identityId, bytes32(tokenId))`
is called before the walk to settle the delegator's score-per-stake
index, mirroring the stake/relock/redelegate/finalizeWithdrawal
pattern — required because we are about to mutate the delegator
base from `raw + rewardsOld` to `raw + rewardsNew`.

**TRAC source is stubbed with a `TODO(Phase 11)` note.** Phase 5
claim does NOT pull TRAC from any external source; the
`StakingStorage` vault must be pre-funded by Phase 11's
Paymaster / EpochStorage integration. Unit tests simulate this via
`Token.mint(stakingStorage, rewardTotal)`. If the vault is
under-funded, claim bookkeeping still succeeds but a later
`finalizeWithdrawal` would surface the discrepancy as a
`transferStake` underflow.

Green phase: all 11 new `claim` tests pass; full
`DKGStakingConvictionNFT` suite (67 tests) green;
`ConvictionStakingStorage` (57 tests) + `Staking` (229 tests)
regression suites both green; hardhat deploy smoke test passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a dedicated `describe('convertToNFT (→ StakingV10.convertToNFT)')`
block to `DKGStakingConvictionNFT.test.ts` covering the V8 → V10 migration
path:

- precondition: V8 rolling rewards must be 0 before convert
- precondition: V8 lastClaimedEpoch >= currentEpoch - 1 before convert
- `NoV8StakeToConvert` when the caller has no V8 address-keyed position
- wrapper-level tier rejects (2, 4, 13)
- `onlyConvictionNFT` gate on the direct StakingV10.convertToNFT entry
- happy path tier 12: V8 bucket drained, V10 position created with 6x
  multiplier, NFT minted, totals invariant
- happy path tier 0: rest-state migration (1x, expiryEpoch == 0)
- cross-user isolation: Alice migrates, Bob's V8 stake is untouched

The tests use a `setupV8Stake` hub-owner shortcut that pokes
`StakingStorage` + `DelegatorsInfo` directly to land the V8 address-keyed
state (increaseDelegatorStakeBase + increaseNodeStake + increaseTotalStake
+ addDelegator + setHasEverDelegatedToNode + setLastClaimedEpoch). This
keeps the convertToNFT tests independent from V8 `Staking.stake` — which
already has full coverage in `Staking.test.ts` — and avoids the reward
epoch baselining noise that a real V8 path would introduce.

Expected red: four tier/gate tests already pass on the scaffold
(wrapper-layer rejects fire before StakingV10 is ever called), six
precondition/happy-path tests fail with "NotImplemented" or a missing
custom-error surface until the implementation lands in the next commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…th (green)

Implements `StakingV10.convertToNFT`, the one-way V8 address-keyed →
V10 NFT-backed migration entry:

  1. Precondition: V8 rolling rewards == 0 AND
     `lastClaimedEpoch >= currentEpoch - 1`. The Phase 5 scope
     intentionally keeps StakingV10 out of the V8 reward-distribution
     path — the user must run `Staking.claimDelegatorRewards` for
     every unclaimed epoch BEFORE invoking convertToNFT. Reverts
     `V8StakeNotFullyClaimed`.
  2. Settle V8 score-per-stake indices at the current epoch for the
     V8 `keccak256(abi.encodePacked(staker))` delegator key via
     `Staking.prepareForStakeChange`. Required BEFORE the V8 bucket
     drain so the score cursor is captured against the V8 key.
  3. Read V8 `delegatorStakeBase`. Revert `NoV8StakeToConvert` if 0
     — distinct from the precondition revert so the UI can
     distinguish "nothing to migrate" from "claim V8 rewards first".
  4. Zero the V8 key: `setDelegatorStakeBase(0)` (triggers
     `_updateDelegatorActivity` → removes from `delegatorNodes[v8Key]`
     and decrements `delegatorCount`) + `decreaseNodeStake` +
     `decreaseTotalStake` + `delegatorsInfo.removeDelegator`.
  5. NFT already minted by the wrapper (Phase 4 precedent) — no
     action needed here.
  6. Create the V10 position:
       - `Staking.prepareForStakeChange` on the fresh
         `bytes32(tokenId)` V10 delegator key.
       - `setDelegatorStakeBase(amount)` on the V10 key.
       - `increaseNodeStake` + `increaseTotalStake` — re-adds the
         amount so the net migration is zero.
       - `expectedMultiplier18(lockEpochs)` validates the tier
         (reverts "Invalid lock" outside {0,1,3,6,12}). Tier 0
         (permanent rest state) is a legitimate migration target.
       - `convictionStorage.createPosition` writes the V10 position
         with `lastClaimedEpoch = currentEpoch - 1` (Phase 2 default).
  7. `ask.recalculateActiveSet()` — node stake delta is zero (V8
     out, V10 in, same amount), so sharding table composition is
     unchanged. The ask recalc is kept for symmetry with the other
     entry points — a cheap no-op that keeps the call shape uniform
     across all eight V10 entries.

The `onlyConvictionNFT` gate pins the caller to
`DKGStakingConvictionNFT`; `_convictionMultiplier` at the wrapper layer
already rejects tier 2/4/13 before forwarding. Tier 0 (no-lockup rest
state) is a legitimate migration target here — unlike `stake()` which
rejects lock == 0 for fresh mints, `convertToNFT` supports the migration
tier set {0,1,3,6,12} directly.

Adds a new custom error `NoV8StakeToConvert` and drops `view` on the
entry stub. Mutability is now `nonpayable`.

All 10 new `convertToNFT` tests pass. No regressions in the existing
77-test DKGStakingConvictionNFT suite, the 57-test
ConvictionStakingStorage suite, the 239-test Staking suite, or the
35-test DelegatorsInfo suite. Deploy smoke test green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…sertion

Add a `transfer (accrued-interest model)` describe block to
`DKGStakingConvictionNFT.test.ts` that proves the pure-pass-through
`_update` override. 10 new tests (suite 77 → 87) covering:

  - Position byte-identity across an ERC-721 transfer (raw, rewards,
    lockEpochs, expiryEpoch, identityId, multiplier18, lastClaimedEpoch
    all unchanged).
  - Bob claims all 3 epochs of accrued rewards Alice held pre-transfer.
  - Alice's claim / relock / redelegate / createWithdrawal /
    cancelWithdrawal / finalizeWithdrawal all revert NotPositionOwner
    post-transfer (full owner-rights surface).
  - Second claim by Bob in the same epoch is a no-op (no RewardsClaimed).
  - Gas ceiling: transferFrom < 100K (actual: 62,393 on Hardhat).
  - Bob can redelegate after the transfer (owner rights fully migrated).
  - safeTransferFrom to an EOA preserves accrued-interest semantics
    (3-arg overload exercised; no receiver mock exists under
    contracts/ so the callback branch is out of scope).
  - NFT wrapper TRAC balance is always zero before and after transfer.
  - Alice can mint a second NFT post-transfer — tokenIds stay monotonic
    (Bob keeps #0, Alice gets #1).

No contract changes. `_update` is already the Phase 4 pass-through,
confirmed by NFT-rewrite commit 33d009d; this block is the end-to-end
proof the pass-through holds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ew API

Replace the previous file — which tested legacy V8 `Staking.convictionMultiplier`
pure math and `PublishingConvictionAccount` (publisher-side, both already
covered by `v10-e2e-conviction.test.ts` Flow 1+2) — with a focused Phase 5
integration smoke test for the NFT-backed staking stack.

The new file is a 5-test top-layer smoke covering:
  1. Deploy + Hub wiring (NFT ↔ StakingV10 gate)
  2. createConviction happy path (ERC-721 mint + SS + CSS state)
  3. claim after 1-epoch advance (reward banks into rewards bucket)
  4. Full withdrawal lifecycle (createWithdrawal → delay → finalize → burn)
  5. convertToNFT V8→V10 migration (totals invariant, NFT minted)

Exhaustive unit coverage remains in `test/unit/DKGStakingConvictionNFT.test.ts`
(87 tests) and `test/unit/ConvictionStakingStorage.test.ts` (57 tests); this
file is intentionally short and targets multi-contract wiring catches.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 5 review fix prep. Adds the V10-native pieces
StakingV10's decrement-at-request withdrawal model needs:

- `PendingWithdrawal` struct + `pendingWithdrawals[tokenId]` mapping —
  replaces the V8 `StakingStorage.withdrawals` slot for the V10 NFT path.
  V8 mapping is left untouched.
- `createPendingWithdrawal` / `deletePendingWithdrawal` /
  `getPendingWithdrawal` CRUD with `amount != 0` as the existence sentinel
  and a `rewardsPortion <= amount` split invariant.
- `increaseRaw` mutator — sign-flipped mirror of `decreaseRaw`. Used by
  `StakingV10.cancelWithdrawal` to roll back a pending raw share into the
  position when the user cancels.

Storage version bumped 1.1.0 → 1.2.0.

Tests cover `increaseRaw` round-trip identity vs. `decreaseRaw`,
pre/post-expiry diff math, and the pending-withdrawal CRUD sentinel rules.
Existing 57 tests still pass; new total 69.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…reateWithdrawal

`ConvictionStakingStorage.createPosition` writes `expiryEpoch = currentEpoch
+ lockEpochs`, and `StakingV10.claim` treats `e < expiryEpoch` as boosted
and `e >= expiryEpoch` as 1x — so `expiryEpoch` is the FIRST unboosted
epoch, not the last boosted one.

Two consumers had the strict `>` / `<=` form, which forced one extra epoch
of wait past the actual boundary:

- `relock`: `currentEpoch <= pos.expiryEpoch` → `currentEpoch < pos.expiryEpoch`
- `createWithdrawal` raw branch: `currentEpoch > pos.expiryEpoch` →
  `currentEpoch >= pos.expiryEpoch`

`pos.expiryEpoch == 0` (lock-0 rest state) still falls into the post-expiry
branch on epoch 1, so the rest-state path is unaffected.

All 87 DKGStakingConvictionNFT tests still pass; the existing tests use
`advanceEpochs(2)` after a 1-epoch lock so they were never on the
boundary epoch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…V10-native pending storage

Phase 5 review fix. Switches the V10 NFT-backed withdrawal flow from a
"deduct-on-finalize" model to "decrement-at-request":

- `createWithdrawal` immediately decrements `pos.raw` / `pos.rewards` and
  the `StakingStorage` delegator/node/total stake. The withdrawing TRAC
  stops earning rewards as soon as the user starts the timer. Pending
  withdrawals now live on the V10-native
  `ConvictionStakingStorage.pendingWithdrawals[tokenId]` slot — NOT the
  legacy `StakingStorage.withdrawals` map. The V8 mapping is untouched.
- `cancelWithdrawal` is the symmetric inverse: restores rewards/raw via
  the rewardsPortion split captured on the pending struct, restores the
  delegator base composite, restores node/total stake, and re-inserts
  the node into the sharding table if needed.
- `finalizeWithdrawal` now ONLY transfers TRAC + deletes the pending
  slot. No bucket decrement, no settle, no sharding-table work — all
  handled at create time.

Other knock-on changes:
- `redelegate` rejects when a pending exists. Under decrement-at-request
  the pending TRAC is anchored to `pos.identityId`, so moving the
  position would strand the cancel/finalize on the old node. Phase 5
  UX answer: cancel first, then redelegate. The "mint a new NFT on
  cancel" cross-node restore is deferred to a follow-up PR.
- `relock` needs no pending guard (under decrement-at-request the
  remaining `pos.raw` already excludes the pending amount).
- `WithdrawalFinalized` event reshaped from
  `(tokenId, rawDraw, rewardsDraw)` → `(tokenId, amount)`. The split
  is now stored on the pending struct's `rewardsPortion` field.

Test surface: createWithdrawal/cancelWithdrawal/finalizeWithdrawal
flipped from "withdrawals stored in StakingStorage.withdrawals" to
"pending stored in ConvictionStakingStorage.pendingWithdrawals", with
new assertions for the immediate decrement at create time and the
symmetric restore at cancel time. Added boundary tests for relock and
createWithdrawal at exactly `currentEpoch == pos.expiryEpoch`, and a
new redelegate guard test. Total: 87 → 92.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
`StakingV10.claim` accumulates `rewardTotal` in uint256 inside the per-
epoch walk loop, then narrows to uint96 four times for the
`increaseRewards` / `setDelegatorStakeBase` / `increaseNodeStake` /
`increaseTotalStake` storage calls. A pathological per-epoch score
injection or a long enough walk window could push the accumulator over
`type(uint96).max` (~7.92e28), at which point the narrowing would
silently truncate the reward.

Add an explicit `RewardOverflow` revert immediately after the walker
loop, before the first cast. New unit test exercises the path with a
score-per-stake injection sized to push the stub formula past 2^96.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the existing TODO(Phase 11) note in StakingV10.claim with a
WARNING block that makes the deployment dependency explicit:

- Phase 11 MUST land before ANY testnet/mainnet deployment — claim()
  pre-credits reward TRAC into the StakingStorage vault accounting
  (nodeStake / totalStake / delegatorStakeBase) ahead of the actual
  TRAC arriving. Without Phase 11's Paymaster wiring, finalizeWithdrawal
  would underflow the vault and break the totalStake invariant.
- The Phase 5 unit-test fixture pre-funds the vault via Token.mint as
  a stand-in for the Phase 11 flow.

Pure docs change. No behavioral shift.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ition mutator

Change existence guard from `raw > 0` to `identityId != 0` on both
`setLastClaimedEpoch` and `deletePosition` in ConvictionStakingStorage.
This fixes the split-bucket model where raw can be drained to 0 while
the position is still alive (rewards-only state).

Add two Phase 2 tests verifying the sentinel works on rewards-only
positions for both mutators.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…alizeWithdrawal + TRAC-zero assertion

Add `_requireFullyClaimed` internal guard to StakingV10.sol that
reverts `UnclaimedEpochs` when `lastClaimedEpoch < currentEpoch - 1`.
Called at the top of relock, redelegate, createWithdrawal, and
cancelWithdrawal — forces users to claim() before any structural
mutation so reward history is settled.

Add deletePosition call in finalizeWithdrawal: when both raw and
rewards are zero after finalize, clear the orphaned Position struct.

Add 4 UnclaimedEpochs revert tests (one per guarded entry point).
Add TRAC-zero assertion in claim happy path (total now 9).
Add deletePosition verification in full-drain finalize test.
Update all existing tests to call claim() before guarded operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove ConvictionStakingStorage.json and StakingV10.json ABI files
that were committed but should not be tracked (build artifacts).
Scope audit found no drift in Staking.sol, RandomSampling.sol, or
DelegatorsInfo.sol.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Zvonimir and others added 16 commits April 21, 2026 17:46
…st in claim

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…StakingStorage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ake, replace all V8 settlement calls

Add `_getEffectiveStakeAtEpoch` and `_prepareForStakeChangeV10` internal
helpers to StakingV10.sol. The new settlement function is a direct
adaptation of V8's `Staking._prepareForStakeChange` (lines 739-791) with
one key change: it reads effective stake via the position's conviction
multiplier instead of the raw `stakingStorage.getDelegatorStakeBase`.
This ensures `sum(delegatorScores) == nodeScore` once M3 switches the
denominator in `nodeEpochScorePerStake36` to effective node stake.

Replaced 8 `staking.prepareForStakeChange(...)` call sites across
stake/relock/redelegate/createWithdrawal/cancelWithdrawal/claim/convertToNFT
with the new V10-local settlement. The V8 key settlement in
`convertToNFT` (for the `keccak256(staker)` key) is intentionally kept
as-is since it must use V8's raw-base semantics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…elegatorConvictionMultiplier, fix NatSpec refs

Delete two dead functions from Staking.sol:
- convictionMultiplier(uint40): snap-down tier table with Phase 12c
  off-by-one bug (lockEpochs >= 2 returned 1.5x, but spec says
  lockEpochs == 1 should return 1.5x). Canonical multiplier now lives
  in DKGStakingConvictionNFT._convictionMultiplier (exact-match).
- getDelegatorConvictionMultiplier(uint72, address): always returned
  SCALE18 regardless of inputs — real per-delegator multiplier comes
  from ConvictionStakingStorage positions.

Update all NatSpec/comment references across contracts and tests to
point at DKGStakingConvictionNFT._convictionMultiplier instead of the
deleted Staking.convictionMultiplier.

Delete two dead test cases from v10-e2e-conviction.test.ts that tested
the deleted functions. Canonical tier tests live in
test/unit/DKGStakingConvictionNFT.test.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rePerStake denominator

The scorePerStake denominator in RandomSampling.submitProof now uses
effective node stake (V8_raw + V10_effective) instead of raw nodeStake.
This ensures V10 conviction multipliers are reflected in per-stake
reward distribution while keeping inter-node scoring (calculateNodeScore)
unchanged by design.

Formula: effectiveNodeStake = rawNodeStake + nodeEffV10 - nodeV10Base
When no V10 positions exist, nodeEffV10 == nodeV10Base == 0, so behavior
is identical to the previous implementation.

Also adds ConvictionStakingStorage as a deploy dependency for
RandomSampling to prevent initialize() revert in test fixtures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…r fee + EpochStorage

Replace the Phase 5 score-only reward stub in StakingV10.claim() with the
real two-step TRAC computation mirroring V8 Staking.sol:568-593:

1. delegatorScore18 = effStake * scorePerStake36 / 1e18
2. netNodeRewards = epochPool * nodeScore / allNodesScore - operatorFee
   (first claimer per (node, epoch) computes and caches via delegatorsInfo)
3. trac_reward = delegatorScore18 * netNodeRewards / nodeScore18

Changes:
- Add EpochStorage import, state var, EPOCH_POOL_INDEX constant, and
  initialize() wiring (Hub key "EpochStorageV8")
- Replace inner claim loop body with operator fee computation + caching
- Remove TODO(Phase 11) / WARNING comment block
- Update NatSpec to describe the real TRAC source and formula

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rd flywheel)

Add v10-reward-flywheel.test.ts proving the complete V10 reward cycle:
- V10 staker (1000 TRAC, 6x lock) + V8 staker (1000 TRAC, 1x) on same node
- Injected scores and epoch pool simulate publish + proof pipeline
- V10 claims via NFT.claim (auto-compound), V8 via claimDelegatorRewards
- Conservation: 6/7 + 1/7 rewards sum to netNodeRewards (630 wei dust)
- Vault invariant: Token.balanceOf(StakingStorage) >= totalStake
- Operator fee banked correctly (10% and 0% variants)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…d rewards + vault invariant

Update all claim tests to use the Phase 11 TRAC reward formula instead
of the Phase 5 score-denominated stub. Each epoch's reward is now:

  delegatorScore18 = effStake * scorePerStake36 / 1e18
  grossNodeRewards = epochPool * nodeScore18 / allNodesScore18
  reward = delegatorScore18 * netNodeRewards / nodeScore18

Test infrastructure changes:
- Add injectNodeEpochScore, injectAllNodesEpochScore, fundEpochPool,
  fundEpochPoolRange, injectEpochRewardState, injectMultiEpochRewardState
  helpers to both claim and transfer describe blocks
- Update computeReward to the full 7-param TRAC formula with operator fee
- Add assertVaultInvariant helper (vaultBalance >= totalStake)

New tests:
- 6x/1x proportionality: two positions on same node, verify 6/7 and 1/7
  split of netNodeRewards with at most 1 wei rounding
- Expiry boundary: 12-epoch lock, verify last pre-expiry epoch uses 6x
  and first post-expiry epoch uses 1x, with exact 6:1 ratio assertion

Also updates the v10-conviction.test.ts integration smoke test to inject
the full epoch reward state (nodeScore, allNodesScore, epochPool).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…x (V10 uses per-position queries)

The old implementation called staking.getDelegatorConvictionMultiplier()
which was deleted from StakingV10.sol (it was dead code that always
returned 1x). V8 address-keyed stakers have no conviction multiplier.
V10 per-position multipliers are queried by tokenId via
ConvictionStakingStorage.getPosition(), not this address-keyed function.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ery epoch in claim loop

The two early `continue` statements in the claim loop skipped the
operator-fee-flag write for zero-score epochs.  If a node had a
rewardless epoch and only V10 claimers walked through it, the flag
never got set, permanently blocking Profile.updateOperatorFee().

Restructure the loop to match V8's control flow: guard the TRAC
conversion in an `if (delegatorScore18 > 0 && nodeScore18 > 0)` block
and write the fee flag unconditionally after, for every epoch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a regression test exercising the effective-node-stake denominator
with one V8 staker (Carol 1000 TRAC, 1x) and two V10 positions
(Alice 1000 TRAC @ 12-epoch lock = 6x, Bob 1000 TRAC @ 3-epoch lock = 2x)
on the same node. Verifies reward shares match 6000:2000:1000 = 2/3:2/9:1/9
with conservation (total dust <= 10000 wei) and vault balance invariant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…or fee flag gating

Three review bugs fixed:

1. Claim loop gated operator fee computation on `delegatorScore18 > 0 &&
   nodeScore18 > 0`. A dust claimant with zero delegatorScore could mark
   the fee as claimed without caching netNodeRewards, zeroing out all
   subsequent claimants. Fix: gate fee computation on `nodeScore18 > 0`
   only (matching V8 Staking.sol:567-600).

2. The 2-arg `_prepareForStakeChangeV10(epoch, tokenId)` read identityId
   from the position, but in stake() and convertToNFT() the position
   doesn't exist yet — baselined under node 0 instead of the target node.
   Fix: unified into single 3-arg function with mandatory identityId.

3. The 3-arg overload used the old position's effective stake when
   baselining the new node in redelegate() — granting free score on the
   destination. Fix: function compares pos.identityId == identityId to
   determine settlement (real effective stake) vs baseline (zero).

Also syncs chain/abi with evm-module/abi for Staking.json,
DKGStakingConvictionNFT.json, and RandomSampling.json.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codex review of PR #202 flagged the V10 claim walker as mispricing
historical epochs: `delegatorScore18 = effStake * scorePerStake36 / 1e18`
uses the CURRENT post-mutation effective stake for all walk epochs, but
mid-epoch mutations (stake/relock/redelegate/createWithdrawal/
cancelWithdrawal) during an epoch call `_prepareForStakeChangeV10`,
which persists the pre-mutation contribution into
`getEpochNodeDelegatorScore[e,id,key]` and bumps
`getDelegatorLastSettledNodeEpochScorePerStake[e,id,key]` to the
mid-epoch index. For the boundary epoch the naive walker then re-prices
the settled portion at the wrong effStake.

The `_requireFullyClaimed` guard (StakingV10.sol:283) ensures the only
walk-window epoch that can have seen a mutation is the first
(`claimFromEpoch = lastClaimedEpoch + 1`); all later epochs are pure
(effStake constant post-mutation). Walker now composes:

  delegatorScore = settled + unsettled
  settled   = epochNodeDelegatorScore[e, id, key]   (pre-mutation)
  unsettled = effStake * (scorePerStake36 - lastSettledIndex36) / 1e18

For pure epochs, `settled == 0` and `lastSettledIndex36 == 0`, so the
formula collapses to the original `effStake * scorePerStake36 / 1e18`.
For boundary epochs only, the split prices the pre-mutation portion at
the pre-mutation effStake (via `settled`) and the post-mutation portion
at the current effStake (via `unsettled`).

V10 uses `bytes32(tokenId)` as the delegator key, disjoint from the V8
`keccak256(address)` key space, so reading these fields does not
interfere with any V8 claim path.

All 290 existing staking/conviction unit tests + 186 V10 integration
tests still pass.

Made-with: Cursor
…ound

Codex review of PR #202 flagged `StakingV10.claim()` as skipping the
sharding-table and Ask maintenance that every other V10 entry point
performs after a node-stake mutation. The claim's reward compound
increases `nodeStake` / `totalStake`, which changes the node's weight
in the active set and may cross `minimumStake` for a node that had
been evicted.

Mirror the `stake()` pattern (lines 436-442):
  * `ShardingTable.insertNode(id)` if the node is not yet in the
    sharding table and the new stake meets `minimumStake`.
  * `Ask.recalculateActiveSet()` unconditionally, so the updated node
    weight is reflected in the next sampling window.

Reward is always an increase, so no eviction / removeNode path is
needed — only the cross-above-minimum insert.

All 290 existing staking/conviction unit tests + 186 V10 integration
tests still pass.

Made-with: Cursor
…tadata

Codex review of PR #202 flagged `convertToNFT` as stranding current-epoch
V8 sampling score on migration: `staking.prepareForStakeChange(currentEpoch,
...)` at line 1405 can settle a non-zero current-epoch V8 score into
`DelegatorsInfo.epochNodeDelegatorScore[currentEpoch,id,v8Key]`, but
the unconditional `delegatorsInfo.removeDelegator(id, staker)` that
follows drops the V8 delegator registration that
`Staking.claimDelegatorRewards` needs to pay out that score after the
epoch closes.

Mirror the V8 `_handleDelegatorRemovalOnZeroStake` pattern
(`Staking.sol:884-898`):
  * Capture the `prepareForStakeChange` return (current-epoch
    delegator score) in `v8CurrentEpochScore18`.
  * If non-zero, call `setLastStakeHeldEpoch(id, staker, currentEpoch)`
    instead of `removeDelegator`. V8's `claimDelegatorRewards` post-
    claim cleanup (Staking.sol:608-618) will remove the delegator
    once all owed epochs are settled.
  * If zero, the original `removeDelegator` path is safe — no stranded
    score to claim.

V8 TRAC is already drained (`setDelegatorStakeBase(id, v8Key, 0)` +
`decreaseNodeStake` / `decreaseTotalStake`), so the retained
DelegatorsInfo entry is purely a claim-lookup key, not a stake marker.

All 290 existing staking/conviction unit tests + 186 V10 integration
tests still pass.

Made-with: Cursor

/// @notice Mint a fresh NFT-backed staking position on `identityId` with
/// `amount` TRAC locked for `lockEpochs` epochs.
function createConviction(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: This PR replaces the public stake(...) flow with createConviction(...) and moves the token pull into StakingV10, but packages/chain/src/evm-adapter.ts still approves/calls DKGStakingConvictionNFT.stake(...). After these ABI changes, stakeWithLock() will fail at runtime (missing method and wrong spender). Either keep a compatibility shim here or update the adapter/tests in the same PR to call createConviction and approve StakingV10.

} catch {
return { multiplier: 1.0 };
}
async getDelegatorConvictionMultiplier(_identityId: bigint, _delegator: string): Promise<{ multiplier: number }> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: Hard-coding multiplier: 1 silently breaks the existing ChainAdapter.getDelegatorConvictionMultiplier contract for locked V10 positions. Current callers/tests use this to observe post-lock conviction, so they will now get incorrect data instead of an explicit unsupported/deprecated signal. Either resolve the owner’s V10 position(s) and read the multiplier from ConvictionStakingStorage, or remove/deprecate this adapter method in the same PR.

branarakic added a commit that referenced this pull request Apr 24, 2026
Consolidates all V10 contract work (D26 time-accurate staking, code-review
fixes, Paranet cleanup Groups A/B, ParanetV9Registry -> ContextGraphNameRegistry
rename, orphan/migrator deletions, FairSwapJudge removal) with v10-rc's
latest agent/CLI/sync changes so the branch can fast-forward to v10-rc and
close out PRs #231, #240, and #270 in one pass.

Conflict resolutions:

- scripts/devnet.sh: took v10-rc's version. v10-rc's ef7016c retired the
  direct ParanetV9Registry.createParanetV9() bootstrap call entirely and
  switched to POST /api/context-graph/register on node 1 (the real V10
  ContextGraphs.createContextGraph path). Our rename of that block to
  ContextGraphNameRegistry.claimName was obsolete vs v10-rc's cleaner
  architecture, so we accept v10-rc's version wholesale. The rename of
  the underlying Solidity contract + ABI is unaffected (those files are
  not touched by v10-rc).

- packages/cli/src/api-client.ts: took v10-rc's version. v10-rc expanded
  ApiClient.query() with full memory-layer routing (view, graphSuffix,
  verifiedGraph, subGraphName, includeSharedMemory, agentAddress,
  assertionName, minTrust). v10-rc's opts surface is a strict superset
  of our branch's includeSharedMemory-only version.

Verification:

- No contract-level conflicts — v10-rc has zero contract changes that
  were missing from v10-pr97-spec-impl.
- D26 staking, ContextGraphNameRegistry, StakingV10, and all Paranet/
  orphan/FairSwapJudge deletions survive the merge.
- PR #270's contract deletions were already in this branch; its relay
  list cleanup was superseded on v10-rc — #270 is now fully obsolete.

Made-with: Cursor
@branarakic branarakic closed this pull request by merging all changes into main in d6f9aa4 Apr 24, 2026
@branarakic branarakic mentioned this pull request Apr 24, 2026
KilianTrunk pushed a commit to KilianTrunk/dkg-v9 that referenced this pull request Apr 30, 2026
…NFT, CSS, cross-contract

Implements the V10 migration spec (dkgv10-spec PR OriginTrail#97) on top of PR OriginTrail#231's
`Commit 1/7` CSS state additions. Takes the V10 stack from a partial CSS
rewrite to a fully-migrated, self-consistent architecture.

CSS is the canonical V10 stake store; `StakingStorage` becomes a frozen
V8 archive + TRAC vault; V8 `Staking` and `DelegatorsInfo` are on the
chopping block (unregistered at cutover). All stake reads that matter
to reward accounting go through `ConvictionStakingStorage`.

Commit 2 — StakingV10 + RandomSampling rewrites
  * Drop V8 Staking / DelegatorsInfo wiring (D3/D13/D17). V10-native
    `_prepareForStakeChangeV10` replaces the V8 cross-call.
  * `claim` walks the unclaimed window via D6 retroactive
    `migrationEpoch` and compounds rewards into `raw` through
    `cs.increaseRaw` + `cs.addCumulativeRewardsClaimed` (D19 — no
    separate rewards bucket).
  * All V10 stake writes go to CSS (`nodeStakeV10`, `totalStakeV10`,
    D15). `StakingStorage.nodes[id].stake` is not written by V10.
  * `RandomSampling.calculateNodeScore` reads `nodeStakeV10` from CSS
    (mandatory-migration model: no V8-only nodes post-cutover, so CSS
    is the canonical source). `submitProof` denominator likewise uses
    CSS `getNodeEffectiveStakeAtEpoch` and drops the now-redundant
    `nodeV10BaseStake` subtraction (D4).

Commit 4 — D21 ephemeral NFTs + D23 CSS primitive
  * New CSS primitive `createNewPositionFromExisting(oldTokenId,
    newTokenId, newIdentityId, newLockEpochs, newMultiplier18)`
    atomically replaces a live position at a fresh tokenId while
    preserving `cumulativeRewardsClaimed`, `lastClaimedEpoch`, and
    `migrationEpoch`. Emits `PositionReplaced`.
  * `StakingV10.relock(oldTokenId, newTokenId, newLockEpochs)` and
    `redelegate(oldTokenId, newTokenId, newIdentityId)` rewritten to
    use the D23 primitive.
  * `DKGStakingConvictionNFT.relock` / `redelegate` mint `newTokenId`
    before the forward, burn `oldTokenId` after. Both return
    `newTokenId` so callers can track the continuation.

Commit 5 — D7 + D8 + D11 migration primitives
  * Split `convertToNFT` into `selfConvertToNFT` and `adminConvertToNFT`
    (dual-path D7); shared `_convertToNFT` worker.
  * D8 — `_convertToNFT` absorbs BOTH V8 `stakeBase` and pending
    withdrawal amounts into the new V10 position's `raw`. V8 drain
    subtracts only `stakeBase` from node/total stake (pending was
    already off-stake at V8 request time).
  * NFT wrapper: new `selfMigrateV8`, `adminMigrateV8`,
    `adminMigrateV8Batch` (D11 batched rescue), and
    `finalizeMigrationBatch` (DAO closer — sets `v10LaunchEpoch`).
    Admin gate via new `onlyOwnerOrMultiSigOwner` modifier.
  * New `ConvertedFromV8` event shape:
    `(delegator, tokenId, identityId, stakeBaseAbsorbed,
    pendingAbsorbed, lockEpochs, isAdmin)`.

Commit 6 — D13 / D18 cross-contract redirects + deploy scripts
  * `Profile.sol` — drops `DelegatorsInfo`, reads
    `isOperatorFeeClaimedForEpoch` from CSS.
  * `StakingKPI.sol` — drops `DelegatorsInfo`; `isNodeDelegator` guard
    dropped (redundant under V8-archive semantics); fee-claim flag +
    net-node rewards read from CSS.
  * `DKGStakingConvictionNFT.sol` — unused `DelegatorsInfo` import/
    state removed.
  * Deploy scripts 021 / 054 / 055 — annotated. 055's dependency list
    trimmed to match V10 `initialize()`. 054 joined the `v10` tag.
  * Hub naming decision: `StakingV10` stays registered as
    `StakingV10`, NOT aliased to `Staking`. Rationale documented in
    055: V10 staking is gated by `onlyConvictionNFT`, so aliasing
    would make V8-era integrations silently call gated V10 and fail
    opaquely; keeping slots distinct makes the break loud.

Commit 7 — D14 + NatSpec
  * `WITHDRAWAL_DELAY = 0` on both `StakingV10` and the NFT wrapper.
    Conviction lock expiry IS the delay gate; a second address-timer
    on top is redundant.
  * Top-of-file NatSpec on `DKGStakingConvictionNFT` rewritten to
    match the final entry-point set and document D21/D23 burn-mint
    semantics.

Known follow-up:
  * Test suite is red — signatures changed on `relock`/`redelegate`,
    `convertToNFT` renamed, event shapes shifted. Triage is the next
    task before PR review.
  * Live-chain cutover (script 998 — `Hub.removeContractByAddress`
    for V8 `Staking` + `DelegatorsInfo`) is ops-coordinated and not
    scripted in this diff.

Refs: * Spec: OriginTrail/dkgv10-spec#97
  * Stacked on: OriginTrail#231
Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant