feat(evm): OT-RFC-50 V8→V10 admin-push stake migration#1192
feat(evm): OT-RFC-50 V8→V10 admin-push stake migration#1192branarakic wants to merge 12 commits into
Conversation
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", |
There was a problem hiding this comment.
🔴 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 () => { |
There was a problem hiding this comment.
🟡 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.
…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>
|
Addressed the review findings in 🔴 Stale chain ABI snapshots (codex) — fixed. Regenerated 🟡 Missing D8 pending-withdrawal coverage (codex) — fixed. Added a regression in the Slither reentrancy-events / calls-in-loop (
|
| // 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); |
There was a problem hiding this comment.
🔴 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 |
There was a problem hiding this comment.
🟡 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], |
There was a problem hiding this comment.
🟡 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]); |
There was a problem hiding this comment.
🟡 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>
|
Round 2 — addressed in 🔴 "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 🟡 three tooling fixes (rehearsal scripts only):
CI |
| // 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; |
There was a problem hiding this comment.
🔴 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( |
There was a problem hiding this comment.
🔴 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); |
There was a problem hiding this comment.
🔴 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 { |
There was a problem hiding this comment.
🔴 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( |
There was a problem hiding this comment.
🔴 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(); |
There was a problem hiding this comment.
🔴 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())) { |
There was a problem hiding this comment.
🟡 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>
…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>
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>
…gration # Conflicts: # packages/evm-module/test/unit/EpochStorage.test.ts
|
The sweep runbook and driver should use the same cutover ordering. The driver currently requires the legacy V8 |
|
I do not see a V8 operator-fee migration path in this PR. The current drain path ( 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 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. |
|
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 The reason is operational safety rather than reentrancy/token leakage: an unattributed vault balance means the driver cannot currently classify all TRAC in |
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>
…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>
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 —
startMigrationis removed.Contracts (
packages/evm-module)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 areonlyOwnerOrMultiSigOwnerand requireV8MigrationEligibility.frozen().drainV8ToCreditworker (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.migrationCredit/eligibleCredit, deploy-timeconvictionCreditSeconds).Safety
allocate(node, amount, 0)→withdraw— and tier 0 cannot be gated off by the owner.allocatemoves no TRAC; credit is never withdrawable as credit.Tests & review
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.stakeRewardIndexedfinding was refuted (dead/legacy field; the release-gate aggregate_totalStakeis independent of it).Deploy / cutover
0x99Aa…/ Gnosis0x882D…), not a fresh Hub.DelegatorsInfo.getDelegators→ chunkedadminDrainBatch→ untilgetTotalStake()==0per chain) is a separate ops deliverable.StakingStorage.getTotalStake() == 0per chain before unregistering the V8Stakingcontract.packages/evm-module/scripts/(mirror-mainnet-delegator,migrate-as-wallet,fork-rehearsal,create-profile+RFC50-TESTNET-REHEARSAL.md).🤖 Generated with Claude Code