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
19 changes: 17 additions & 2 deletions .github/workflows/run-script.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ on:
- 20260623-upgrade-receipt-vaults-to-v4
- 20260722-swap-rklb-authoriser
- 20260722-swap-remaining-vault-authorisers
- 20260723-provision-additional-service-signer
sig:
description: 'Entrypoint to dispatch (default: run())'
required: true
Expand All @@ -44,6 +45,18 @@ on:
# JSON path argument this dispatcher can't supply and runs off-chain
# on the signer's machine, not in CI.
- 'run()'
network:
description: 'Network whose authoriser/Safe the artifact targets (default: base)'
required: true
type: choice
default: 'base'
options:
# The chain to fork when authoring — the foundry.toml
# `[rpc_endpoints]` alias. Chain-aware scripts author for
# whichever network is selected; chain-pinned scripts simply
# revert on the wrong network.
- 'base'
- 'ethereum'
# Manually dispatches an operational script from `script/` and uploads any
# JSON it writes to `out/` as a build artifact.
#
Expand Down Expand Up @@ -72,7 +85,7 @@ jobs:
# Serialise dispatches of the same script+sig so overlapping runs don't
# race on the shared out/ artifacts; distinct entrypoints run in parallel.
concurrency:
group: run-script-${{ inputs.script }}-${{ inputs.sig }}
group: run-script-${{ inputs.script }}-${{ inputs.sig }}-${{ inputs.network }}
cancel-in-progress: false
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
Expand All @@ -84,15 +97,17 @@ jobs:
- name: Run script
env:
BASE_RPC_URL: ${{ secrets.RPC_URL_BASE_FORK }}
ETHEREUM_RPC_URL: ${{ secrets.RPC_URL_ETHEREUM_FORK }}
# Pass the choice inputs via env rather than template-expanding them
# into the command, so a dispatch (even one crafted via the API) is
# used as a literal argument and cannot inject shell.
SCRIPT: ${{ inputs.script }}
SIG: ${{ inputs.sig }}
NETWORK: ${{ inputs.network }}
run: |
nix develop --command forge script "script/${SCRIPT}.s.sol" \
--sig "${SIG}" \
--rpc-url base \
--rpc-url "${NETWORK}" \
--no-storage-caching
- name: Upload script output
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
Expand Down
2 changes: 1 addition & 1 deletion script/20260619-deploy-v4-authoriser-clone.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ contract DeployV4AuthoriserClone is Script {
uint256 internal constant MIRROR_START_INDEX = 7;

/// @notice The number of non-admin grants this script mirrors in.
uint256 internal constant MIRROR_COUNT = 6;
uint256 internal constant MIRROR_COUNT = 9;

/// @notice The number of `_ADMIN` roles the base + ST0x-override
/// `initialize` auto-grant to `initialAdmin` (five base + two
Expand Down
287 changes: 287 additions & 0 deletions script/20260723-provision-additional-service-signer.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
// SPDX-License-Identifier: LicenseRef-DCL-1.0
// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd
pragma solidity =0.8.25;

import {Script} from "forge-std-1.16.1/src/Script.sol";
import {console2} from "forge-std-1.16.1/src/console2.sol";

import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol";
import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol";
import {LibAuthoriserInvariants, RoleGrant} from "../src/lib/LibAuthoriserInvariants.sol";
import {LibProdDeployV4} from "../src/generated/LibProdDeployV4.sol";
import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol";
import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol";

/// @notice The additional service signer already holds every canonical
/// additional grant on this chain — the provisioning has executed and there
/// is nothing left to author.
error AdditionalSignerAlreadyProvisioned();

/// @notice The active chain has no usable V4 authoriser (unsupported chain,
/// unhydrated pin, no code, or codehash drift).
/// @param authoriser The authoriser address inspected (`address(0)` when
/// the chain's pin is unhydrated).
error AuthoriserNotReadyForProvisioning(address authoriser);

/// @notice No V4 authoriser pin exists for the active chain at all.
/// @param chainId The unsupported chain id.
error UnsupportedChainForProvisioning(uint256 chainId);

/// @notice The Safe does not hold the `_ADMIN` role that admins one of the
/// provisioned roles — the grant would revert inside the Safe tx.
/// @param adminRole The missing admin role.
error SafeMissingRoleAdmin(bytes32 adminRole);

/// @title ProvisionAdditionalServiceSigner
/// @notice **EXECUTED — verified 2026-08-05 (Base and Ethereum).** Authors
/// the Safe bundle that provisions the ADDITIONAL service signer
/// (`LibAuthoriserInvariants.GRANTEE_SERVICE_3D0C`) on the ACTIVE chain's
/// V4 authoriser with the canonical post-ceremony grants
/// (`additionalServiceGrants()`: `DEPOSIT` / `WITHDRAW` / `CERTIFY` — the
/// same three action roles the original service signer holds; both signers
/// are active side by side, nothing is revoked). Dispatch via
/// `Actions → run-script` with
/// `script = 20260723-provision-additional-service-signer`, `sig = run()`,
/// and the target `network`; one dispatch + Safe signing per chain carrying
/// a live authoriser. Base and Ethereum are the only chains this script
/// dispatches against — `activeChainAuthoriser()` reverts
/// `UnsupportedChainForProvisioning` for every other chain id, HyperEVM
/// included, which needs a branch here once its authoriser bootstraps.
/// Both supported chains hold every canonical pair, so re-dispatching
/// reverts `AdditionalSignerAlreadyProvisioned` — the state
/// `20260723-provision-additional-service-signer.prod.t.sol` pins per
/// chain.
///
/// The canonical pairs are the `GRANTEE_SERVICE_3D0C` rows of
/// `LibAuthoriserInvariants.expectedGrants()` — the single current-state
/// map every live invariant asserts (the cross-chain parity authoriser
/// leg, the multichain production-state bundle, the per-chain prod pins) —
/// so each chain is RED on this signer's rows until this bundle executes
/// there, and guarded against drift thereafter.
///
/// SELF-SCOPING: only the pairs the signer does not yet hold are authored
/// (partial execution recovers by re-dispatch); a fully provisioned chain
/// refuses to author an empty bundle.
///
/// @dev Flow: pre-flight (Safe state via the shared chain-aware entry
/// point; authoriser pinned + deployed + pinned codehash; the ceremony
/// grant map intact; Safe holds the `_ADMIN` role for every provisioned
/// role) -> build one `grantRole` tx per missing pair -> SafeTxHash against
/// the live nonce -> simulate as the Safe -> post-state (every additional
/// pair holds; ceremony map untouched; Safe identity + threshold
/// unchanged) -> artifact to
/// `out/20260723-additional-service-signer.json` -> n+1 walk proving the
/// provisioning is REVERSIBLE (revoke sits under the same `_ADMIN` the
/// Safe holds): revoke the first role under the live threshold, then
/// re-grant so the simulated fork ends provisioned.
contract ProvisionAdditionalServiceSigner is Script {
/// @notice The additional service signer being provisioned — the
/// canonical pin from the invariant lib.
address internal constant ADDITIONAL_SIGNER = LibAuthoriserInvariants.GRANTEE_SERVICE_3D0C;

/// @notice Human-readable name embedded in the emitted Tx Builder JSON's
/// `meta.name`. Visible to signers in the Safe Tx Builder UI.
string internal constant BUNDLE_NAME = "ST0x provision: additional service signer on the V4 authoriser";

/// @notice Output path (relative to the project root) for the Tx Builder
/// JSON artifact.
string internal constant ARTIFACT_PATH = "out/20260723-additional-service-signer.json";

/// @notice The `_ADMIN` role that admins `role` under the authoriser's
/// hierarchy: `<ROLE>` is admined by `<ROLE>_ADMIN`.
/// @dev Hardcoding the mapping is safe rather than lucky. The hierarchy
/// is written by `_setRoleAdmin` inside the authoriser's `initialize`
/// and nowhere else — OpenZeppelin's `_setRoleAdmin` is `internal` and
/// `AccessControlUpgradeable` exposes no external setter — so a given
/// implementation's mapping is fixed at initialisation and immutable
/// thereafter. `activeChainAuthoriser()` asserts the clone's runtime
/// codehash equals the pinned EIP-1167 runtime embedding the audited
/// 0.1.1 implementation, which is therefore proof of WHICH mapping the
/// clone carries. A live `getRoleAdmin` read would be re-deriving what
/// the codehash pin already establishes.
/// @param role The action role.
/// @return adminRole The role's admin role.
function roleAdminOf(bytes32 role) internal pure returns (bytes32 adminRole) {
if (role == keccak256("DEPOSIT")) return keccak256("DEPOSIT_ADMIN");
if (role == keccak256("WITHDRAW")) return keccak256("WITHDRAW_ADMIN");
if (role == keccak256("CERTIFY")) return keccak256("CERTIFY_ADMIN");
revert("ProvisionAdditionalServiceSigner: no admin mapping for role");
}

/// @notice The active chain's hydrated V4 authoriser, asserted deployed
/// with the shared EIP-1167 codehash.
/// @return authoriser The validated authoriser address.
function activeChainAuthoriser() internal view returns (address authoriser) {
if (block.chainid == LibSafeInvariants.BASE_CHAIN_ID) {
authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE;
} else if (block.chainid == LibSafeInvariants.ETHEREUM_CHAIN_ID) {
authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM;
} else {
revert UnsupportedChainForProvisioning(block.chainid);
}
if (
authoriser == address(0) || authoriser.code.length == 0
|| authoriser.codehash != LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH
) {
revert AuthoriserNotReadyForProvisioning(authoriser);
}
}

/// @notice Pre-flight the authoriser's grant map and self-scope the
/// bundle: the signer's canonical pairs, plus one `grantRole`
/// transaction per pair the signer does not yet hold.
///
/// Reverts when the map has drifted (any non-signer row missing), when
/// the Safe does not admin a provisioned role, or when every pair
/// already holds (`AdditionalSignerAlreadyProvisioned` — nothing left
/// to author).
///
/// @dev Every chain-specific input arrives as an argument rather than
/// being read from `block.chainid`, so the selection is exercisable
/// against ANY authoriser carrying the canonical map — a locally
/// cloned one in the unit suite as readily as a pinned production
/// clone on a fork.
/// @param authoriser The authoriser whose live role state is read.
/// @param safeAddr The chain's token-owner Safe: fills the Safe
/// grantee slots of the canonical map and holds every `_ADMIN` role.
/// @return additional The signer's canonical pairs, in map order.
/// @return txs One `grantRole` tx per pair the signer does not hold,
/// in map order.
function authorBundle(address authoriser, address safeAddr)
internal
view
returns (RoleGrant[] memory additional, SafeTx[] memory txs)
{
IAccessControl acl = IAccessControl(authoriser);

// Split the canonical map: the additional signer's rows are the
// work items; every OTHER row must already hold — the signer must
// never be provisioned on an authoriser whose configuration has
// drifted.
RoleGrant[] memory all = LibAuthoriserInvariants.expectedGrants(safeAddr);
uint256 pairCount = 0;
for (uint256 i = 0; i < all.length; i++) {
if (all[i].grantee == ADDITIONAL_SIGNER) {
pairCount++;
continue;
}
require(
acl.hasRole(all[i].role, all[i].grantee),
"ProvisionAdditionalServiceSigner: authoriser grant map has drifted"
);
}
additional = new RoleGrant[](pairCount);
uint256 p = 0;
for (uint256 i = 0; i < all.length; i++) {
if (all[i].grantee == ADDITIONAL_SIGNER) {
additional[p] = all[i];
p++;
}
}

// The Safe holds the `_ADMIN` role for every provisioned role.
for (uint256 i = 0; i < additional.length; i++) {
bytes32 adminRole = roleAdminOf(additional[i].role);
if (!acl.hasRole(adminRole, safeAddr)) {
revert SafeMissingRoleAdmin(adminRole);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Self-scope to the pairs not yet held.
bool[] memory missing = new bool[](additional.length);
uint256 count = 0;
for (uint256 i = 0; i < additional.length; i++) {
if (!acl.hasRole(additional[i].role, additional[i].grantee)) {
missing[i] = true;
count++;
}
}
if (count == 0) {
revert AdditionalSignerAlreadyProvisioned();
}

txs = new SafeTx[](count);
uint256 t = 0;
for (uint256 i = 0; i < additional.length; i++) {
if (!missing[i]) continue;
txs[t] = SafeTx({
to: authoriser,
value: 0,
data: abi.encodeCall(IAccessControl.grantRole, (additional[i].role, additional[i].grantee)),
operation: 0
});
t++;
}
}

/// @notice Author the provisioning bundle for the active chain: see the
/// contract-level flow. Does not broadcast — execution happens via the
/// Safe UI using the emitted artifact.
function run() external {
// --- Pre-flight ---------------------------------------------------

address safeAddr = LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid);
IGnosisSafe safe = IGnosisSafe(safeAddr);

address authoriser = activeChainAuthoriser();
IAccessControl acl = IAccessControl(authoriser);

// --- Build the bundle ----------------------------------------------

(RoleGrant[] memory additional, SafeTx[] memory txs) = authorBundle(authoriser, safeAddr);

uint256 nonce = safe.nonce();
bytes32 firstSafeTxHash = LibSafeOps.computeSafeTxHashViaSafe(safe, txs[0], nonce);

// --- Simulate -----------------------------------------------------

for (uint256 i = 0; i < txs.length; i++) {
LibSafeOps.simulateExternalCall(safe, txs[i].to, txs[i].data);
}

// --- Post-state ---------------------------------------------------

// The full current-state map holds — the exact bundle
// `assertExpectedGrants` enforces in CI — and the Safe identity +
// threshold are unchanged.
LibAuthoriserInvariants.assertExpectedGrants(authoriser, safeAddr);
LibSafeInvariants.assertImmutableInvariants(safe);
LibSafeInvariants.assertThreshold(safe, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD);

// --- Artifact -----------------------------------------------------

string memory json = LibSafeOps.emitTxBuilderJson(safeAddr, block.chainid, BUNDLE_NAME, txs);
vm.writeFile(ARTIFACT_PATH, json);

console2.log("==== TX BUILDER JSON BEGIN ====");
console2.log(json);
console2.log("==== TX BUILDER JSON END ====");
console2.log("First-tx SafeTxHash:", vm.toString(firstSafeTxHash));
console2.log("Nonce:", nonce);
console2.log("Bundle item count:", txs.length);
console2.log("Chain:", block.chainid);
console2.log("Authoriser:", authoriser);
console2.log("Additional signer:", ADDITIONAL_SIGNER);

// --- n+1 reversal proof --------------------------------------------

// The provisioning is fully reversible (revoke sits under the same
// `_ADMIN` the Safe holds). Prove the revoke clears the live
// threshold on the first role, then re-grant so the fork ends
// provisioned.
LibSafeOps.simulateNPlus1(
safe,
authoriser,
abi.encodeCall(IAccessControl.revokeRole, (additional[0].role, additional[0].grantee)),
LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD
);
require(
!acl.hasRole(additional[0].role, additional[0].grantee),
"ProvisionAdditionalServiceSigner: n+1 revoke did not remove the role"
);
LibSafeOps.simulateExternalCall(
safe, authoriser, abi.encodeCall(IAccessControl.grantRole, (additional[0].role, additional[0].grantee))
);
console2.log("n+1 reversal check passed: the Safe can revoke (and re-grant) under the live threshold");
}
}
Loading
Loading