Skip to content

feat(evm): OT-RFC-50 V8→V10 admin-push stake migration#1192

Open
branarakic wants to merge 12 commits into
mainfrom
feat/v8-v10-pool-migration
Open

feat(evm): OT-RFC-50 V8→V10 admin-push stake migration#1192
branarakic wants to merge 12 commits into
mainfrom
feat/v8-v10-pool-migration

Conversation

@branarakic

Copy link
Copy Markdown
Contributor

Implements the OT-RFC-50 V8→V10 "pool & allocate" stake migration, admin-push model. Spec: OriginTrail/dkgv10-spec PR #131 (rev 4).

Model

The protocol/admin drains every delegator's V8 stake into their in-protocol migration credit; users only allocate that credit into V10 conviction positions. There is no self-service drain — startMigration is removed.

Contracts (packages/evm-module)

  • DKGStakingConvictionNFT: adminDrainBatch(address[] delegators, uint72[] identityIds) — primary bulk drain; flattened (delegator, node) pairs (even per-tx gas), skips zero-drain pairs (no revert → idempotent / re-run-safe). adminMigrateToCredit (single-delegator re-sweep). allocate(targetNode, amount, lockTier) (user spends credit). Both drains are onlyOwnerOrMultiSigOwner and require V8MigrationEligibility.frozen().
  • StakingV10: drainV8ToCredit worker (zeroes the V8 slot, moves TRAC SS→CSS, credits the delegator, tags the eligible portion) + allocateFromCredit (mints the conviction position). Tier 0 is exempt from the active-tier gate so the recover-to-wallet path is owner-independent.
  • ConvictionStakingStorage: Option-B credit ledger (migrationCredit / eligibleCredit, deploy-time convictionCreditSeconds).
  • V8MigrationEligibility: frozen registry gating the tier-6/12 conviction lock-credit.

Safety

  • Always recoverable: force-drained funds can be returned to the wallet in two txs — allocate(node, amount, 0)withdraw — and tier 0 cannot be gated off by the owner.
  • Collateralization: every drain moves TRAC SS→CSS exactly; allocate moves no TRAC; credit is never withdrawable as credit.
  • Idempotent drains make the chunked off-chain sweep re-run-safe.

Tests & review

  • 22 migration tests green (test/v10-pool-migration.test.ts): drain accounting + eligible tagging, adminDrainBatch (skip-zero / idempotent / length-mismatch / gating), allocate (credit gate, partial/clamp, reverts), collateralization, tier-0 recovery + recovery-after-tier-0-deactivation, setConvictionCreditSeconds.
  • Adversarial multi-agent review: 2 LOW findings fixed (tier-0 gate, stale exported ABI); a HIGH stakeRewardIndexed finding was refuted (dead/legacy field; the release-gate aggregate _totalStake is independent of it).

Deploy / cutover

  • Deploy onto each chain's V8-stake-holding Hub (Base freeze 0x99Aa… / Gnosis 0x882D…), not a fresh Hub.
  • The off-chain sweep driver (enumerate DelegatorsInfo.getDelegators → chunked adminDrainBatch → until getTotalStake()==0 per chain) is a separate ops deliverable.
  • Cutover gate: assert StakingStorage.getTotalStake() == 0 per chain before unregistering the V8 Staking contract.
  • Includes testnet rehearsal tooling under packages/evm-module/scripts/ (mirror-mainnet-delegator, migrate-as-wallet, fork-rehearsal, create-profile + RFC50-TESTNET-REHEARSAL.md).

🤖 Generated with Claude Code

Branimir Rakic and others added 3 commits June 15, 2026 15:36
Replace the V8→V10 mint-per-stake migration surface with the pool-and-allocate
model from OT-RFC-50.

- ConvictionStakingStorage: Option-B credit ledger — migrationCredit /
  eligibleCredit mappings + convictionCreditSeconds (capped < tier-6 duration);
  addMigrationCredit / spendMigrationCredit (non-eligible-first clamp) /
  setConvictionCreditSeconds, all onlyContracts. Credit lives in CSS (durable
  storage) not the wrapper (logic), so a mid-migration wrapper redeploy can't
  strand migrated TRAC (§0 Option B).
- StakingV10: factor _convertToNFT into gated workers drainV8ToCredit (per-node
  drain → CSS credit; returns total+eligible) and allocateFromCredit (full stake
  tail: _prepareForStakeChangeV10 → createPosition → sharding insert → ask
  recalc, with the credit gate + active-tier check) + setConvictionCreditSeconds
  relay. Remove selfConvertToNFT / adminConvertToNFT / _convertToNFT and the
  ConvertedFromV8 event/NoV8StakeToConvert error.
- DKGStakingConvictionNFT: startMigration / allocate / adminMigrateToCredit
  (drain-only straggler sweep) + migrationCredit/eligibleCredit/
  convictionCreditSeconds views + MigrationStarted/Allocated events;
  owner-gated, until-frozen setConvictionCreditSeconds; v8MigrationEligibility
  wired in initialize. Remove selfMigrateV8 / adminMigrateV8 / adminMigrateV8Batch
  / _adminMigrateV8Single.

New test/v10-pool-migration.test.ts — 14 passing: drain→credit + eligible
tagging, the eligibleCredit clamp, the credit gate, the collateralization
invariant (SS→CSS exact; allocate moves no TRAC), idempotency, freeze-gate,
admin sweep, and the capped/gated credit.

Pre-existing migration tests that referenced the removed surface are updated
in a follow-up commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Delete v10-converttonft-drain-fix.test.ts — a regression test for the removed
  _convertToNFT; its collateralization property (SS→CSS exact, no shared-pool
  drain) is now covered by v10-pool-migration.test.ts.
- v10-migration-conviction-credit.test.ts: drop the _convertToNFT/selfMigrateV8
  credit-application block (superseded by v10-pool-migration.test.ts); keep the
  V8MigrationEligibility registry-lifecycle tests (the registry is unchanged).

No test references the removed selfMigrateV8 / adminMigrateV8* / *ConvertToNFT
surface. Migration test surface green: 14 (pool-allocate) + 5 (registry).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tartMigration

Switch V8→V10 migration from user-initiated to admin-push: the protocol drains
every delegator's V8 stake into their migration credit; users only allocate.

- remove self-service startMigration
- add adminDrainBatch(address[] delegators, uint72[] identityIds): flattened
  (delegator,node) pairs for even per-tx gas, onlyOwnerOrMultiSigOwner, requires
  frozen(), skips zero-drain pairs (no revert → idempotent, re-run-safe)
- keep adminMigrateToCredit (single delegator); MigrationStarted.byAdmin always
  true (kept for ABI stability)
- StakingV10.allocateFromCredit: exempt tier 0 from the active-tier gate so the
  tier-0 recover-to-wallet safety net is owner-independent
- 22 migration tests (adminDrainBatch skip-zero/idempotent, tier-0 recovery, and
  recovery-after-tier-0-deactivation); regenerate the exported ABI
- add testnet rehearsal tooling (mirror/migrate-as-wallet/fork-rehearsal/
  create-profile + RFC50-TESTNET-REHEARSAL runbook)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
}
],
"name": "adminMigrateV8",
"name": "adminMigrateToCredit",

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 changes the public staking-migration ABI, but the snapshot ABIs under packages/chain/abi/ are still on the old selfMigrateV8 / adminMigrateV8 / ConvertedFromV8 surface. packages/chain/src/evm-adapter-abi.ts prefers those local snapshots, so downstream SDK callers and custom-error decoding will keep using stale interfaces until those copies are regenerated too.

// Collateralization invariant
// ===========================================================================
describe('collateralization', () => {
it('admin drain moves TRAC SS→CSS exactly; allocate moves no TRAC', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Issue: The new pool-migration suite no longer covers the D8 pending withdrawal branch that drainV8ToCredit() still migrates. The deleted regression used to prove stakeBase + pending was transferred SS→CSS; without a replacement test here, a regression would silently under-credit users or re-open the collateralization bug. Please add a case that seeds createDelegatorWithdrawalRequest(...) and asserts both credited amount and vault transfer include pending.

Comment thread packages/evm-module/contracts/DKGStakingConvictionNFT.sol Fixed
Comment thread packages/evm-module/contracts/DKGStakingConvictionNFT.sol Fixed
Comment thread packages/evm-module/contracts/DKGStakingConvictionNFT.sol Fixed
…ding test

- regenerate packages/chain/abi/{DKGStakingConvictionNFT,StakingV10,
  ConvictionStakingStorage}.json to the current migration surface. These local
  snapshots are preferred by packages/chain/src/evm-adapter-abi.ts over the
  evm-module export, so downstream SDK callers were resolving the stale
  selfMigrateV8/startMigration interface (codex review 🔴). Now adminDrainBatch
  present, startMigration/selfMigrateV8 gone.
- add a D8 regression: admin drain of a delegator with a pending V8 withdrawal
  request credits stakeBase + pending and moves both SS→CSS (guards the
  collateralization invariant the re-pointed suite had dropped — codex 🟡).

Slither reentrancy-events / calls-in-loop on adminMigrateToCredit + adminDrainBatch
are benign: both are onlyOwnerOrMultiSigOwner, call only trusted protocol
contracts (drainV8ToCredit is onlyConvictionNFT), move standard ERC20 TRAC (no
transfer callback), and the drain is idempotent (re-entry finds zeroed slots).
No nonReentrant elsewhere in these contracts; the loop is the documented,
re-run-safe batch design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@branarakic

Copy link
Copy Markdown
Contributor Author

Addressed the review findings in 46d57d7:

🔴 Stale chain ABI snapshots (codex) — fixed. Regenerated packages/chain/abi/{DKGStakingConvictionNFT,StakingV10,ConvictionStakingStorage}.json to the current migration surface. Since packages/chain/src/evm-adapter-abi.ts prefers these package-local snapshots over the evm-module export, downstream SDK callers were resolving the stale selfMigrateV8/startMigration interface; they now match (adminDrainBatch present, startMigration/selfMigrateV8 gone).

🟡 Missing D8 pending-withdrawal coverage (codex) — fixed. Added a regression in the collateralization block: seeds createDelegatorWithdrawalRequest(...), then asserts the admin drain credits stakeBase + pending and moves the full base + pending SS→CSS (and clears both V8 slots). Suite now 23 green.

Slither reentrancy-events / calls-in-loop (adminMigrateToCredit, adminDrainBatch) — reviewed, benign, no change:

  • Both entrypoints are onlyOwnerOrMultiSigOwner; the only external call is to StakingV10.drainV8ToCredit, which is onlyConvictionNFT (callable solely by this wrapper) and touches only trusted protocol storage.
  • The sole token movement is StakingStorage.transferStake of TRAC — a standard ERC20 with no transfer callback (no ERC777-style hook), so no re-entrancy vector.
  • The drain is idempotent (a re-entered/re-run pair reads a zeroed slot and contributes 0), so even hypothetical re-entry cannot double-credit. The flagged class is reentrancy-events (informational), and the event must follow the call because it reports the drained amount.
  • nonReentrant is not used on the analogous allocate/stake/withdraw paths in these contracts, so adding a guard here would be inconsistent. The loop in adminDrainBatch is the intended, documented, re-run-safe batch shape (the off-chain driver packs uniform chunks).

// StakingStorage.transferStake is onlyContracts and StakingV10 is
// Hub-registered, so the call is authorized.
// Move the migrated TRAC SS -> CSS so the credit stays collateralized.
ss.transferStake(address(convictionStorage), total);

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: draining V8 stake into credit never re-evaluates the source node's sharding-table membership or recomputes the Ask active set. Under OT-RFC-50 the source node can lose stake immediately while the user allocates later or to a different node, so a node that was above minimumStake only because of V8 stake can remain incorrectly active after the drain. After mutating SS/CSS here, remove the source node when its canonical post-drain stake is sub-threshold and trigger an active-set refresh (batched if you want to avoid per-pair recalcs).

await (await (SS as any).connect(admin).increaseNodeStake(p.id, p.base)).wait();
await (await (SS as any).connect(admin).increaseTotalStake(p.base)).wait();
}
// NOTE: pending V8 withdrawal requests are not replicated here (would need

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Issue: this helper claims to mirror a real delegator position, but it silently drops any pending V8 withdrawal requests. For delegators with pending > 0, the seeded state, admin-drain credit and collateralization rehearsal will all be wrong. Either seed the pending request + vault top-up here, or fail fast when any source position has a non-zero pending amount.

// ---- assertions ----
const checks: [string, boolean][] = [
['V8 stake fully drained', baseAfter === 0n],
['credit == drained base', credited === baseBefore],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Issue: this assertion assumes credited == stakeBase, but adminMigrateToCredit drains stakeBase + pendingWithdrawal. If DELEGATOR is overridden to one with a pending request, the rehearsal will report a failure even though the contract behaved correctly; the next totalBefore - totalAfter assertion has the same problem because totalStake excludes pending. Include pending in the baseline or assert up front that the chosen delegator has no pending withdrawal.

opFee,
);
const receipt = await tx.wait();
const identityId = Number(receipt.logs[0].topics[1]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Issue: receipt.logs[0].topics[1] assumes the first emitted log is always the profile-created event. That's brittle and can silently print the wrong identityId if createProfile starts emitting another event first. Parse the receipt by event signature/name (or read the created id from contract state) instead of indexing raw logs.

Tooling-only (rehearsal scripts; no contract change):
- create-profile.ts: parse the ProfileCreated event by name instead of
  receipt.logs[0].topics[1] (createProfile also emits identity/wallet events,
  so logs[0] is not reliably the profile-created event).
- fork-rehearsal.ts: read the delegator's pending withdrawal and assert
  credited == base + pending (and total drops by base only — pending is excluded
  from totalStake), so a DELEGATOR override with a pending request no longer
  reports a false failure.
- mirror-mainnet-delegator.ts: seed a real pending V8 withdrawal faithfully
  (fund the vault for base + pending + createDelegatorWithdrawalRequest), so the
  rehearsal of a pending-heavy delegator is correct rather than silently dropped.

Not changed (assessed, see PR comment):
- "drain leaves source node incorrectly active" (codex): false positive — the V10
  active set / sharding table rank by ConvictionStakingStorage.getNodeStakeV10
  (Ask.sol, ShardingTable.sol), not V8 StakingStorage.getNodeStake. The drain
  makes UNALLOCATED credit and never changes nodeStakeV10, so V10 membership is
  unaffected; the active-set update correctly happens in allocate.
- CI "Tornado: Solidity [1/4]/[4/4]": unrelated ContextGraphs typechain binding
  failure, present since the first commit; this branch changes no
  ContextGraphs/typechain/config files and the binding generates fine locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@branarakic

Copy link
Copy Markdown
Contributor Author

Round 2 — addressed in 48a301e:

🔴 "drain leaves the source node incorrectly active" — false positive, no change. The premise (a node stays active off un-migrated V8 stake) doesn't hold in this codebase: the V10 active set and sharding table rank nodes by ConvictionStakingStorage.getNodeStakeV10Ask.recalculateActiveSet (Ask.sol:35) and ShardingTable (ShardingTable.sol:238), the latter's own comment noting the V8 getNodeStake "is not a faithful read" post-migration. drainV8ToCredit produces unallocated migration credit and never touches nodeStakeV10, so a node's V10 membership is unaffected by the drain — un-migrated V8 stake never made a node active in V10 to begin with. nodeStakeV10 (and therefore the active set) changes only in allocate, which already does insertNode + recalculateActiveSet. So no drain-side recalc is needed (and none is correct).

🟡 three tooling fixes (rehearsal scripts only):

  • create-profile.ts — parse the ProfileCreated event by name rather than receipt.logs[0].topics[1] (the call also emits identity/wallet events).
  • fork-rehearsal.ts — read the delegator's pending withdrawal; assert credited == base + pending and that totalStake drops by base only (pending is excluded from totalStake), so a DELEGATOR override with a pending request no longer reports a false failure.
  • mirror-mainnet-delegator.ts — seed a real pending V8 withdrawal faithfully (fund the vault for base + pending + createDelegatorWithdrawalRequest) instead of silently dropping it.

CI Tornado: Solidity [1/4] / [4/4] — unrelated, not introduced here. Both fail on the CI's ContextGraphs typechain sanity check (createContextGraph binding), and they were already failing on this branch's first commit. This branch changes no ContextGraphs/typechain/config files, and typechain/contracts/ContextGraphs.ts (with createContextGraph) generates fine locally — so it's a pre-existing/base CI-environment issue, not a regression from this PR. The migration suite itself passes (23 green; the ABI freshness, Slither, Aderyn, CodeQL, and shards [2/4]/[3/4] checks are green).

// eligible only for the lock-bearing tiers; CSS returns whether the
// eligible sub-balance fully covered `amount`.
creditApplied = cs.spendMigrationCredit(staker, amount, lockTier == 6 || lockTier == 12);
uint40 expiryShortenedBy = creditApplied ? cs.convictionCreditSeconds() : 0;

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: creditApplied can be true while convictionCreditSeconds() is still 0 if the owner freezes the eligibility registry before initializing the credit. Because setConvictionCreditSeconds is blocked after freeze, every eligible 6/12 allocation would then silently lose its promised lock shortening forever. Please fail loudly here (or gate freeze/drain) when eligible credit is being consumed but the credit duration was never configured.

/// registry frozen so the eligible tag is final. Reverts
/// `NothingToMigrate` if nothing was drained (caller-error guard on
/// an intentional single call).
function adminMigrateToCredit(

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 replaces the public V8 migration surface instead of deprecating it. There are still in-repo callers/docs wired to selfMigrateV8 / adminMigrateV8* and ConvertedFromV8 (for example packages/evm-module/scripts/devnet-credit-smoke.ts and scripts/devnet-rc12-release-validation.sh), so publishing this ABI as-is will break those flows immediately. Either keep compatibility shims or update/remove every existing caller in the same PR.

}

// ---- allocate: spend credit into a fresh conviction position ----
const tokenId: bigint = await w.allocate.staticCall(TARGET_NODE, amount, LOCK_TIER);

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: Predicting tokenId with allocate.staticCall(...) is race-prone on a shared testnet. Another allocation can consume the next id before this transaction is mined, so the later ownerOf(tokenId) can report the wrong NFT or revert. Parse the minted token id from the mined Allocated/Transfer event instead of relying on a preflight static call.

…EpochStorage)

CI Solidity shards [4/4] and [1/4] were red on two version-assertion tests that
exist on main and are unrelated to this migration PR: the contracts were bumped
but the tests were not updated. ShardingTable._VERSION is 10.0.3 (test expected
10.0.2); EpochStorage._VERSION is 10.0.3 (test expected 10.0.2). Updated the
tests to match the contract _VERSION (the source of truth). No contract change.
(On main these shards are "skipped" on push, which masked the failure; they run
on PRs.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/// `expiryShortenedBy < duration` holds and a tier-6 allocation can
/// never underflow its expiry. The owner gate + until-frozen policy
/// live on the DKGStakingConvictionNFT entrypoint that drives this.
function setConvictionCreditSeconds(uint40 secondsValue) external onlyContracts {

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: The "immutable after freeze" guarantee is only enforced in the NFT wrapper. This CSS entrypoint is still directly callable by any Hub-registered contract or hub.owner() via onlyContracts, so convictionCreditSeconds can be changed after the eligibility registry is frozen by bypassing the wrapper. Move the freeze check into CSS as well, or narrow this setter to the exact caller path that enforces the freeze invariant.

/// registry frozen so the eligible tag is final. Reverts
/// `NothingToMigrate` if nothing was drained (caller-error guard on
/// an intentional single call).
function adminMigrateToCredit(

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 replaces the public selfMigrateV8 / adminMigrateV8* flow with a different ABI (adminMigrateToCredit + allocate) and also drops the old ConvertedFromV8 event, but the wrapper still reports version 10.0.2. That is a breaking contract/API change that existing clients and indexers cannot detect from version(). Either keep compatibility shims for the old surface or bump the wrapper version and rollout artifacts in this PR.

['StakingV10', stakingV10Addr],
['DKGStakingConvictionNFT', wrapperAddr],
] as const) {
await (await hub.connect(owner).setContractAddress(n, a)).wait();

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: Repointing only the Hub entries here is not enough for a realistic fork rehearsal. Ask, ShardingTable, and RandomSampling cache ConvictionStakingStorage in their own initialize() calls, so after this swap they still read the old CSS address. That means allocate()'s active-set/sharding path is exercised against stale stake data, and the rehearsal can pass while production would still be wired incorrectly. Reinitialize or redeploy every contract that caches CSS before using the new stack.

// target). Keyed by (identityId, TARGET_DELEGATOR) — the wallet that signs.
const ids = positions.map((p) => p.id);
const addrs = positions.map(() => TARGET_DELEGATOR);
if (!(await (Registry as any).frozen())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Issue: The registry already frozen branch silently skips eligibility setup and still proceeds to drain later. If this helper is rerun for a different TARGET_DELEGATOR or node set after freeze, the wallet will be mirrored and drained as non-eligible with no way to correct it. Fail fast here unless the target (identityId, delegator) pairs are already present in the frozen registry and convictionCreditSeconds matches the expected value.

…review

Collapse the credit model to ONE migrationCredit balance and make the tier-6/12
conviction lock-credit UNIVERSAL (any migrant on 6/12 earns it). Removes the
per-staker eligibility apparatus entirely.

- ConvictionStakingStorage: drop the eligibleCredit mapping; addMigrationCredit
  (delegator,total); spendMigrationCredit(staker,amount) is now a plain debit
  (no consumeEligible, no return, no clamp).
- StakingV10: drainV8ToCredit returns just total (no eligible tagging, no
  isEligible read); allocateFromCredit sets creditApplied = (lockTier==6||12)
  then expiryShortenedBy from convictionCreditSeconds. Drops V8MigrationEligibility.
- DKGStakingConvictionNFT: drop the frozen() gate on the admin drains and the
  until-frozen gate on setConvictionCreditSeconds; MigrationStarted simplified to
  (delegator, credited); drop the eligibleCredit view.
- DELETE V8MigrationEligibility.sol + its deploy script + the obsolete
  eligibility/old-surface scripts (upload_v8_eligibility, devnet-credit-smoke,
  V8_MIGRATION_CREDIT_RUNBOOK — these also carried the removed selfMigrateV8
  references the review flagged). Delete the registry-lifecycle test.
- 20 migration tests green; regenerate + sync the exported + packages/chain ABIs.

Also addresses PR review round 3:
- migrate-as-wallet.ts: read the minted tokenId from the Allocated event instead
  of a race-prone allocate.staticCall predict.
- the "drain leaves source node active" finding was a false positive (the V10
  active set ranks by ConvictionStakingStorage.getNodeStakeV10, not V8 stake) —
  no change; the "creditApplied true while convictionCreditSeconds==0" finding is
  moot under the one-balance model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Codex review skipped: filtered diff is 5743 lines (cap: 5,000). Please consider splitting this into smaller PRs for reviewability.

…hearsal + stale comments

Adversarial review of the rev-5 simplification found the fork-rehearsal pre-flight
still drove the deleted registry:
- fork-rehearsal.ts: drop the V8MigrationEligibility deploy / Hub-registration /
  setEligibleBatch / freeze (it would throw "artifact not found" at runtime). Keep
  setConvictionCreditSeconds (on the wrapper, still required for the tier-12
  allocate). Now deploys 3 contracts; init log says 12 deps.
- StakingV10.sol: fix two stale doc-comments referencing the removed freeze gate;
  drop phantom adminMigrateV8/adminConvertToNFT mentions in setV10LaunchEpoch.
- RFC50-TESTNET-REHEARSAL.md: "4 migration contracts" -> 3 (both spots).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Codex review skipped: filtered diff is 5737 lines (cap: 5,000). Please consider splitting this into smaller PRs for reviewability.

The admin-push migration (rev 5) removed self-service startMigration, so an
operator-run sweep is now the only path that empties V8. Add the driver +
runbook + local test harness.

scripts/v8-sweep-driver.ts:
- Enumerates (delegator, node) pairs via DelegatorsInfo.getDelegators (complete
  for active stake — V8 Staking calls addDelegator in lockstep with every
  stakeBase increase), then drains them into V10 migration credit via chunked,
  gas-sized adminDrainBatch signed by the Hub owner.
- Recovers the one enumeration gap (pending-only delegators removed from
  getDelegators but still holding a pending withdrawal) by scanning
  DelegatorsInfo.DelegatorAdded logs — but only when the pre-sweep vault
  decomposition shows pending actually exists (unattributed > dust).
- Vault-balance correctness oracle: stranded stake/pending physically sits in
  the StakingStorage TRAC vault, so completeness reduces to
  balanceOf(SS) == Σ operator fees. feeResting counts both getOperatorFeeBalance
  and open getOperatorFeeWithdrawalRequestAmount (withdrawal requests leave the
  fee balance but stay in the vault until finalize — else systematic false-RED).
- Gates: per-node getNodeStake==0, getTotalStake==0, credited==totalStake+
  enumerated pending, unattributed<=dust. Plan mode estimates the largest chunk's
  gas and flags an ACTIVE-STAKE GAP (getTotalStake vs enumerated) up front.
- Idempotent (drainV8ToCredit zeroes the V8 slot; adminDrainBatch skips zero),
  RPC reads wrapped in retry(), freeze precondition asserts V8 Staking is
  unregistered, buildCalldata split from submitChunk for a future Safe adapter.

scripts/V8_SWEEP_DRIVER.md: completeness rationale, plan→execute runbook, cutover
sequence (sweep to COMPLETE before unregistering V8 Staking), limitations.

scripts/sweep-test-setup.ts: throwaway local-node harness (multi-delegator/
multi-node incl. a recovered pending-only delegator + operator-fee residual)
used to validate the driver end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Codex review skipped: filtered diff is 7871 lines (cap: 5,000). Please consider splitting this into smaller PRs for reviewability.

…gration

# Conflicts:
#	packages/evm-module/test/unit/EpochStorage.test.ts
@zsculac

zsculac commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

The sweep runbook and driver should use the same cutover ordering. The driver currently requires the legacy V8 Staking entrypoint to be unregistered/frozen before a sweep runs, but the runbook text implies the unregister step happens after the sweep. I think the runbook should be updated so the sequence is explicit: freeze/unregister the V8 staking entrypoint first, then run plan, then execute the sweep, then verify. That avoids building and executing a sweep against balances that can still change while the migration is in progress.

@zsculac

zsculac commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

I do not see a V8 operator-fee migration path in this PR. The current drain path (StakingV10.drainV8ToCredit) reads only the delegator V8 stakeBase plus delegator pending withdrawal amount, transfers that total SS→CSS, and credits migrationCredit[delegator]. It does not read or transfer StakingStorage.getOperatorFeeBalance(identityId) or open operator-fee withdrawal requests into ConvictionStakingStorage.

The sweep driver also treats operator fees as residual vault funds that are explicitly not drained. That means V8 operator-fee balances must either be withdrawn/restaked by operators before V8 Staking is unregistered, or this PR needs an explicit operator-fee migration/rescue flow. Otherwise, once V8 Staking is no longer Hub-registered, the normal operator-fee request/finalize/restake functions may no longer be able to mutate StakingStorage, leaving those balances stranded in the old vault.

This is not just theoretical: current active-node V8 mainnet reads show nonzero operator-fee balances on NeuroWeb, Gnosis, and Base. Please either add a V8 operator-fee migration path, or make the cutover runbook explicitly require clearing/finalizing/restaking operator fees before shutdown and verify zero residual operator-fee balances/requests per chain.

@zsculac

zsculac commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

The sweep driver should hard-fail before submitting transactions when the pre-sweep vault decomposition shows an unresolved accounting gap.

Today the plan prints red warnings for cases such as ACTIVE-STAKE GAP or UNATTRIBUTED VAULT TRAC, but MODE=execute continues into chunk submission and only fails after the final verification. I think these should be preflight blockers: if activeGap > 0, unattributed > DUST_TOLERANCE, or the pending-only event scan was degraded/skipped, execution should stop before sending any adminDrainBatch transaction unless an explicit override is provided.

The reason is operational safety rather than reentrancy/token leakage: an unattributed vault balance means the driver cannot currently classify all TRAC in StakingStorage as active delegator stake, pending delegator withdrawals, or operator fees. Executing anyway can produce a partially completed sweep that still requires manual reconciliation afterward. A hard preflight stop makes the migration process easier to reason about and prevents a red plan from becoming a sent transaction by accident.

V8 operator fees lived only on the V8 Staking *logic* contract
(request/finalize/restake); once it is unregistered at cutover, accrued
operator-fee balances would be stranded in the StakingStorage vault with no
entry point. Mainnet has nonzero operator-fee balances on NeuroWeb, Gnosis, and
Base (zsculac), so the admin-push sweep must drain them too.

Contracts (§0 Option B — "one credit"): the operator fee folds into the
operator's SAME migrationCredit bucket as delegator stake, recovered via
allocate (tier 0 → liquid, or 6/12 → conviction) exactly like a delegator.
- StakingV10.drainOperatorFeeToCredit(identityId, operator): drains
  operatorFeeBalance + any open fee-withdrawal request, zeroes the V8 slots
  (idempotent), moves the TRAC SS→CSS, and credits the operator. Operator
  addresses are not recoverable on-chain (IdentityStorage holds key hashes), so
  the address is supplied off-chain and validated here against ADMIN_KEY — a
  stale/wrong address REVERTS, never credits silently.
- DKGStakingConvictionNFT.adminDrainOperatorFeesBatch(ids[], operators[]):
  owner/multisig-gated bulk drain, skip-zero, emits OperatorFeeMigrated per node.
- 8 tests: credit-to-operator, fold open request + SS→CSS exact, multi-node
  (distinct operators), skip-zero, idempotent, non-admin revert, length, gate.
  Full pool-migration suite 28 passing. ABIs regenerated + chain snapshots synced.

Sweep driver (scripts/v8-sweep-driver.ts) + runbook — addresses the three review
comments from zsculac:
- operator fees (#2): enumerate fee-bearing nodes id-keyed (complete), resolve
  operators via OPERATOR_MAP validated on-chain, drain via
  adminDrainOperatorFeesBatch. Vault oracle tightened from "residual == Σ fees"
  to the EMPTY-VAULT gate balanceOf(SS) ≤ dust (stake + pending + fees all out).
- preflight hard-fail (#3): MODE=execute now stops BEFORE sending any tx on an
  active-stake gap, unattributed vault TRAC, a degraded DelegatorAdded scan, or
  an unresolved operator (OVERRIDE_PREFLIGHT=1 forces; not recommended) — a red
  plan can no longer become a partial sweep.
- cutover ordering (#1): runbook corrected to freeze-FIRST (unregister V8
  Staking before plan/execute, which the driver already asserts), then sweep to
  an empty vault as the release gate.
Validated on a local node: full sweep drains the vault to 0 and reaches
COMPLETE; an unresolved operator hard-blocks execute with no tx sent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread packages/evm-module/contracts/DKGStakingConvictionNFT.sol Fixed
Comment thread packages/evm-module/contracts/DKGStakingConvictionNFT.sol Fixed
…drains

The admin drain batches (adminDrainBatch, adminDrainOperatorFeesBatch,
adminMigrateToCredit/_drainAll) are onlyOwnerOrMultiSigOwner; the looped external
call is the onlyConvictionNFT StakingV10 worker over trusted protocol storage,
moves plain ERC20 TRAC (no callback), and is idempotent — no value-reentrancy
vector, and event-after-call is benign. Annotate with slither-disable-next-line
(directive placed immediately above the call so it actually applies). Confirmed
with Slither 0.11.3: the five calls-loop/reentrancy-events findings clear.
No bytecode/ABI change (comments only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

3 participants