Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/lib/LibMigrationInvariant.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: LicenseRef-DCL-1.0
// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd
pragma solidity ^0.8.25;

/// @notice `actual` is neither the accepted pre-migration nor the accepted
/// post-migration state, and the migration deadline has not yet passed. The
/// live chain has drifted onto a value neither side of the migration expects.
/// @param label Human-readable identifier for the invariant being asserted
/// (e.g. `"STOX_RECEIPT_VAULT_BEACON_V1.owner()"`).
/// @param expectedPre The accepted state before the migration runs.
/// @param expectedPost The accepted state after the migration runs.
/// @param actual The value read from the live chain.
error MigrationStateDrift(string label, bytes32 expectedPre, bytes32 expectedPost, bytes32 actual);

/// @notice The migration deadline has passed but `actual` is still not the
/// post-migration value. Signals that either the script never ran on-chain
/// before `deadline`, or the deadline was set too aggressively. The invariant
/// deliberately red-lines cron CI in this state to force an explicit
/// operator choice: run the migration, extend the deadline, or delete the
/// invariant (accepting the pre-state as the new canonical).
/// @param label Human-readable identifier for the invariant being asserted.
/// @param expectedPost The accepted state after the migration runs.
/// @param actual The value read from the live chain.
/// @param deadline The unix timestamp past which only the post-state passes.
error MigrationDeadlinePassed(string label, bytes32 expectedPost, bytes32 actual, uint256 deadline);

/// @title LibMigrationInvariant
/// @notice Reusable dual-state invariant helper with an operator SLA baked
/// in. Encodes the pattern:
///
/// - The migration script mutates some on-chain value from `pre` to `post`.
/// - A live-fork invariant test asserts, against the head of the target
/// network, that the value is EITHER `pre` (script has not run yet) OR
/// `post` (script has run) — while `block.timestamp < deadline`.
/// - Once `block.timestamp >= deadline`, only `post` passes. If the script
/// has not landed on-chain by then, cron CI red-lines and forces the
/// operator to make an explicit choice — run the script, extend the
/// deadline, or delete the invariant (accepting `pre` as the new
/// canonical).
///
/// This lets the invariant test PR merge alongside the migration script
/// (rather than waiting until the script has actually executed on-chain),
/// giving the migration itself the same "cron would trip if we drifted"
/// enforcement every other production invariant has — even while the
/// migration is pending.
///
/// @dev `block.timestamp` is read once per call from the current chain. A
/// live-fork test at chain head sees real time, so cron picks up the
/// deadline transition automatically without any per-test warping.
library LibMigrationInvariant {
/// @notice Assert `actual` matches the migration acceptance window for
/// the current time. Before `deadline`: `actual` must be `pre` OR `post`.
/// At or after `deadline`: `actual` must be `post`.
/// @param label Human-readable identifier for the invariant surfaced in
/// revert data — pick something that unambiguously names the on-chain
/// slot being asserted (e.g. `"STOX_RECEIPT_VAULT_BEACON_V1.owner()"`).
/// @param actual The value read from the live chain.
/// @param pre The accepted state before the migration runs.
/// @param post The accepted state after the migration runs.
/// @param deadline Unix timestamp past which only `post` is accepted.
function assertMigration(string memory label, bytes32 actual, bytes32 pre, bytes32 post, uint256 deadline)
internal
view
{
if (block.timestamp >= deadline) {
if (actual != post) {
revert MigrationDeadlinePassed(label, post, actual, deadline);
}
} else if (actual != pre && actual != post) {
revert MigrationStateDrift(label, pre, post, actual);
}
}

/// @notice `address` overload. Casts each address to `bytes32` under the
/// hood via `uint160`.
/// @param label Human-readable identifier for the invariant surfaced in
/// revert data.
/// @param actual The value read from the live chain.
/// @param pre The accepted state before the migration runs.
/// @param post The accepted state after the migration runs.
/// @param deadline Unix timestamp past which only `post` is accepted.
function assertMigration(string memory label, address actual, address pre, address post, uint256 deadline)
internal
view
{
assertMigration(
label,
bytes32(uint256(uint160(actual))),
bytes32(uint256(uint160(pre))),
bytes32(uint256(uint160(post))),
deadline
);
}

/// @notice `uint256` overload. Casts each value to `bytes32` under the
/// hood.
/// @param label Human-readable identifier for the invariant surfaced in
/// revert data.
/// @param actual The value read from the live chain.
/// @param pre The accepted state before the migration runs.
/// @param post The accepted state after the migration runs.
/// @param deadline Unix timestamp past which only `post` is accepted.
function assertMigration(string memory label, uint256 actual, uint256 pre, uint256 post, uint256 deadline)
internal
view
{
assertMigration(label, bytes32(actual), bytes32(pre), bytes32(post), deadline);
}
}
66 changes: 66 additions & 0 deletions test/src/concrete/deploy/BeaconOwnerMigrationPin.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: LicenseRef-DCL-1.0
// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd
pragma solidity =0.8.25;

import {Test} from "forge-std-1.16.1/src/Test.sol";
import {Ownable} from "@openzeppelin-contracts-5.6.1/access/Ownable.sol";
import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol";
import {LibMigrationInvariant} from "../../../../src/lib/LibMigrationInvariant.sol";
import {LibProdDeployV1} from "../../../../src/lib/LibProdDeployV1.sol";
import {LibSafeInvariants} from "../../../../src/lib/LibSafeInvariants.sol";

/// @title BeaconOwnerMigrationPinTest
/// @notice Live-fork pin of the beacon-ownership migration executed by
/// `script/MigrateBeaconOwners.s.sol` (PR #196). Reads each of the three V1
/// beacons' `owner()` from live Base head and asserts, via
/// `LibMigrationInvariant`, that the value is either the pre-migration EOA
/// (`LibProdDeployV1.BEACON_INITIAL_OWNER`) or the post-migration Safe
/// (`LibSafeInvariants.STOX_TOKEN_OWNER_SAFE`) — up until
/// `BEACON_OWNER_MIGRATION_DEADLINE`. From that timestamp on only the Safe is
/// accepted; any beacon still EOA-owned at that point trips
/// `MigrationDeadlinePassed` and red-lines the cron.
///
/// @dev Uses an unpinned Base head fork so `block.timestamp` is real. Pinning
/// a block would freeze the deadline check to whichever timestamp the pinned
/// block carried, which is exactly the wrong behaviour for a deadline-gated
/// invariant.
///
/// When the migration lands on-chain the beacon owner reads flip from EOA
/// to Safe and this test transitions from the "pre acceptable" branch to the
/// "post required" branch without any code change. If the migration has not
/// landed by the deadline this test red-lines and forces an operator choice:
/// run the script, extend the deadline, or delete the invariant.
contract BeaconOwnerMigrationPinTest is Test {
/// @notice Unix timestamp past which only the Safe-owned post-state is
/// accepted — the operator-SLA cut-off for the beacon-owner migration.
/// `2026-09-01T00:00:00Z`. Past this instant the invariant demands the
/// migration has landed on-chain; a later PR can move it earlier to
/// tighten the forcing function or later to loosen it if the SLA shifts.
uint256 internal constant BEACON_OWNER_MIGRATION_DEADLINE = 1_788_220_800;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// @notice Assert the migration-window invariant on a single beacon.
function assertBeaconOwnerMigrationInvariant(address beacon, string memory label) internal view {
LibMigrationInvariant.assertMigration(
label,
Ownable(beacon).owner(),
LibProdDeployV1.BEACON_INITIAL_OWNER,
LibSafeInvariants.STOX_TOKEN_OWNER_SAFE,
BEACON_OWNER_MIGRATION_DEADLINE
);
}

/// @notice Each of the three V1 beacons `MigrateBeaconOwners` targets
/// is either still EOA-owned or already Safe-owned. Runs against Base
/// head so any drift into a third owner surfaces immediately, and the
/// deadline transition surfaces automatically on cron.
function testV1BeaconOwnersInMigrationWindow() external {
vm.createSelectFork(LibRainDeploy.BASE);
assertBeaconOwnerMigrationInvariant(LibProdDeployV1.STOX_RECEIPT_BEACON_V1, "STOX_RECEIPT_BEACON_V1.owner()");
assertBeaconOwnerMigrationInvariant(
LibProdDeployV1.STOX_RECEIPT_VAULT_BEACON_V1, "STOX_RECEIPT_VAULT_BEACON_V1.owner()"
);
assertBeaconOwnerMigrationInvariant(
LibProdDeployV1.STOX_WRAPPED_TOKEN_VAULT_BEACON_V1, "STOX_WRAPPED_TOKEN_VAULT_BEACON_V1.owner()"
);
}
Comment on lines +56 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check how fork-based tests are filtered/tagged in CI config.
rg -n "createSelectFork|BASE_RPC|forge test" --type=yaml -g '*.yml' -g '*.yaml'
fd -e toml -x cat -n {} \; | rg -n "fork|no_match"

Repository: S01-Issuer/st0x.deploy

Length of output: 160


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files =="
git ls-files | sed -n '1,240p'

echo
echo "== CI/workflow files =="
fd -a -t f .github .github/workflows .gitlab-ci.yml .circleci .azuredevops .drone.yml .buildkite | sed -n '1,240p' || true

echo
echo "== search for fork/test scheduling keywords =="
rg -n --hidden --glob '!**/.git/**' \
  "createSelectFork|schedule:|cron:|workflow_dispatch|forge test|no_match|fork|BASE_RPC|rpc|cron drift|drift" \
  .github foundry.toml . 2>/dev/null || true

Repository: S01-Issuer/st0x.deploy

Length of output: 39464


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== top-level files =="
find . -maxdepth 2 -type f | sed 's#^\./##' | sort | sed -n '1,240p'

echo
echo "== workflow files =="
find .github -maxdepth 3 -type f 2>/dev/null | sort | sed -n '1,240p' || true

echo
echo "== foundry config files =="
find . -maxdepth 3 -type f \( -name 'foundry.toml' -o -name 'remappings.txt' -o -name '*.toml' \) | sort | sed -n '1,240p'

Repository: S01-Issuer/st0x.deploy

Length of output: 974


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== foundry.toml =="
cat -n foundry.toml | sed -n '1,220p'

echo
echo "== .github/workflows/rainix-sol.yaml =="
cat -n .github/workflows/rainix-sol.yaml | sed -n '1,260p'

echo
echo "== .github/workflows/run-script.yaml =="
cat -n .github/workflows/run-script.yaml | sed -n '1,260p'

echo
echo "== .github/workflows/multisig-artifact.yaml =="
cat -n .github/workflows/multisig-artifact.yaml | sed -n '1,220p'

Repository: S01-Issuer/st0x.deploy

Length of output: 11658


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== git status summary =="
git status --short

echo
echo "== test file outline =="
ast-grep outline test/src/concrete/deploy/BeaconOwnerMigrationPin.t.sol --view expanded || true

Repository: S01-Issuer/st0x.deploy

Length of output: 279


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow trigger summary =="
python3 - <<'PY'
from pathlib import Path
import re, json
for p in sorted(Path('.github/workflows').glob('*.y*ml')):
    text = p.read_text()
    print(f"\n## {p}")
    for key in ["on:", "push:", "pull_request:", "workflow_dispatch:", "schedule:", "cron:"]:
        if key in text:
            print(f"contains {key}")
PY

Repository: S01-Issuer/st0x.deploy

Length of output: 640


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in .github/workflows/*.y*ml; do
  echo "== $f =="
  cat -n "$f" | sed -n '1,260p'
  echo
done

echo "== foundry.toml =="
cat -n foundry.toml | sed -n '1,220p'

Repository: S01-Issuer/st0x.deploy

Length of output: 16651


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== BeaconOwnerMigrationPin file =="
cat -n test/src/concrete/deploy/BeaconOwnerMigrationPin.t.sol | sed -n '1,220p'

Repository: S01-Issuer/st0x.deploy

Length of output: 4195


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== foundry.toml =="
cat -n foundry.toml | sed -n '1,240p'

echo
echo "== .github/workflows/rainix-sol.yaml =="
cat -n .github/workflows/rainix-sol.yaml | sed -n '1,260p'

Repository: S01-Issuer/st0x.deploy

Length of output: 3684


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== .github/workflows/run-script.yaml =="
cat -n .github/workflows/run-script.yaml | sed -n '1,260p'

echo
echo "== .github/workflows/multisig-artifact.yaml =="
cat -n .github/workflows/multisig-artifact.yaml | sed -n '1,220p'

Repository: S01-Issuer/st0x.deploy

Length of output: 8133


🌐 Web query:

rainlanguage/rainix .github/workflows/rainix-sol.yaml forge test reusable workflow

💡 Result:

In the rainlanguage/rainix ecosystem, rainix-sol.yaml is a reusable GitHub Actions workflow used to standardize Solidity development tasks across repositories [1][2]. It serves as a centralized composite workflow that simplifies CI configurations, such as running Foundry tests, static analysis, and other contract-related checks [1][2]. While there is a specific workflow file named rainix-sol-test.yaml often used for testing [3][2], the broader rainix-sol suite (often triggered via reusable workflows) encapsulates various Solidity tasks defined in the rainix flake [1]. Key technical details regarding its use include: 1. Reusability: Downstream repositories consume these workflows to avoid duplicating CI logic [1]. They replace per-task wrappers by consolidating them into a single, maintainable interface [2]. 2. Forge Integration: The workflows are designed to execute Foundry tasks defined in the rainix flake [1]. For example, nix run..#rainix-sol-test maps to forge test -vvv [1]. 3. Workflow Structure: The rainix-sol workflows integrate with GitHub Actions to provide standardized environments, often including caching mechanisms for Foundry build artifacts (cache/ and out/) to improve CI speed [3]. 4. Implementation: Consuming repositories typically invoke these via the jobs section in their local.github/workflows files, pointing to the reusable workflow in the rainix repository [4][2]. For current usage, developers should check the repository's specific.github/workflows directory or the official rainix documentation for the exact syntax required to call these reusable components in their CI pipelines [1][4].

Citations:


🌐 Web query:

site:github.com/rainlanguage/rainix "rainix-sol.yaml" "forge test"

💡 Result:

In the context of the rainlanguage/rainix repository, rainix-sol.yaml is a GitHub Actions workflow file that acts as a reusable workflow for Solidity projects [1][2]. It is specifically designed to run tasks related to Solidity development, such as testing and other CI/CD processes, within a Nix-based environment [3]. The file is commonly referenced in the context of rainix-sol-test.yaml, which is the reusable workflow responsible for executing forge test commands [1][2]. By using these reusable workflows, consuming repositories can standardize their CI processes—such as running forge test, static analysis, or artifact deployments—by pointing their own workflow files to the rainix-sol-* templates defined in the rainlanguage/rainix repository [1][3]. For example, a typical implementation in a consuming repository involves creating a workflow file that calls the remote reusable workflow: name: rainix-sol-test on: [push] jobs: test: uses: rainlanguage/rainix/.github/workflows/rainix-sol-test.yaml@main This setup ensures that the repository uses the standardized Nix derivations and environment configurations provided by the rainix project when running Foundry's forge test [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "rainix-sol|forge test|pull_request|workflow_dispatch|schedule:|cron:" README.md CLAUDE.md .github/workflows test src script

Repository: S01-Issuer/st0x.deploy

Length of output: 1833


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== README excerpt =="
cat -n README.md | sed -n '130,170p'

echo
echo "== CLAUDE excerpt =="
cat -n CLAUDE.md | sed -n '20,40p'

echo
echo "== search for this test file in docs/workflows =="
rg -n "BeaconOwnerMigrationPin|MigrateBeaconOwners|rainix-sol-test|full test suite|forge test" README.md CLAUDE.md .github/workflows test/script test/src

Repository: S01-Issuer/st0x.deploy

Length of output: 3648


🌐 Web query:

site:github.com/rainlanguage/rainix "rainix-sol.yaml" "rainix-sol-test" "full test suite"

💡 Result:

Rainix is a collection of Nix derivations designed to standardize development and CI workflows for Rain projects [1]. rainix-sol.yaml is a GitHub Actions workflow file (located at.github/workflows/rainix-sol.yaml) that serves as a single wrapper for Solidity-only repositories [2][1]. It fans out various tasks—specifically static analysis, legal compliance checking, and testing—to run in parallel on separate runners [2][1]. rainix-sol-test is a task provided by Rainix that executes the test suite for a project [3]. It is typically run via Nix using the command nix run..#rainix-sol-test (or similar, depending on the directory context) to execute forge test -vvv [3]. Within the CI pipeline, this task is associated with the.github/workflows/rainix-sol-test.yaml workflow file, which manages the execution of these tests, often including steps to cache Foundry build artifacts to improve performance [4]. While there is no single file literally named "full test suite," the term refers to the comprehensive validation performed by the rainix-sol-test task and its associated CI workflow, which ensures that all project contracts, artifacts, and compliance requirements are verified [2][4][3].

Citations:


🌐 Web query:

site:github.com/rainlanguage/rainix ".github/workflows/rainix-sol.yaml" "push" "forge test"

💡 Result:

The file ".github/workflows/rainix-sol.yaml" is a composite GitHub Actions workflow used in the "rainix" framework to streamline Solidity-only CI processes [1][2]. Instead of running all tasks sequentially in a single job, it fans out static analysis, legal compliance checks, and testing into parallel execution steps, with each running on its own dedicated runner [1][2]. Within the rainix ecosystem, the "forge test" functionality mentioned in your query is typically managed via specific Nix derivations defined in "flake.nix" [3] and documented for use in "CLAUDE.md" [4]. Specifically: - The task "rainix-sol-test" is used to execute "forge test -vvv" [4]. - This task is often invoked via "nix run..#rainix-sol-test" from a consuming repository [4]. - CI workflows (such as ".github/workflows/rainix-sol-test.yaml") utilize these underlying Nix derivations to ensure consistency between local development and continuous integration environments [5][4].

Citations:


🌐 Web query:

site:github.com/rainlanguage/rainix "nix develop -c rainix-sol-test" "forge test -vvv"

💡 Result:

The command "nix develop -c rainix-sol-test" is used within the Rainix environment (a set of Nix derivations for Solidity development) to execute the standard Solidity testing suite [1][2]. Specifically, "rainix-sol-test" is a configured task or script that typically executes "forge test -vvv" (or similar variations) to perform tests on a project [2][3]. In practice: - "nix develop -c rainix-sol-test" invokes the specified Rainix environment and runs the "rainix-sol-test" command inside it [2]. - The "rainix-sol-test" command is defined as a wrapper that runs "forge test" (often with verbosity flags such as -vvv) [2][3]. - This setup ensures that the development environment—including the necessary Solidity and Foundry toolchains—is correctly configured via Nix before running the tests [4][3]. You may also encounter "nix run..#rainix-sol-test" used in similar contexts to execute the same testing workflow [3]. These commands are designed to standardize testing across different environments by ensuring consistent tool versions and dependencies [4][5].

Citations:


Move this live-fork check out of the default push suite. The rainix-sol workflow runs on push and drives the full test suite, so this Base-head fork test will be hit on ordinary CI runs; a separate scheduled/manual job would avoid flaky RPC failures blocking unrelated merges.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/src/concrete/deploy/BeaconOwnerMigrationPin.t.sol` around lines 55 - 64,
Move the live-fork BeaconOwnerMigrationPin check out of the default push test
path so ordinary CI runs do not depend on Base-head RPC availability. Update the
test wiring around testV1BeaconOwnersInMigrationWindow in
BeaconOwnerMigrationPin.t.sol so it is excluded from the rainix-sol push suite
and only runs in a separate scheduled/manual job. Keep the invariant coverage
via assertBeaconOwnerMigrationInvariant and the LibProdDeployV1 beacon
constants, but relocate how this test is invoked.

}
167 changes: 167 additions & 0 deletions test/src/lib/LibMigrationInvariant.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// SPDX-License-Identifier: LicenseRef-DCL-1.0
// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd
pragma solidity =0.8.25;

import {Test} from "forge-std-1.16.1/src/Test.sol";
import {
LibMigrationInvariant,
MigrationStateDrift,
MigrationDeadlinePassed
} from "../../../src/lib/LibMigrationInvariant.sol";
import {LibMigrationInvariantHarness} from "./LibMigrationInvariantHarness.sol";

/// @title LibMigrationInvariantTest
/// @notice Behavioural coverage of the migration invariant helper: both
/// acceptance branches before the deadline, the exact-deadline boundary, the
/// two enforcement branches after the deadline, and the two typed errors it
/// surfaces. Each overload (`bytes32` / `address` / `uint256`) exercises the
/// same underlying decision, so the address and uint256 overloads only need
/// one round-trip test each to prove the cast — the exhaustive branch
/// coverage lives on the `bytes32` overload.
///
/// @dev `block.timestamp` is warped explicitly per test rather than forking a
/// live chain: the helper's decision surface is pure `block.timestamp`
/// comparisons, so cheatcode warping is the tightest way to exercise every
/// branch without pulling in fork state that is irrelevant to what is being
/// tested.
contract LibMigrationInvariantTest is Test {
LibMigrationInvariantHarness internal harness;

string internal constant LABEL = "test.invariant";
bytes32 internal constant PRE = bytes32(uint256(0xAAAA));
bytes32 internal constant POST = bytes32(uint256(0xBBBB));
bytes32 internal constant OTHER = bytes32(uint256(0xCCCC));
uint256 internal constant DEADLINE = 2_000_000_000;

function setUp() external {
harness = new LibMigrationInvariantHarness();
}

/// @notice Before the deadline, `actual == pre` is accepted: the script
/// has not yet run and the chain still reports the pre-migration value.
function testBeforeDeadlineAcceptsPre() external {
vm.warp(DEADLINE - 1);
harness.callAssertMigrationBytes32(LABEL, PRE, PRE, POST, DEADLINE);
}

/// @notice Before the deadline, `actual == post` is accepted: the script
/// has already run and the chain now reports the post-migration value.
function testBeforeDeadlineAcceptsPost() external {
vm.warp(DEADLINE - 1);
harness.callAssertMigrationBytes32(LABEL, POST, PRE, POST, DEADLINE);
}

/// @notice Before the deadline, `actual` matching neither `pre` nor
/// `post` trips `MigrationStateDrift` — the chain has landed on a value
/// the migration does not anticipate on either side of the transition.
function testBeforeDeadlineRejectsOtherWithDrift() external {
vm.warp(DEADLINE - 1);
vm.expectRevert(abi.encodeWithSelector(MigrationStateDrift.selector, LABEL, PRE, POST, OTHER));
harness.callAssertMigrationBytes32(LABEL, OTHER, PRE, POST, DEADLINE);
}

/// @notice At exactly the deadline the helper flips to strict
/// enforcement — `post` still passes.
function testAtDeadlineAcceptsPost() external {
vm.warp(DEADLINE);
harness.callAssertMigrationBytes32(LABEL, POST, PRE, POST, DEADLINE);
}

/// @notice At exactly the deadline the helper flips to strict
/// enforcement — `pre` no longer passes, trips
/// `MigrationDeadlinePassed`. This is the forcing-function on the
/// operator: run the migration or make an explicit choice.
function testAtDeadlineRejectsPreWithDeadlinePassed() external {
vm.warp(DEADLINE);
vm.expectRevert(abi.encodeWithSelector(MigrationDeadlinePassed.selector, LABEL, POST, PRE, DEADLINE));
harness.callAssertMigrationBytes32(LABEL, PRE, PRE, POST, DEADLINE);
}

/// @notice After the deadline, `actual == post` still passes.
function testAfterDeadlineAcceptsPost() external {
vm.warp(DEADLINE + 1);
harness.callAssertMigrationBytes32(LABEL, POST, PRE, POST, DEADLINE);
}

/// @notice After the deadline, `actual == pre` trips
/// `MigrationDeadlinePassed` — the pre-state grace window has closed.
function testAfterDeadlineRejectsPreWithDeadlinePassed() external {
vm.warp(DEADLINE + 1);
vm.expectRevert(abi.encodeWithSelector(MigrationDeadlinePassed.selector, LABEL, POST, PRE, DEADLINE));
harness.callAssertMigrationBytes32(LABEL, PRE, PRE, POST, DEADLINE);
}

/// @notice After the deadline, any drift trips
/// `MigrationDeadlinePassed` (not `MigrationStateDrift`) — the strict-
/// enforcement branch is unconditional.
function testAfterDeadlineRejectsOtherWithDeadlinePassed() external {
vm.warp(DEADLINE + 1);
vm.expectRevert(abi.encodeWithSelector(MigrationDeadlinePassed.selector, LABEL, POST, OTHER, DEADLINE));
harness.callAssertMigrationBytes32(LABEL, OTHER, PRE, POST, DEADLINE);
}

/// @notice An unset (`deadline == 0`) deadline resolves to the
/// restrictive outcome: it reads as already-passed, so only `post` is
/// accepted and `pre` trips `MigrationDeadlinePassed`. Pins the fail-safe
/// reading of an uninitialized SLA — guards against a future "0 means no
/// deadline / accept `pre` forever" misinterpretation, which would flip
/// the sentinel from fail-closed to fail-open.
function testZeroDeadlineIsStrict() external {
// A realistic non-zero chain time; the zero deadline must still
// enforce strictly rather than open an unbounded grace window.
vm.warp(DEADLINE);
harness.callAssertMigrationBytes32(LABEL, POST, PRE, POST, 0);
vm.expectRevert(abi.encodeWithSelector(MigrationDeadlinePassed.selector, LABEL, POST, PRE, 0));
harness.callAssertMigrationBytes32(LABEL, PRE, PRE, POST, 0);
}

/// @notice The `address` overload round-trips through the same
/// decision — one before-deadline pre acceptance is enough to prove the
/// `bytes32(uint256(uint160(...)))` cast lands where it should.
function testAddressOverloadRoundTripsPre() external {
vm.warp(DEADLINE - 1);
address pre = address(0x1111111111111111111111111111111111111111);
address post = address(0x2222222222222222222222222222222222222222);
harness.callAssertMigrationAddress(LABEL, pre, pre, post, DEADLINE);
}

/// @notice The `address` overload surfaces the same
/// `MigrationStateDrift` selector as the `bytes32` overload when the
/// value matches neither side — the byte layout of the emitted
/// revert data is unaffected by which overload was called.
function testAddressOverloadDriftSurfacesSelector() external {
vm.warp(DEADLINE - 1);
address pre = address(0x1111111111111111111111111111111111111111);
address post = address(0x2222222222222222222222222222222222222222);
address other = address(0x3333333333333333333333333333333333333333);
vm.expectRevert(
abi.encodeWithSelector(
MigrationStateDrift.selector,
LABEL,
bytes32(uint256(uint160(pre))),
bytes32(uint256(uint160(post))),
bytes32(uint256(uint160(other)))
)
);
harness.callAssertMigrationAddress(LABEL, other, pre, post, DEADLINE);
}

/// @notice The `uint256` overload round-trips through the same decision.
function testUint256OverloadRoundTripsPre() external {
vm.warp(DEADLINE - 1);
harness.callAssertMigrationUint256(LABEL, 1, 1, 3, DEADLINE);
}

/// @notice The `uint256` overload surfaces the same
/// `MigrationDeadlinePassed` selector as the `bytes32` overload after
/// the deadline has passed.
function testUint256OverloadDeadlinePassedSurfacesSelector() external {
vm.warp(DEADLINE + 1);
vm.expectRevert(
abi.encodeWithSelector(
MigrationDeadlinePassed.selector, LABEL, bytes32(uint256(3)), bytes32(uint256(1)), DEADLINE
)
);
harness.callAssertMigrationUint256(LABEL, 1, 1, 3, DEADLINE);
}
}
43 changes: 43 additions & 0 deletions test/src/lib/LibMigrationInvariantHarness.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: LicenseRef-DCL-1.0
// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd
pragma solidity =0.8.25;

import {LibMigrationInvariant} from "../../../src/lib/LibMigrationInvariant.sol";

/// @title LibMigrationInvariantHarness
/// @notice External-call shim around the internal library so `vm.expectRevert`
/// can intercept the typed errors. `vm.expectRevert` only catches reverts from
/// external calls; library `internal` functions inline and would fail the
/// depth check otherwise. One `call*` per overload so each `bytes32` /
/// `address` / `uint256` signature is exercised end-to-end.
contract LibMigrationInvariantHarness {
function callAssertMigrationBytes32(
string memory label,
bytes32 actual,
bytes32 pre,
bytes32 post,
uint256 deadline
) external view {
LibMigrationInvariant.assertMigration(label, actual, pre, post, deadline);
}

function callAssertMigrationAddress(
string memory label,
address actual,
address pre,
address post,
uint256 deadline
) external view {
LibMigrationInvariant.assertMigration(label, actual, pre, post, deadline);
}

function callAssertMigrationUint256(
string memory label,
uint256 actual,
uint256 pre,
uint256 post,
uint256 deadline
) external view {
LibMigrationInvariant.assertMigration(label, actual, pre, post, deadline);
}
}
Loading