import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers';
import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers';
import { expect } from 'chai';
import hre from 'hardhat';

import { ConvictionStakingStorage, Chronos } from '../../typechain';

type Fixture = {
  accounts: SignerWithAddress[];
  ConvictionStakingStorage: ConvictionStakingStorage;
  Chronos: Chronos;
};

const ALICE_ID = 100n;
const BOB_ID = 200n;
const OTHER_ID = 999n;

// 1e18-scaled multiplier constants matching the baseline D20 tier ladder.
const SCALE18 = 10n ** 18n;
const ONE_X = SCALE18;
const ONE_AND_HALF_X = (15n * SCALE18) / 10n;
const TWO_X = 2n * SCALE18;
const THREE_AND_HALF_X = (35n * SCALE18) / 10n;
const SIX_X = 6n * SCALE18;

const DAY = 24n * 60n * 60n;
const HALF_DAY = 12n * 60n * 60n;
const TIER_DURATIONS: Record<number, bigint> = {
  0: 0n,
  1: 30n * DAY,
  3: 90n * DAY,
  6: 180n * DAY,
  12: 366n * DAY,
};

const EXPIRY_BUCKET_SECONDS = HALF_DAY;

// D26 — timestamp accounting: no BLOCK_DRIFT_BUFFER padding, no epoch rounding.
// Boost expiries are rounded up to half-day buckets for queue DoS hardening.

async function latestTimestamp(): Promise<bigint> {
  return BigInt(await time.latest());
}

// Expected boost contribution to `runningNodeEffectiveStake` for a fresh
// `createPosition` at lockTier/multiplier18/raw. Under D26 the full
// `raw * mult / 1e18` is added while the position is boosted; on expiry the
// scheduled drop equals the boost-only portion (`raw * (mult-1) / 1e18`).
function effNow(raw: bigint, mult18: bigint): bigint {
  return (raw * mult18) / SCALE18;
}
function boostDrop(raw: bigint, mult18: bigint): bigint {
  return (raw * (mult18 - SCALE18)) / SCALE18;
}
function bucketExpiry(rawExpiry: bigint): bigint {
  return (
    ((rawExpiry + EXPIRY_BUCKET_SECONDS - 1n) / EXPIRY_BUCKET_SECONDS) *
    EXPIRY_BUCKET_SECONDS
  );
}
function expectedExpiryFrom(baseTs: bigint, tier: number): bigint {
  return bucketExpiry(baseTs + TIER_DURATIONS[tier]);
}

describe('@unit ConvictionStakingStorage', () => {
  let accounts: SignerWithAddress[];
  let ConvictionStakingStorage: ConvictionStakingStorage;
  let Chronos: Chronos;

  async function deployFixture(): Promise<Fixture> {
    await hre.deployments.fixture(['ConvictionStakingStorage']);
    ConvictionStakingStorage = await hre.ethers.getContract<ConvictionStakingStorage>(
      'ConvictionStakingStorage',
    );
    Chronos = await hre.ethers.getContract<Chronos>('Chronos');
    accounts = await hre.ethers.getSigners();
    return { accounts, ConvictionStakingStorage, Chronos };
  }

  beforeEach(async () => {
    hre.helpers.resetDeploymentsJson();
    ({ accounts, ConvictionStakingStorage, Chronos } = await loadFixture(deployFixture));
  });

  // ------------------------------------------------------------
  // Metadata
  // ------------------------------------------------------------

  it('Should have correct name and version', async () => {
    expect(await ConvictionStakingStorage.name()).to.equal('ConvictionStakingStorage');
    // v10.0.6 — one-shot delta boost accounting (audit G-6) on top of v10.0.5's
    // bucketed expiries + bounded per-node pending expiry slots.
    expect(await ConvictionStakingStorage.version()).to.equal('10.0.6');
  });

  // ------------------------------------------------------------
  // Tier ladder (exact wall-clock durations, no BLOCK_DRIFT_BUFFER)
  // ------------------------------------------------------------

  describe('expectedMultiplier18 tier ladder (D26 durations)', () => {
    it('Returns canonical multipliers for the baseline ladder {0,1,3,6,12}', async () => {
      expect(await ConvictionStakingStorage.expectedMultiplier18(0)).to.equal(ONE_X);
      expect(await ConvictionStakingStorage.expectedMultiplier18(1)).to.equal(ONE_AND_HALF_X);
      expect(await ConvictionStakingStorage.expectedMultiplier18(3)).to.equal(TWO_X);
      expect(await ConvictionStakingStorage.expectedMultiplier18(6)).to.equal(THREE_AND_HALF_X);
      expect(await ConvictionStakingStorage.expectedMultiplier18(12)).to.equal(SIX_X);
    });

    it('Returns exact wall-clock durations (BLOCK_DRIFT_BUFFER removed)', async () => {
      for (const [tierStr, duration] of Object.entries(TIER_DURATIONS)) {
        const tier = Number(tierStr);
        expect(await ConvictionStakingStorage.tierDuration(tier)).to.equal(duration);
      }
    });

    it('Reverts on every lock outside the discrete set', async () => {
      for (const invalid of [2, 4, 5, 7, 8, 9, 10, 11, 13, 24, 100]) {
        await expect(
          ConvictionStakingStorage.expectedMultiplier18(invalid),
        ).to.be.revertedWith('Invalid tier');
      }
    });
  });

  // ------------------------------------------------------------
  // createPosition — timestamp-accurate state
  // ------------------------------------------------------------

  describe('createPosition — single position', () => {
    it('Stores position fields with zero-lock (permanent 1x)', async () => {
      const currentEpoch = await Chronos.getCurrentEpoch();
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 0, 0, 0);

      const pos = await ConvictionStakingStorage.getPosition(1);
      expect(pos.raw).to.equal(1000);
      expect(pos.cumulativeRewardsClaimed).to.equal(0);
      expect(pos.lockTier).to.equal(0);
      // Tier 0 expiry sentinel: no boost → 0.
      expect(pos.expiryTimestamp).to.equal(0);
      expect(pos.multiplier18).to.equal(ONE_X);
      expect(pos.identityId).to.equal(ALICE_ID);
      expect(pos.lastClaimedEpoch).to.equal(currentEpoch - 1n);
      expect(pos.migrationEpoch).to.equal(0);
    });

    it('Tier-12 position: expiryTimestamp = next half-day bucket after block.ts + 366d, full boost in running stake, drop at expiry', async () => {
      const tx = await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
      await tx.wait();
      const tsNow = await latestTimestamp();
      const expectedExpiry = expectedExpiryFrom(tsNow, 12);

      const pos = await ConvictionStakingStorage.getPosition(1);
      expect(pos.expiryTimestamp).to.equal(expectedExpiry);
      expect(pos.multiplier18).to.equal(SIX_X);

      // Running stake at creation: full raw*6 = 6000.
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(
        effNow(1000n, SIX_X),
      );
      expect(await ConvictionStakingStorage.getNodeEffectiveStake(ALICE_ID)).to.equal(
        effNow(1000n, SIX_X),
      );

      // Expiry queue holds a single drop = raw*(6-1) = 5000 at `expectedExpiry`.
      const times = await ConvictionStakingStorage.getNodeExpiryTimes(ALICE_ID);
      expect(times.length).to.equal(1);
      expect(times[0]).to.equal(expectedExpiry);
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expectedExpiry)).to.equal(
        boostDrop(1000n, SIX_X),
      );

      // Simulated read past expiry: stake drops to raw (1x tail).
      expect(
        await ConvictionStakingStorage.getNodeEffectiveStakeAtTimestamp(
          ALICE_ID,
          expectedExpiry,
        ),
      ).to.equal(1000n);
      expect(
        await ConvictionStakingStorage.getNodeEffectiveStakeAtTimestamp(
          ALICE_ID,
          expectedExpiry + 10_000n,
        ),
      ).to.equal(1000n);
    });

    it('Fractional tier 1.5x (lock=1): raw 2000 → boost 1000, drop at the half-day bucket after +30d', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 2000, 1, 0, 0);
      const tsNow = await latestTimestamp();
      const expectedExpiry = expectedExpiryFrom(tsNow, 1);

      const pos = await ConvictionStakingStorage.getPosition(1);
      expect(pos.expiryTimestamp).to.equal(expectedExpiry);

      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(3000n);
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expectedExpiry)).to.equal(
        1000n,
      );
      expect(
        await ConvictionStakingStorage.getNodeEffectiveStakeAtTimestamp(
          ALICE_ID,
          expectedExpiry - 1n,
        ),
      ).to.equal(3000n);
      expect(
        await ConvictionStakingStorage.getNodeEffectiveStakeAtTimestamp(
          ALICE_ID,
          expectedExpiry,
        ),
      ).to.equal(2000n);
    });

    it('Fractional tier 3.5x (lock=6): raw 2000 → boost 5000, drop at the half-day bucket after +180d', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 2000, 6, 0, 0);
      const tsNow = await latestTimestamp();
      const expectedExpiry = expectedExpiryFrom(tsNow, 6);

      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(7000n);
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expectedExpiry)).to.equal(
        5000n,
      );
    });

    it('Reverts on invalid inputs', async () => {
      await expect(
        ConvictionStakingStorage.createPosition(1, 0, 1000, 12, 0, 0),
      ).to.be.revertedWith('Zero node');

      await expect(
        ConvictionStakingStorage.createPosition(1, ALICE_ID, 0, 12, 0, 0),
      ).to.be.revertedWith('Zero raw');

      // L11 — multiplier18 is now derived inside CSS; tier-mismatch paths
      //       are no longer reachable from the external surface. Only the
      //       "Invalid tier" check remains as the lock-validation signal.
      await expect(
        ConvictionStakingStorage.createPosition(99, ALICE_ID, 1000, 5, 0, 0),
      ).to.be.revertedWith('Invalid tier');

      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
      await expect(
        ConvictionStakingStorage.createPosition(1, ALICE_ID, 500, 3, 0, 0),
      ).to.be.revertedWith('Position exists');
    });
  });

  // ------------------------------------------------------------
  // Sorted-insert invariant (Option A)
  // ------------------------------------------------------------

  describe('expiry queue — sorted insert invariant', () => {
    it('Inserts expiries in ascending order regardless of creation order (mixed tiers)', async () => {
      // Create tier-12 first (furthest expiry), then tier-1 (soonest), then tier-6.
      // Expected final order in the queue: [+30d, +180d, +366d].
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 100, 12, 0, 0);
      const ts12 = expectedExpiryFrom(await latestTimestamp(), 12);

      await ConvictionStakingStorage.createPosition(2, ALICE_ID, 100, 1, 0, 0);
      const ts1 = expectedExpiryFrom(await latestTimestamp(), 1);

      await ConvictionStakingStorage.createPosition(3, ALICE_ID, 100, 6, 0, 0);
      const ts6 = expectedExpiryFrom(await latestTimestamp(), 6);

      const times = await ConvictionStakingStorage.getNodeExpiryTimes(ALICE_ID);
      expect(times.length).to.equal(3);
      expect(times[0]).to.equal(ts1);
      expect(times[1]).to.equal(ts6);
      expect(times[2]).to.equal(ts12);
      expect(await ConvictionStakingStorage.getNodePendingExpiryCount(ALICE_ID)).to.equal(3);
    });

    it('Coalesces drops at the same timestamp (two boosted positions at identical expiry)', async () => {
      // Create two tier-12 positions in the same block. Their expiryTimestamps
      // collide, but the queue must still hold a SINGLE entry with the summed drop.
      await hre.network.provider.send('evm_setAutomine', [false]);
      try {
        await ConvictionStakingStorage.createPosition(1, ALICE_ID, 100, 12, 0, 0);
        await ConvictionStakingStorage.createPosition(2, ALICE_ID, 200, 12, 0, 0);
        await hre.network.provider.send('evm_mine', []);
      } finally {
        await hre.network.provider.send('evm_setAutomine', [true]);
      }

      const tsNow = await latestTimestamp();
      const expectedExpiry = expectedExpiryFrom(tsNow, 12);

      const times = await ConvictionStakingStorage.getNodeExpiryTimes(ALICE_ID);
      expect(times.length).to.equal(1);
      expect(times[0]).to.equal(expectedExpiry);
      expect(
        await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expectedExpiry),
      ).to.equal(boostDrop(300n, SIX_X));
    });

    it('Dust stakes at distinct seconds coalesce into the same 12h bucket (queue stays bounded)', async () => {
      // The DoS the bucketing closes: many stakes against a node at DISTINCT block
      // timestamps. Pre-fix every distinct second was its own queue slot (unbounded
      // -> settle eventually exceeds block gas). With 12h bucketing, all stakes whose
      // lock ends in the same half-day share ONE slot, so the queue cannot grow with
      // the stake count.
      const N = 50;
      for (let i = 1; i <= N; i++) {
        // each createPosition auto-mines a block (timestamp +1s) -> N distinct
        // seconds, all inside the same 12h bucket.
        await ConvictionStakingStorage.createPosition(i, ALICE_ID, 1, 12, 0, 0);
      }
      // All N expiries fall in at most two adjacent 12h buckets (one, unless the
      // ~N-second creation window straddles a bucket boundary).
      const pending = await ConvictionStakingStorage.getNodePendingExpiryCount(ALICE_ID);
      expect(Number(pending)).to.be.lessThanOrEqual(2);

      // The whole bounded queue drains in one cheap settle past the last expiry.
      const times = await ConvictionStakingStorage.getNodeExpiryTimes(ALICE_ID);
      await time.increaseTo(Number(times[times.length - 1]) + 1);
      await ConvictionStakingStorage.settleNodeTo(ALICE_ID, await latestTimestamp());
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(
        BigInt(N),
      );
      expect(await ConvictionStakingStorage.getNodePendingExpiryCount(ALICE_ID)).to.equal(0);
    });

    it('Tier-duration cap keeps the per-node slot cap an unreachable backstop', async () => {
      // MAX_TIER_DURATION = MAX_NODE_PENDING_EXPIRIES * EXPIRY_BUCKET_SECONDS
      // (1024 * 12h = 512 days). Capping the tier there means a single tier's
      // expiry window can never span more than MAX_NODE_PENDING_EXPIRIES distinct
      // buckets, so a node can never accumulate enough distinct future buckets to
      // reach the slot cap — `NodeExpiryQueueFull` (and the tombstone-vs-live slot
      // distinction) can therefore never block a legitimate stake/relock. The
      // production ladder tops out at 366 days, well under this. (Same-bucket
      // coalescing is covered by the "Coalesces drops at the same timestamp" test.)
      const maxPending = await ConvictionStakingStorage.MAX_NODE_PENDING_EXPIRIES();
      const bucketSecs = await ConvictionStakingStorage.EXPIRY_BUCKET_SECONDS();
      const maxTier = await ConvictionStakingStorage.MAX_TIER_DURATION();
      // (MAX_NODE_PENDING_EXPIRIES - 1) * EXPIRY_BUCKET_SECONDS = 1023 * 12h = 511.5
      // days. The `- 1` accounts for `_computeExpiryTimestamp` rounding UP: a k-bucket
      // tier created just after a boundary expires k+1 buckets out, so a node can hold
      // up to tierBuckets+1 distinct pending slots; capping at MAX-1 keeps that <= MAX.
      expect(maxTier).to.equal((maxPending - 1n) * bucketSecs);

      // A tier whose expiry window would exceed the slot cap is rejected...
      await expect(
        ConvictionStakingStorage.addTier(24, maxTier + 1n, TWO_X),
      ).to.be.revertedWith('Tier duration too long');
      // ...exactly at the cap is accepted.
      await expect(ConvictionStakingStorage.addTier(24, maxTier, TWO_X)).to.not.be.reverted;
      expect(await ConvictionStakingStorage.tierDuration(24)).to.equal(maxTier);
    });

  });

  // ------------------------------------------------------------
  // Multi-position aggregation on a single node
  // ------------------------------------------------------------

  describe('per-node aggregation', () => {
    it('Two NFTs under same identityId sum their effective stake and decay independently', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 500, 12, 0, 0);
      const tsAtCreate1 = await latestTimestamp();
      const expiry12 = expectedExpiryFrom(tsAtCreate1, 12);

      await ConvictionStakingStorage.createPosition(2, ALICE_ID, 200, 3, 0, 0);
      const tsAtCreate2 = await latestTimestamp();
      const expiry3 = expectedExpiryFrom(tsAtCreate2, 3);

      // Before any expiry: 500*6 + 200*2 = 3400.
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(3400n);

      // At expiry3 (tier-3 drops boost): 500*6 + 200 = 3200.
      expect(
        await ConvictionStakingStorage.getNodeEffectiveStakeAtTimestamp(ALICE_ID, expiry3),
      ).to.equal(3200n);

      // At expiry12 (tier-12 drops boost): 500 + 200 = 700.
      expect(
        await ConvictionStakingStorage.getNodeEffectiveStakeAtTimestamp(ALICE_ID, expiry12),
      ).to.equal(700n);
    });

    it('Second node is unaffected by first node writes', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 500, 12, 0, 0);
      await ConvictionStakingStorage.createPosition(2, ALICE_ID, 200, 3, 0, 0);

      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(BOB_ID)).to.equal(0n);
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(OTHER_ID)).to.equal(0n);
    });
  });

  // ------------------------------------------------------------
  // settleNodeTo — drains expiries in one pass
  // ------------------------------------------------------------

  describe('settleNodeTo', () => {
    it('Drains a single expiry, zeroes the drop, bumps the head cursor', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
      const tsAtCreate = await latestTimestamp();
      const expiry = expectedExpiryFrom(tsAtCreate, 12);

      // Advance past the expiry.
      await time.increaseTo(Number(expiry) + 1);
      await ConvictionStakingStorage.settleNodeTo(ALICE_ID, await latestTimestamp());

      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(1000n);
      expect(await ConvictionStakingStorage.getNodeExpiryHead(ALICE_ID)).to.equal(1n);
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expiry)).to.equal(0n);
    });

    it('Drains multiple pending expiries across one call (dormancy resume)', async () => {
      // 3 positions with staggered expiries (tiers 1, 3, 12).
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 100, 1, 0, 0);
      await ConvictionStakingStorage.createPosition(2, ALICE_ID, 100, 3, 0, 0);
      await ConvictionStakingStorage.createPosition(3, ALICE_ID, 100, 12, 0, 0);

      const times = await ConvictionStakingStorage.getNodeExpiryTimes(ALICE_ID);
      // Jump well past all three expiries in one go.
      await time.increaseTo(Number(times[2] + 10n));
      await ConvictionStakingStorage.settleNodeTo(ALICE_ID, await latestTimestamp());

      // After full drain: raw-tail only = 100 + 100 + 100 = 300.
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(300n);
      expect(await ConvictionStakingStorage.getNodeExpiryHead(ALICE_ID)).to.equal(3n);
      expect(await ConvictionStakingStorage.getNodePendingExpiryCount(ALICE_ID)).to.equal(0);
    });

    it('Idempotent: settling to a past timestamp is a no-op', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
      const tsAtCreate = await latestTimestamp();
      await ConvictionStakingStorage.settleNodeTo(ALICE_ID, tsAtCreate);
      await ConvictionStakingStorage.settleNodeTo(ALICE_ID, tsAtCreate - 1n);
      // Still fully boosted.
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(6000n);
      expect(await ConvictionStakingStorage.getNodeExpiryHead(ALICE_ID)).to.equal(0n);
    });
  });

  // ------------------------------------------------------------
  // Mutators — running-state maintenance
  // ------------------------------------------------------------

  // M1 / L11 — `updateOnRelock` was removed. Post-expiry relocks now go
  // through `createNewPositionFromExisting` (burn old tokenId, mint a fresh
  // one keyed to the new lockTier). See `DKGStakingConvictionNFT.test.ts`
  // for the token-side coverage and the D26 integration suite for the
  // end-to-end flow.

  describe('updateOnRedelegate', () => {
    it('Moves full effective contribution from old node to new node; cancels + reschedules the boost drop', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
      const expiry = expectedExpiryFrom(await latestTimestamp(), 12);

      await ConvictionStakingStorage.updateOnRedelegate(1, BOB_ID);

      expect((await ConvictionStakingStorage.getPosition(1)).identityId).to.equal(BOB_ID);
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(0n);
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(BOB_ID)).to.equal(6000n);

      // Alice's queue entry was cancelled; Bob's queue has the same ts.
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expiry)).to.equal(0n);
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(BOB_ID, expiry)).to.equal(5000n);
    });

    it('Redelegating a post-expiry position moves only the raw tail (no boost to cancel)', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 3, 0, 0);
      const expiry = expectedExpiryFrom(await latestTimestamp(), 3);
      await time.increaseTo(Number(expiry) + 1);
      await ConvictionStakingStorage.settleNodeTo(ALICE_ID, await latestTimestamp());

      await ConvictionStakingStorage.updateOnRedelegate(1, BOB_ID);

      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(0n);
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(BOB_ID)).to.equal(1000n);
    });

    it('Reverts when redelegating to same node / identityId 0', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
      await expect(
        ConvictionStakingStorage.updateOnRedelegate(1, ALICE_ID),
      ).to.be.revertedWith('Same node');
      await expect(
        ConvictionStakingStorage.updateOnRedelegate(1, 0),
      ).to.be.revertedWith('Zero node');
    });
  });

  describe('deletePosition', () => {
    it('Wipes a pre-expiry position and cancels its pending boost drop', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
      const expiry = expectedExpiryFrom(await latestTimestamp(), 12);

      await ConvictionStakingStorage.deletePosition(1);
      const pos = await ConvictionStakingStorage.getPosition(1);
      expect(pos.raw).to.equal(0);
      expect(pos.identityId).to.equal(0);

      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(0n);
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expiry)).to.equal(0n);
    });

    it('Post-expiry delete: boost was already drained, only raw tail is unwound', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 3, 0, 0);
      const expiry = expectedExpiryFrom(await latestTimestamp(), 3);
      await time.increaseTo(Number(expiry) + 1);
      await ConvictionStakingStorage.settleNodeTo(ALICE_ID, await latestTimestamp());

      await ConvictionStakingStorage.deletePosition(1);
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(0n);
    });

    it('Reverts on missing position', async () => {
      await expect(ConvictionStakingStorage.deletePosition(42)).to.be.revertedWith('No position');
    });

    it('Succeeds on a rewards-only position (raw==0, identityId!=0)', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 0, 0, 0);
      await ConvictionStakingStorage.decreaseRaw(1, 1000);
      const pos = await ConvictionStakingStorage.getPosition(1);
      expect(pos.raw).to.equal(0);
      expect(pos.identityId).to.equal(ALICE_ID);
      await expect(ConvictionStakingStorage.deletePosition(1)).to.not.be.reverted;
    });
  });

  describe('decreaseRaw / increaseRaw', () => {
    it('Pre-expiry decreaseRaw shrinks running stake by amount*mult and cancels proportional boost drop', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
      const expiry = expectedExpiryFrom(await latestTimestamp(), 12);

      await ConvictionStakingStorage.decreaseRaw(1, 400);
      const pos = await ConvictionStakingStorage.getPosition(1);
      expect(pos.raw).to.equal(600);

      // Running effective: (1000-400)*6 = 3600.
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(3600n);
      // Boost drop shrunk to 600*(6-1) = 3000.
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expiry)).to.equal(3000n);
    });

    it('Post-expiry decreaseRaw removes a flat `amount` (boost already drained)', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 3, 0, 0);
      const expiry = expectedExpiryFrom(await latestTimestamp(), 3);
      await time.increaseTo(Number(expiry) + 1);
      await ConvictionStakingStorage.settleNodeTo(ALICE_ID, await latestTimestamp());

      await ConvictionStakingStorage.decreaseRaw(1, 300);
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(700n);
    });

    it('increaseRaw pre-expiry grows running stake by amount*mult and adds to the same expiry bucket', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 600, 12, 0, 0);
      const expiry = expectedExpiryFrom(await latestTimestamp(), 12);

      await ConvictionStakingStorage.increaseRaw(1, 200);
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(4800n);
      // drop grew: 800*(6-1) = 4000.
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expiry)).to.equal(4000n);
    });

    it('Round-trip: decreaseRaw then increaseRaw restores running stake exactly (pre-expiry)', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
      const expiry = expectedExpiryFrom(await latestTimestamp(), 12);
      const runningBefore = await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID);
      const dropBefore = await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expiry);

      await ConvictionStakingStorage.decreaseRaw(1, 400);
      await ConvictionStakingStorage.increaseRaw(1, 400);

      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(
        runningBefore,
      );
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expiry)).to.equal(
        dropBefore,
      );
    });

    it('decreaseRaw reverts on insufficient raw / non-existent position', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
      await expect(ConvictionStakingStorage.decreaseRaw(1, 1001)).to.be.revertedWith(
        'Insufficient raw',
      );
      await expect(ConvictionStakingStorage.decreaseRaw(42, 1)).to.be.revertedWith('No position');
    });

    it('Zero-amount ops are no-ops', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
      const runningBefore = await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID);
      await ConvictionStakingStorage.increaseRaw(1, 0);
      await ConvictionStakingStorage.decreaseRaw(1, 0);
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(
        runningBefore,
      );
    });

    // ── Audit G-6 — one-shot delta accounting on the OTHER changed function.
    //    increaseRaw/decreaseRaw install/remove a position's contribution as the
    //    one-shot delta floor((oldRaw±amount)*m) - floor(oldRaw*m), so a compounded
    //    boosted position's slot/running stake always equal the one-shot value and
    //    the unwind is exact. (redelegate is covered by the integration regression;
    //    relock/exit are post-expiry-gated and decreaseRaw has no production caller,
    //    so this exercises the decreaseRaw change directly via the operator.)
    it('G-6: a reward-compounded boosted position unwinds via decreaseRaw exactly (one-shot delta, no floor over-cancel)', async () => {
      // Tier 6 = 3.5x (fractional). ODD raw + ODD compound so the per-piece floors
      // would disagree with the one-shot floor by 1 wei (the audit-G-6 carry case).
      const R0 = 100000n * 10n ** 18n + 1n; // odd
      const rew = R0; // odd compound
      const total = R0 + rew; // even ⇒ floor(total*k) is exact
      const eff = (x: bigint) => (x * 35n) / 10n; // floor(x*3.5)
      const boost = (x: bigint) => (x * 25n) / 10n; // floor(x*(3.5-1))

      await ConvictionStakingStorage.createPosition(1, ALICE_ID, R0, 6, 0, 0);
      const expiry = (await ConvictionStakingStorage.getPosition(1)).expiryTimestamp;

      // Compound: increaseRaw installs the boost as the ONE-SHOT delta, so the slot
      // and running stake hold floor(total*k) — 1 wei MORE than the pre-fix
      // sum-of-floors floor(R0*k)+floor(rew*k).
      await ConvictionStakingStorage.increaseRaw(1, rew);
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(
        eff(total),
      );
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expiry)).to.equal(
        boost(total),
      );

      // Full decrease while still boosted: the one-shot delta removes exactly the
      // installed contribution. Pre-fix decreaseRaw removed floor(total*k), 1 wei
      // MORE than the sum-of-floors slot ⇒ "Neg running stake" / "Cancel > scheduled".
      await expect(ConvictionStakingStorage.decreaseRaw(1, total)).to.not.be.reverted;
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(0n);
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expiry)).to.equal(0n);
      expect((await ConvictionStakingStorage.getPosition(1)).raw).to.equal(0n);
    });

    // ── Audit G-6 — the `boostDelta != 0` skip in increaseRaw. On a near-1x tier a
    //    small compound can floor the one-shot BOOST delta to 0 even though the raw
    //    grew; _scheduleNodeExpiry rejects a zero drop, so increaseRaw must skip it.
    it('G-6: increaseRaw skips a zero one-shot boost delta on a near-1x tier (no "Zero drop" revert)', async () => {
      // Tier 1 = 1.5x ⇒ boost factor 0.5. oldRaw=2, amount=1:
      //   one-shot boost delta = floor(3*0.5) - floor(2*0.5) = 1 - 1 = 0 ⇒ skip schedule.
      //   one-shot eff   delta = floor(3*1.5) - floor(2*1.5) = 4 - 3 = 1 ⇒ running grows.
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 2, 1, 0, 0);
      const expiry = (await ConvictionStakingStorage.getPosition(1)).expiryTimestamp;
      const slotBefore = await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expiry);

      // Pre-guard this called _scheduleNodeExpiry(.., 0) and reverted "Zero drop".
      await expect(ConvictionStakingStorage.increaseRaw(1, 1)).to.not.be.reverted;

      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expiry)).to.equal(
        slotBefore, // unchanged — the zero boost delta was skipped
      );
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(4n);
      expect((await ConvictionStakingStorage.getPosition(1)).raw).to.equal(3n);
    });
  });

  // ------------------------------------------------------------
  // D26-specific: find-and-remove on delete before expiry is O(1)
  // amortized (queue entry stays, drop goes to 0; drained later).
  // ------------------------------------------------------------

  describe('boost-drop cancellation leaves queue entry intact', () => {
    it('Deletes the only pending position: queue entry remains but drop is 0', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
      const expiry = expectedExpiryFrom(await latestTimestamp(), 12);
      await ConvictionStakingStorage.deletePosition(1);

      // Entry is still present in the array (drained later as a no-op).
      const times = await ConvictionStakingStorage.getNodeExpiryTimes(ALICE_ID);
      expect(times.length).to.equal(1);
      expect(times[0]).to.equal(expiry);
      // But the drop has been cancelled to 0.
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expiry)).to.equal(0n);

      // Settling past expiry drains the zero entry without blowing up.
      await time.increaseTo(Number(expiry) + 1);
      await ConvictionStakingStorage.settleNodeTo(ALICE_ID, await latestTimestamp());
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(0n);
      expect(await ConvictionStakingStorage.getNodeExpiryHead(ALICE_ID)).to.equal(1n);
    });
  });

  // ------------------------------------------------------------
  // D20 — mutable tier ladder (unchanged admin surface)
  // ------------------------------------------------------------

  describe('Mutable tier ladder (v2.1.0)', () => {
    it('Seeds the full baseline ladder with exact wall-clock durations', async () => {
      const ids = await ConvictionStakingStorage.allTierIds();
      expect(ids.map((i) => Number(i)).sort((a, b) => a - b)).to.deep.equal([0, 1, 3, 6, 12]);
      expect(await ConvictionStakingStorage.tierCount()).to.equal(5);

      for (const tier of [0, 1, 3, 6, 12]) {
        const tc = await ConvictionStakingStorage.getTier(tier);
        expect(tc.exists).to.equal(true);
        expect(tc.active).to.equal(true);
        expect(tc.duration).to.equal(TIER_DURATIONS[tier]);
      }
    });

    it('HubOwner can append a new tier and createPosition picks it up', async () => {
      const newTier = 24;
      const newDuration = 500n * DAY; // <= MAX_TIER_DURATION (1024 * 12h = 512 days)
      const newMult = 12n * SCALE18;
      await expect(ConvictionStakingStorage.addTier(newTier, newDuration, newMult))
        .to.emit(ConvictionStakingStorage, 'TierAdded')
        .withArgs(newTier, newDuration, newMult);

      expect(await ConvictionStakingStorage.expectedMultiplier18(newTier)).to.equal(newMult);
      expect(await ConvictionStakingStorage.tierDuration(newTier)).to.equal(newDuration);

      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, newTier, 0, 0);
      const pos = await ConvictionStakingStorage.getPosition(1);
      expect(pos.lockTier).to.equal(newTier);
      expect(pos.multiplier18).to.equal(newMult);
      // Expected expiry = next half-day bucket after ts + newDuration.
      const tsNow = await latestTimestamp();
      expect(pos.expiryTimestamp).to.equal(bucketExpiry(tsNow + newDuration));
    });

    it('addTier rejects duplicates, degenerate tier-0 configs, and boost-less non-zero tiers', async () => {
      // Duplicate id.
      await expect(
        ConvictionStakingStorage.addTier(1, 30n * DAY, ONE_AND_HALF_X),
      ).to.be.revertedWith('Tier exists');
      // M3/M4 — non-zero tier with sub-1x multiplier is rejected as a degenerate
      // boost (would make the "big sum" == "raw sum" and break our fast-path
      // shortcuts in StakingV10._delegatorIncrementForEpoch).
      await expect(
        ConvictionStakingStorage.addTier(24, 366n * DAY, SCALE18 / 2n),
      ).to.be.revertedWith('Non-zero tier needs boost');
      // M3/M4 — non-zero tier with exactly 1x is rejected for the same reason.
      await expect(
        ConvictionStakingStorage.addTier(24, 366n * DAY, ONE_X),
      ).to.be.revertedWith('Non-zero tier needs boost');
      // M3/M4 — non-zero tier with duration == 0 is rejected (would install a
      // position whose expiryTimestamp == block.timestamp, i.e. already expired).
      await expect(
        ConvictionStakingStorage.addTier(24, 0n, TWO_X),
      ).to.be.revertedWith('Non-zero tier needs duration');
      // M3/M4 — tier 0 is reserved as the "rest state"; duration/mult must be
      // exactly (0, 1x) or we break the canonical rest-state contract.
      await expect(
        ConvictionStakingStorage.addTier(0, 1n, ONE_X),
      ).to.be.revertedWith('Tier exists');
    });

    it('Non-owner cannot addTier / deactivateTier', async () => {
      await expect(
        ConvictionStakingStorage.connect(accounts[1]).addTier(24, 100n, ONE_AND_HALF_X),
      ).to.be.reverted;
      await expect(ConvictionStakingStorage.connect(accounts[1]).deactivateTier(1)).to.be.reverted;
    });

    it('deactivateTier rejects fresh stakes but relock still honors original commitment', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 3, 0, 0);
      const expiry = expectedExpiryFrom(await latestTimestamp(), 3);
      await time.increaseTo(Number(expiry) + 1);
      await ConvictionStakingStorage.settleNodeTo(ALICE_ID, await latestTimestamp());

      await ConvictionStakingStorage.deactivateTier(3);
      await expect(
        ConvictionStakingStorage.createPosition(2, ALICE_ID, 1000, 3, 0, 0),
      ).to.be.revertedWith('Tier inactive');

      // L11 — existing holder can still relock into the deactivated tier via
      //       `createNewPositionFromExisting` (relocks are exempt from the
      //       active-tier check; only fresh `createPosition` enforces it).
      await expect(
        ConvictionStakingStorage.createNewPositionFromExisting(1, 2, ALICE_ID, 3),
      ).to.not.be.reverted;
    });
  });

  // ------------------------------------------------------------
  // CCO-8 — Code-review follow-ups (M2 / L5 / L7 / L9).
  // ------------------------------------------------------------

  describe('CCO-8 — code-review follow-up regressions', () => {
    it('M2 — cancel → reschedule at the same expiryTimestamp does not duplicate queue slots', async () => {
      // Create, cancel (deletePosition), then re-create another position with a
      // `createPosition` in the same block. Because `_scheduleNodeExpiry` uses
      // `nodeExpiryPresent` to dedupe, the queue should contain a SINGLE slot
      // with the combined drop from the surviving position only.
      await hre.network.provider.send('evm_setAutomine', [false]);
      try {
        await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
        await ConvictionStakingStorage.deletePosition(1);
        await ConvictionStakingStorage.createPosition(2, ALICE_ID, 1000, 12, 0, 0);
        await hre.network.provider.send('evm_mine', []);
      } finally {
        await hre.network.provider.send('evm_setAutomine', [true]);
      }

      const tsNow = await latestTimestamp();
      const expectedExpiry = expectedExpiryFrom(tsNow, 12);

      // There must be exactly one live entry — the delete+recreate MUST NOT
      // append a second slot at the same ts.
      const times = await ConvictionStakingStorage.getNodeExpiryTimes(ALICE_ID);
      expect(times.length).to.equal(1);
      expect(times[0]).to.equal(expectedExpiry);
      expect(
        await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expectedExpiry),
      ).to.equal(boostDrop(1000n, SIX_X));
    });

    it('L5 — cancelling the entire drop zeroes `nodeExpiryDrop` for a gas refund', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
      const expiry = expectedExpiryFrom(await latestTimestamp(), 12);
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expiry)).to.equal(
        boostDrop(1000n, SIX_X),
      );
      // `deletePosition` fully cancels the only pending drop.
      await ConvictionStakingStorage.deletePosition(1);
      // After cancellation the slot's drop is 0 (storage actually `delete`d,
      // which returns a gas refund and preserves the invariant that
      // `getNodeExpiryDrop` returns 0 for drained/cancelled entries).
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expiry)).to.equal(0n);
    });

    it('L7 — `getNodeEffectiveStakeAtEpoch` tolerates epoch >= horizon (Chronos boundary)', async () => {
      // Create a pre-expiry position and read its effective stake at a very
      // distant epoch. The getter must not revert even if Chronos returns 0
      // for `timestampForEpoch(epoch + 1)` (which is the "no bound" sentinel).
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, 0);
      const farFutureEpoch = 10_000n;
      // Defensive: the contract must not revert on out-of-horizon reads.
      await expect(
        ConvictionStakingStorage.getNodeEffectiveStakeAtEpoch(ALICE_ID, farFutureEpoch),
      ).to.not.be.reverted;
    });

    it('L9 — `setV10LaunchEpoch` is one-shot and rejects the second call', async () => {
      // Seeding a launch epoch succeeds once; subsequent calls must revert.
      await expect(ConvictionStakingStorage.setV10LaunchEpoch(5)).to.not.be.reverted;
      await expect(ConvictionStakingStorage.setV10LaunchEpoch(6)).to.be.revertedWith(
        'V10 launch already set',
      );
    });
  });

  // ------------------------------------------------------------
  // v4.1.0 — `expiryShortenedBy` (V8→V10 migration credit)
  // ------------------------------------------------------------

  describe('createPosition expiryShortenedBy (V8→V10 migration credit)', () => {
    const SIXTY_DAYS = 60n * DAY;

    it('Tier 12 with 60d credit: expiryTimestamp lands 60d before the tier default', async () => {
      // Migration migrationEpoch=1 to take the migration code path (skips
      // active-tier check); the credit is otherwise identical.
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 1, SIXTY_DAYS);
      const tsNow = await latestTimestamp();
      const tierDefault = expectedExpiryFrom(tsNow, 12);
      const expected = tierDefault - SIXTY_DAYS;

      const pos = await ConvictionStakingStorage.getPosition(1);
      expect(pos.expiryTimestamp).to.equal(expected);

      // The pre-expiry effective-stake invariants still hold; the credit
      // shortens the BOOST WINDOW but the boost magnitude (raw * mult) is
      // unchanged while the position is still pre-expiry.
      expect(await ConvictionStakingStorage.getNodeRunningEffectiveStake(ALICE_ID)).to.equal(
        effNow(1000n, SIX_X),
      );
      expect(await ConvictionStakingStorage.getNodeExpiryDrop(ALICE_ID, expected)).to.equal(
        boostDrop(1000n, SIX_X),
      );
    });

    it('preserves a non-bucket-aligned credit exactly — no silent re-bucket rounding', async () => {
      // `convictionCreditSeconds` (passed as expiryShortenedBy) can be arbitrary
      // seconds. The expiry is `bucketedDefault - credit` with NO re-bucket, so a
      // credit that is not a multiple of EXPIRY_BUCKET_SECONDS is applied to the
      // exact second. (Re-bucketing would round it UP to the 12h grid — e.g.
      // `60d - 1h` -> a full `60d` — quietly shrinking the configured credit.)
      const credit = SIXTY_DAYS - 3600n; // 60 days minus 1 hour (not a 12h multiple)
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 1, credit);
      const tsNow = await latestTimestamp();
      const pos = await ConvictionStakingStorage.getPosition(1);
      // exact: the bucketed default minus the FULL credit (not rounded back up).
      expect(pos.expiryTimestamp).to.equal(expectedExpiryFrom(tsNow, 12) - credit);
      // crucially NOT snapped onto the 12h grid:
      expect(pos.expiryTimestamp % (12n * 60n * 60n)).to.not.equal(0n);
    });

    it('Tier 6 with 60d credit: expiryTimestamp lands 60d before the tier default', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 2000, 6, 1, SIXTY_DAYS);
      const tsNow = await latestTimestamp();
      const expected = expectedExpiryFrom(tsNow, 6) - SIXTY_DAYS;

      const pos = await ConvictionStakingStorage.getPosition(1);
      expect(pos.expiryTimestamp).to.equal(expected);
    });

    it('Reverts when credit equals tier duration (zero remaining lock)', async () => {
      // Tier 1 = 30d. A 30d credit would zero out the lock entirely.
      await expect(
        ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 1, 1, TIER_DURATIONS[1]),
      ).to.be.revertedWith('Credit >= tier duration');
    });

    it('Reverts when credit exceeds tier duration', async () => {
      // 90d credit on a 30d tier — credit > duration, so no remaining lock.
      await expect(
        ConvictionStakingStorage.createPosition(
          1,
          ALICE_ID,
          1000,
          1,
          1,
          TIER_DURATIONS[1] + 1n,
        ),
      ).to.be.revertedWith('Credit >= tier duration');
    });

    it('Reverts when caller passes a non-zero credit on tier 0', async () => {
      // Tier 0 has no lock; any credit is meaningless.
      await expect(
        ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 0, 1, SIXTY_DAYS),
      ).to.be.revertedWith('Credit requires locked tier');
    });

    it('expiryShortenedBy = 0 is the no-op default and matches the legacy expiry', async () => {
      await ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 1, 0);
      const tsNow = await latestTimestamp();
      const pos = await ConvictionStakingStorage.getPosition(1);
      expect(pos.expiryTimestamp).to.equal(expectedExpiryFrom(tsNow, 12));
    });

    it('Reverts when a non-zero credit is passed with migrationEpoch == 0 (fresh-stake guard)', async () => {
      // The credit is V8→V10 migration-exclusive. Fresh `stake()` calls
      // (which pass `migrationEpoch == 0`) must never apply the bonus,
      // otherwise a future Hub-registered caller could mint a shortened
      // fresh stake just by passing a non-zero `expiryShortenedBy`. This
      // test pins the guard that ties the credit to the V8 drain path.
      await expect(
        ConvictionStakingStorage.createPosition(1, ALICE_ID, 1000, 12, 0, SIXTY_DAYS),
      ).to.be.revertedWith('Credit requires migrationEpoch');
    });
  });
});
