From da0c4dc4be108306e215a0f4637c0a2c42946668 Mon Sep 17 00:00:00 2001
From: Piotr Roslaniec
Date: Sun, 2 Nov 2025 08:41:13 +0000
Subject: [PATCH 01/74] feat: extend bank balance authorization
---
solidity/.eslintrc | 3 +-
.../IBridgeMintingAuthorization.sol | 32 +++
solidity/contracts/bridge/Bridge.sol | 88 ++++++--
.../contracts/bridge/BridgeGovernance.sol | 19 +-
solidity/contracts/bridge/BridgeState.sol | 7 +-
.../deploy/99_configure_bridge_controllers.ts | 17 ++
.../utils/bridge-controller-authorization.ts | 208 ++++++++++++++++++
solidity/fix-lint.js | 2 +
.../test/bridge/Bridge.Governance.test.ts | 153 ++++++++++++-
solidity/test/fixtures/bridge.ts | 8 +-
.../integrator/AbstractBTCRedeemer.test.ts | 5 +-
11 files changed, 513 insertions(+), 29 deletions(-)
create mode 100644 solidity/contracts/account-control/interfaces/IBridgeMintingAuthorization.sol
create mode 100644 solidity/deploy/99_configure_bridge_controllers.ts
create mode 100644 solidity/deploy/utils/bridge-controller-authorization.ts
diff --git a/solidity/.eslintrc b/solidity/.eslintrc
index c002c8a81..f9013eabf 100644
--- a/solidity/.eslintrc
+++ b/solidity/.eslintrc
@@ -13,7 +13,8 @@
"new-cap": "off",
"import/no-extraneous-dependencies": "off",
"@typescript-eslint/no-use-before-define": "off",
- "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }]
+ "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
+ "func-names": "off"
},
"overrides": [
{
diff --git a/solidity/contracts/account-control/interfaces/IBridgeMintingAuthorization.sol b/solidity/contracts/account-control/interfaces/IBridgeMintingAuthorization.sol
new file mode 100644
index 000000000..26ee3d783
--- /dev/null
+++ b/solidity/contracts/account-control/interfaces/IBridgeMintingAuthorization.sol
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: GPL-3.0-only
+
+// ██████████████ ▐████▌ ██████████████
+// ██████████████ ▐████▌ ██████████████
+// ▐████▌ ▐████▌
+// ▐████▌ ▐████▌
+// ██████████████ ▐████▌ ██████████████
+// ██████████████ ▐████▌ ██████████████
+// ▐████▌ ▐████▌
+// ▐████▌ ▐████▌
+// ▐████▌ ▐████▌
+// ▐████▌ ▐████▌
+// ▐████▌ ▐████▌
+// ▐████▌ ▐████▌
+
+pragma solidity 0.8.17;
+
+/// @notice Minimal Bridge surface consumed by AccountControl for minting.
+interface IBridgeMintingAuthorization {
+ function controllerIncreaseBalance(address recipient, uint256 amount)
+ external;
+
+ function controllerIncreaseBalances(
+ address[] calldata recipients,
+ uint256[] calldata amounts
+ ) external;
+
+ function authorizedBalanceIncreasers(address increaser)
+ external
+ view
+ returns (bool);
+}
diff --git a/solidity/contracts/bridge/Bridge.sol b/solidity/contracts/bridge/Bridge.sol
index 0db637962..dd70b3d54 100644
--- a/solidity/contracts/bridge/Bridge.sol
+++ b/solidity/contracts/bridge/Bridge.sol
@@ -186,6 +186,11 @@ contract Bridge is
bool isTrusted
);
+ event AuthorizedBalanceIncreaserUpdated(
+ address indexed increaser,
+ bool authorized
+ );
+
event DepositParametersUpdated(
uint64 depositDustThreshold,
uint64 depositTreasuryFeeDivisor,
@@ -1201,6 +1206,20 @@ contract Bridge is
);
}
+ /// @notice Allows Governance to manage contracts authorized to request
+ /// Bank balance increases through the Bridge.
+ /// @param increaser Address of the contract requesting authorization.
+ /// @param authorized Whether the address should be authorized.
+ function setAuthorizedBalanceIncreaser(address increaser, bool authorized)
+ external
+ onlyGovernance
+ {
+ require(increaser != address(0), "Increaser address must not be 0x0");
+
+ self.authorizedBalanceIncreasers[increaser] = authorized;
+ emit AuthorizedBalanceIncreaserUpdated(increaser, authorized);
+ }
+
/// @notice Allows the Governance to mark the given vault address as trusted
/// or no longer trusted. Vaults are not trusted by default.
/// Trusted vault must meet the following criteria:
@@ -1541,6 +1560,49 @@ contract Bridge is
self.updateTreasury(treasury);
}
+ /// @notice Allows authorized controllers to increase Bank balances via the
+ /// Bridge.
+ /// @param recipient Address receiving the balance increase.
+ /// @param amount Amount by which the balance is increased.
+ function controllerIncreaseBalance(address recipient, uint256 amount)
+ external
+ {
+ require(
+ self.authorizedBalanceIncreasers[msg.sender],
+ "Caller is not an authorized increaser"
+ );
+ self.bank.increaseBalance(recipient, amount);
+ }
+
+ /// @notice Allows authorized controllers to increase multiple Bank
+ /// balances via the Bridge.
+ /// @param recipients Addresses receiving the balance increases.
+ /// @param amounts Amounts by which balances are increased.
+ function controllerIncreaseBalances(
+ address[] calldata recipients,
+ uint256[] calldata amounts
+ ) external {
+ require(
+ self.authorizedBalanceIncreasers[msg.sender],
+ "Caller is not an authorized increaser"
+ );
+ self.bank.increaseBalances(recipients, amounts);
+ }
+
+ /// @notice Sets the redemption watchtower address.
+ /// @param redemptionWatchtower Address of the redemption watchtower.
+ /// @dev Requirements:
+ /// - The caller must be the governance,
+ /// - Redemption watchtower address must not be already set,
+ /// - Redemption watchtower address must not be 0x0.
+ function setRedemptionWatchtower(address redemptionWatchtower)
+ external
+ onlyGovernance
+ {
+ // The internal function is defined in the `BridgeState` library.
+ self.setRedemptionWatchtower(redemptionWatchtower);
+ }
+
/// @notice Collection of all revealed deposits indexed by
/// keccak256(fundingTxHash | fundingOutputIndex).
/// The fundingTxHash is bytes32 (ordered as in Bitcoin internally)
@@ -1674,6 +1736,17 @@ contract Bridge is
return self.isVaultTrusted[vault];
}
+ /// @notice Indicates if the address is authorized to request Bank balance
+ /// increases through the Bridge.
+ /// @param increaser Address to check.
+ function authorizedBalanceIncreasers(address increaser)
+ external
+ view
+ returns (bool)
+ {
+ return self.authorizedBalanceIncreasers[increaser];
+ }
+
/// @notice Returns the current values of Bridge deposit parameters.
/// @return depositDustThreshold The minimal amount that can be requested
/// to deposit. Value of this parameter must take into account the
@@ -1947,21 +2020,6 @@ contract Bridge is
return self.txProofDifficultyFactor;
}
- /// @notice Sets the redemption watchtower address.
- /// @param redemptionWatchtower Address of the redemption watchtower.
- /// @dev Requirements:
- /// - The caller must be the governance,
- /// - Redemption watchtower address must not be already set,
- /// - Redemption watchtower address must not be 0x0.
- function setRedemptionWatchtower(address redemptionWatchtower)
- external
- onlyGovernance
- {
- // The internal function is defined in the `BridgeState` library.
- self.setRedemptionWatchtower(redemptionWatchtower);
- }
-
- /// @return Address of the redemption watchtower.
function getRedemptionWatchtower() external view returns (address) {
return self.redemptionWatchtower;
}
diff --git a/solidity/contracts/bridge/BridgeGovernance.sol b/solidity/contracts/bridge/BridgeGovernance.sol
index 2126bcd8b..568489c09 100644
--- a/solidity/contracts/bridge/BridgeGovernance.sol
+++ b/solidity/contracts/bridge/BridgeGovernance.sol
@@ -305,6 +305,15 @@ contract BridgeGovernance is Ownable {
bridge.setVaultStatus(vault, isTrusted);
}
+ /// @notice Grants or revokes permission for an address to request Bank
+ /// balance increases via the Bridge.
+ function setAuthorizedBalanceIncreaser(address increaser, bool authorized)
+ external
+ onlyOwner
+ {
+ bridge.setAuthorizedBalanceIncreaser(increaser, authorized);
+ }
+
/// @notice Allows the Governance to mark the given address as trusted
/// or no longer trusted SPV maintainer. Addresses are not trusted
/// as SPV maintainers by default.
@@ -1762,11 +1771,6 @@ contract BridgeGovernance is Ownable {
bridge.updateTreasury(newTreasury);
}
- /// @notice Gets the governance delay parameter.
- function governanceDelay() internal view returns (uint256) {
- return governanceDelays[0];
- }
-
/// @notice Sets the redemption watchtower address. This function does not
/// have a governance delay as setting the redemption watchtower is
/// a one-off action performed during initialization of the
@@ -1782,4 +1786,9 @@ contract BridgeGovernance is Ownable {
{
bridge.setRedemptionWatchtower(redemptionWatchtower);
}
+
+ /// @notice Gets the governance delay parameter.
+ function governanceDelay() internal view returns (uint256) {
+ return governanceDelays[0];
+ }
}
diff --git a/solidity/contracts/bridge/BridgeState.sol b/solidity/contracts/bridge/BridgeState.sol
index 6e21a4698..9e74c7c79 100644
--- a/solidity/contracts/bridge/BridgeState.sol
+++ b/solidity/contracts/bridge/BridgeState.sol
@@ -320,14 +320,19 @@ library BridgeState {
// Address of the redemption watchtower. The redemption watchtower
// is responsible for vetoing redemption requests.
address redemptionWatchtower;
+ // Governance-managed set of contracts allowed to request Bank balance
+ // increases through the Bridge.
+ mapping(address => bool) authorizedBalanceIncreasers;
// Reserved storage space in case we need to add more variables.
// The convention from OpenZeppelin suggests the storage space should
// add up to 50 slots. Here we want to have more slots as there are
// planned upgrades of the Bridge contract. If more entires are added to
// the struct in the upcoming versions we need to reduce the array size.
+ // One slot is consumed by `authorizedBalanceIncreasers`, so the gap
+ // size is reduced accordingly.
// See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps
// slither-disable-next-line unused-state
- uint256[49] __gap;
+ uint256[48] __gap;
}
event DepositParametersUpdated(
diff --git a/solidity/deploy/99_configure_bridge_controllers.ts b/solidity/deploy/99_configure_bridge_controllers.ts
new file mode 100644
index 000000000..61af29f80
--- /dev/null
+++ b/solidity/deploy/99_configure_bridge_controllers.ts
@@ -0,0 +1,17 @@
+import { HardhatRuntimeEnvironment } from "hardhat/types"
+import { DeployFunction } from "hardhat-deploy/types"
+
+import { syncBridgeControllerAuthorizations } from "./utils/bridge-controller-authorization"
+
+const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
+ await syncBridgeControllerAuthorizations(hre, {
+ bridgeAddress: process.env.BRIDGE_ADDRESS,
+ increaserAddresses: process.env.BRIDGE_AUTHORIZED_INCREASERS?.split(","),
+ governancePrivateKey: process.env.BRIDGE_GOVERNANCE_PK,
+ })
+}
+
+export default func
+
+func.tags = ["ConfigureBridgeControllers"]
+func.skip = async () => true
diff --git a/solidity/deploy/utils/bridge-controller-authorization.ts b/solidity/deploy/utils/bridge-controller-authorization.ts
new file mode 100644
index 000000000..a493498b5
--- /dev/null
+++ b/solidity/deploy/utils/bridge-controller-authorization.ts
@@ -0,0 +1,208 @@
+/* eslint-disable no-await-in-loop, no-continue, no-restricted-syntax, prefer-destructuring, no-console */
+
+import type { DeployFunction } from "hardhat-deploy/types"
+import { HardhatRuntimeEnvironment } from "hardhat/types"
+
+export interface BridgeControllerAuthorizationSyncOptions {
+ bridgeAddress?: string
+ bridgeGovernanceAddress?: string
+ increaserAddresses?: string[]
+ governancePrivateKey?: string
+}
+
+const BRIDGE_ABI = [
+ "function authorizedBalanceIncreasers(address) view returns (bool)",
+ "event AuthorizedBalanceIncreaserUpdated(address indexed increaser, bool authorized)",
+]
+
+const BRIDGE_GOVERNANCE_ABI = [
+ "function setAuthorizedBalanceIncreaser(address,bool)",
+]
+
+export async function syncBridgeControllerAuthorizations(
+ hre: HardhatRuntimeEnvironment,
+ options: BridgeControllerAuthorizationSyncOptions = {}
+): Promise {
+ const { ethers, deployments, getNamedAccounts } = hre
+ const provider = ethers.provider
+
+ const increaserAddresses =
+ options.increaserAddresses
+ ?.map((addr) => addr.trim())
+ .filter((addr) => addr.length > 0) ?? []
+
+ const desiredIncreasers = Array.from(
+ new Set(
+ increaserAddresses.map((addr) => {
+ try {
+ return ethers.utils.getAddress(addr)
+ } catch (error) {
+ throw new Error(`Invalid increaser address provided: ${addr}`)
+ }
+ })
+ )
+ )
+
+ let bridgeAddress = options.bridgeAddress
+ if (!bridgeAddress) {
+ bridgeAddress = (await deployments.getOrNull("Bridge"))?.address
+ }
+
+ if (!bridgeAddress) {
+ console.log("⚠️ Bridge address not provided; skipping controller setup.")
+ return
+ }
+
+ let bridgeGovernanceAddress = options.bridgeGovernanceAddress
+ if (!bridgeGovernanceAddress) {
+ bridgeGovernanceAddress = (await deployments.getOrNull("BridgeGovernance"))
+ ?.address
+ }
+
+ if (!bridgeGovernanceAddress) {
+ console.log(
+ "⚠️ BridgeGovernance address not provided; cannot perform authorization."
+ )
+ return
+ }
+
+ const bridge = new ethers.Contract(bridgeAddress, BRIDGE_ABI, provider)
+ const bridgeGovernance = new ethers.Contract(
+ bridgeGovernanceAddress,
+ BRIDGE_GOVERNANCE_ABI,
+ provider
+ )
+
+ let governancePrivateKey = options.governancePrivateKey
+ if (!governancePrivateKey) {
+ const envKey = process.env.BRIDGE_GOVERNANCE_PK
+ if (envKey && envKey.trim().length > 0) {
+ governancePrivateKey = envKey.trim()
+ }
+ }
+
+ let signer = governancePrivateKey
+ ? new ethers.Wallet(governancePrivateKey, provider)
+ : undefined
+
+ if (!signer) {
+ const { governance } = await getNamedAccounts()
+ if (!governance) {
+ console.log(
+ "⚠️ No governance account configured and no private key supplied; skipping."
+ )
+ return
+ }
+ signer = await ethers.getSigner(governance)
+ }
+
+ const bridgeGovernanceWithSigner = bridgeGovernance.connect(signer)
+
+ let existingIncreasers: Set | undefined
+ try {
+ const bridgeDeployment = await deployments.getOrNull("Bridge")
+ const fromBlock =
+ (bridgeDeployment?.receipt?.blockNumber as number | undefined) ?? 0
+ const events = await bridge.queryFilter(
+ bridge.filters.AuthorizedBalanceIncreaserUpdated(),
+ fromBlock,
+ "latest"
+ )
+
+ existingIncreasers = new Set()
+ for (const event of events) {
+ const increaser = event.args?.increaser
+ const authorized = event.args?.authorized
+ if (!increaser || authorized === undefined) {
+ continue
+ }
+ const normalized = ethers.utils.getAddress(increaser)
+ if (authorized) {
+ existingIncreasers.add(normalized)
+ } else {
+ existingIncreasers.delete(normalized)
+ }
+ }
+ } catch (error) {
+ console.warn(
+ "⚠️ Failed to fetch existing authorized increasers; revocations will be skipped.",
+ error
+ )
+ }
+
+ if (desiredIncreasers.length === 0) {
+ if (!existingIncreasers) {
+ console.log(
+ "ℹ️ No increaser addresses provided and existing authorizations could not be determined; nothing to configure."
+ )
+ return
+ }
+
+ if (existingIncreasers.size === 0) {
+ console.log("ℹ️ No increaser addresses provided; nothing to configure.")
+ return
+ }
+
+ console.log(
+ "ℹ️ No increaser addresses provided; existing authorizations will be revoked."
+ )
+ }
+
+ for (const addr of desiredIncreasers) {
+ try {
+ const alreadyAuthorized = await bridge.authorizedBalanceIncreasers(addr)
+ if (alreadyAuthorized) {
+ console.log(` ♻️ ${addr} already authorized`)
+ continue
+ }
+
+ const tx = await bridgeGovernanceWithSigner.setAuthorizedBalanceIncreaser(
+ addr,
+ true
+ )
+ console.log(
+ ` ⛓️ Submitted authorization for ${addr}. Tx hash: ${tx.hash}`
+ )
+ await tx.wait()
+ console.log(` ✅ Authorized ${addr}`)
+ } catch (error) {
+ console.error(` ❌ Failed to authorize ${addr}`, error)
+ }
+ }
+
+ if (!existingIncreasers) {
+ return
+ }
+
+ const desiredIncreaserSet = new Set(desiredIncreasers)
+ const increasersToRevoke = Array.from(existingIncreasers).filter(
+ (addr) => !desiredIncreaserSet.has(addr)
+ )
+
+ for (const addr of increasersToRevoke) {
+ try {
+ const stillAuthorized = await bridge.authorizedBalanceIncreasers(addr)
+ if (!stillAuthorized) {
+ console.log(` ♻️ ${addr} already deauthorized`)
+ continue
+ }
+
+ const tx = await bridgeGovernanceWithSigner.setAuthorizedBalanceIncreaser(
+ addr,
+ false
+ )
+ console.log(
+ ` ⛔ Submitted deauthorization for ${addr}. Tx hash: ${tx.hash}`
+ )
+ await tx.wait()
+ console.log(` ✅ Deauthorized ${addr}`)
+ } catch (error) {
+ console.error(` ❌ Failed to revoke ${addr}`, error)
+ }
+ }
+}
+
+const noopDeploy: DeployFunction = async () => {}
+noopDeploy.skip = async () => true
+
+export default noopDeploy
diff --git a/solidity/fix-lint.js b/solidity/fix-lint.js
index 76fa50e9f..eeb4a59d1 100644
--- a/solidity/fix-lint.js
+++ b/solidity/fix-lint.js
@@ -1,3 +1,5 @@
+/* eslint-disable no-console */
+
import fs from "fs"
import path from "path"
import * as glob from "glob"
diff --git a/solidity/test/bridge/Bridge.Governance.test.ts b/solidity/test/bridge/Bridge.Governance.test.ts
index 39ccb9ce1..88506f4bc 100644
--- a/solidity/test/bridge/Bridge.Governance.test.ts
+++ b/solidity/test/bridge/Bridge.Governance.test.ts
@@ -1,10 +1,11 @@
-import { helpers, waffle } from "hardhat"
+import { ethers, helpers, waffle } from "hardhat"
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"
import { expect } from "chai"
import { ContractTransaction } from "ethers"
-import type { BridgeGovernance, Bridge } from "../../typechain"
+import type { BridgeGovernance, Bridge, Bank } from "../../typechain"
import { constants } from "../fixtures"
import bridgeFixture from "../fixtures/bridge"
+import { to1e18 } from "../helpers/contract-test-helpers"
const { createSnapshot, restoreSnapshot } = helpers.snapshot
@@ -13,10 +14,12 @@ describe("Bridge - Governance", () => {
let thirdParty: SignerWithAddress
let bridgeGovernance: BridgeGovernance
let bridge: Bridge
+ let guardians: SignerWithAddress[]
+ let bank: Bank
before(async () => {
// eslint-disable-next-line @typescript-eslint/no-extra-semi
- ;({ governance, thirdParty, bridgeGovernance, bridge } =
+ ;({ governance, thirdParty, bridgeGovernance, bridge, guardians, bank } =
await waffle.loadFixture(bridgeFixture))
})
@@ -125,6 +128,150 @@ describe("Bridge - Governance", () => {
)
})
+ describe("setAuthorizedBalanceIncreaser", () => {
+ const increaser = () => thirdParty.address
+
+ context("when caller is not the owner", () => {
+ it("should revert", async () => {
+ await expect(
+ bridgeGovernance
+ .connect(thirdParty)
+ .setAuthorizedBalanceIncreaser(increaser(), true)
+ ).to.be.revertedWith("Ownable: caller is not the owner")
+ })
+ })
+
+ context("when increaser address is zero", () => {
+ it("should revert", async () => {
+ await expect(
+ bridgeGovernance
+ .connect(governance)
+ .setAuthorizedBalanceIncreaser(ethers.constants.AddressZero, true)
+ ).to.be.revertedWith("Increaser address must not be 0x0")
+ })
+ })
+
+ context("when called by the governance with a valid address", () => {
+ let tx: ContractTransaction
+
+ before(async () => {
+ await createSnapshot()
+ tx = await bridgeGovernance
+ .connect(governance)
+ .setAuthorizedBalanceIncreaser(increaser(), true)
+ })
+
+ after(async () => {
+ await restoreSnapshot()
+ })
+
+ it("should update increaser authorization", async () => {
+ expect(await bridge.authorizedBalanceIncreasers(increaser())).to.be.true
+ })
+
+ it("should emit AuthorizedBalanceIncreaserUpdated event", async () => {
+ await expect(tx)
+ .to.emit(bridge, "AuthorizedBalanceIncreaserUpdated")
+ .withArgs(increaser(), true)
+ })
+ })
+ })
+
+ describe("controllerIncreaseBalance", () => {
+ const recipient = () => guardians[0].address
+ const amount = to1e18(1)
+
+ context("when caller is not authorized", () => {
+ it("should revert", async () => {
+ await expect(
+ bridge
+ .connect(thirdParty)
+ .controllerIncreaseBalance(recipient(), amount)
+ ).to.be.revertedWith("Caller is not an authorized increaser")
+ })
+ })
+
+ context("when caller is authorized", () => {
+ let tx: ContractTransaction
+
+ before(async () => {
+ await createSnapshot()
+ await bridgeGovernance
+ .connect(governance)
+ .setAuthorizedBalanceIncreaser(thirdParty.address, true)
+ tx = await bridge
+ .connect(thirdParty)
+ .controllerIncreaseBalance(recipient(), amount)
+ })
+
+ after(async () => {
+ await restoreSnapshot()
+ })
+
+ it("should increase recipient balance in the bank", async () => {
+ expect(await bank.balanceOf(recipient())).to.equal(amount)
+ })
+
+ it("should emit BalanceIncreased event", async () => {
+ await expect(tx)
+ .to.emit(bank, "BalanceIncreased")
+ .withArgs(recipient(), amount)
+ })
+ })
+ })
+
+ describe("controllerIncreaseBalances", () => {
+ const recipients = () => guardians.map((guardian) => guardian.address)
+ const amounts = [to1e18(1), to1e18(2), to1e18(3)]
+
+ context("when caller is not authorized", () => {
+ it("should revert", async () => {
+ await expect(
+ bridge
+ .connect(thirdParty)
+ .controllerIncreaseBalances(recipients(), amounts)
+ ).to.be.revertedWith("Caller is not an authorized increaser")
+ })
+ })
+
+ context("when caller is authorized", () => {
+ let tx: ContractTransaction
+
+ before(async () => {
+ await createSnapshot()
+ await bridgeGovernance
+ .connect(governance)
+ .setAuthorizedBalanceIncreaser(thirdParty.address, true)
+ tx = await bridge
+ .connect(thirdParty)
+ .controllerIncreaseBalances(recipients(), amounts)
+ })
+
+ after(async () => {
+ await restoreSnapshot()
+ })
+
+ it("should increase all recipients balances in the bank", async () => {
+ const balances = await Promise.all(
+ recipients().map((addr) => bank.balanceOf(addr))
+ )
+ balances.forEach((balance, index) =>
+ expect(balance).to.equal(amounts[index])
+ )
+ })
+
+ it("should emit BalanceIncreased events", async () => {
+ await Promise.all(
+ recipients().map((addr, index) =>
+ expect(tx)
+ .to.emit(bank, "BalanceIncreased")
+ .withArgs(addr, amounts[index])
+ )
+ )
+ })
+ })
+ })
+
describe("beginBridgeGovernanceTransfer", () => {
context("when the caller is not the owner", () => {
it("should revert", async () => {
diff --git a/solidity/test/fixtures/bridge.ts b/solidity/test/fixtures/bridge.ts
index fe404d93d..9f20e2489 100644
--- a/solidity/test/fixtures/bridge.ts
+++ b/solidity/test/fixtures/bridge.ts
@@ -40,7 +40,9 @@ export default async function bridgeFixture(): Promise<{
maintainerProxy: MaintainerProxy
bridgeGovernance: BridgeGovernance
redemptionWatchtower: RedemptionWatchtower
- deployBridge: (txProofDifficultyFactor: number) => Promise
+ deployBridge: (
+ txProofDifficultyFactor: number
+ ) => Awaited>
}> {
await deployments.fixture()
@@ -104,7 +106,9 @@ export default async function bridgeFixture(): Promise<{
// specify txProofDifficultyFactor. The new instance is deployed with
// a random name to do not conflict with the main deployed instance.
// Same parameters as in `05_deploy_bridge.ts` deployment script are used.
- const deployBridge = async (txProofDifficultyFactor: number) =>
+ const deployBridge = async (
+ txProofDifficultyFactor: number
+ ): Promise>> =>
helpers.upgrades.deployProxy(`Bridge_${randomBytes(8).toString("hex")}`, {
contractName: "BridgeStub",
initializerArgs: [
diff --git a/solidity/test/integrator/AbstractBTCRedeemer.test.ts b/solidity/test/integrator/AbstractBTCRedeemer.test.ts
index 9e1ce9d40..74b64e072 100644
--- a/solidity/test/integrator/AbstractBTCRedeemer.test.ts
+++ b/solidity/test/integrator/AbstractBTCRedeemer.test.ts
@@ -1,6 +1,7 @@
import { ethers, helpers } from "hardhat"
import { expect } from "chai"
import { BigNumber, ContractTransaction } from "ethers"
+import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"
import type {
MockTBTCBridge,
MockTBTCToken,
@@ -34,7 +35,7 @@ describe("AbstractBTCRedeemer", () => {
let tbtcVault: MockTBTCVault
let redeemer: TestBTCRedeemer
let fixture: ReturnType
- let deployer: any
+ let deployer: SignerWithAddress
before(async () => {
// eslint-disable-next-line @typescript-eslint/no-extra-semi
@@ -447,7 +448,7 @@ describe("AbstractBTCRedeemer", () => {
describe("rescueTbtc", () => {
const amountToRescue = to1ePrecision(1, 18) // 1 TBTC
- let randomAccount: any
+ let randomAccount: SignerWithAddress
before(async () => {
// eslint-disable-next-line @typescript-eslint/no-extra-semi
From c7bf48ac4cfe29ca4ef820d9d86825d82143b990 Mon Sep 17 00:00:00 2001
From: Piotr Roslaniec
Date: Sun, 9 Nov 2025 20:31:38 +0100
Subject: [PATCH 02/74] docs: add Bridge controller-authorization upgrade
explainer; ignore local env; add Sepolia env example
---
.gitignore | 4 +
...bridge-controller-authorization-upgrade.md | 93 +++++++++++++++++++
solidity/.env.sepolia.example | 41 ++++++++
3 files changed, 138 insertions(+)
create mode 100644 docs/bridge-controller-authorization-upgrade.md
create mode 100644 solidity/.env.sepolia.example
diff --git a/.gitignore b/.gitignore
index 34af738d8..ef75918dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,10 @@ dist/
.vscode/
.envrc
+# Local environment files for Solidity
+solidity/.env
+solidity/.env.sepolia
+
yarn-error.log
CLAUDE.md
.claude/
diff --git a/docs/bridge-controller-authorization-upgrade.md b/docs/bridge-controller-authorization-upgrade.md
new file mode 100644
index 000000000..d94a5aebc
--- /dev/null
+++ b/docs/bridge-controller-authorization-upgrade.md
@@ -0,0 +1,93 @@
+# Bridge Upgrade: Controller Authorization Allowlist (Explainer + Sepolia Report)
+
+## Summary
+- Objective: enable a governance‑managed allowlist of controller contracts that can mint via the Bridge by increasing Bank balances.
+- Approach: upgrade the Bridge proxy implementation to add the allowlist and controller entrypoints; redeploy BridgeGovernance with a forwarder function; transfer governance; optionally sync the allowlist.
+- Safety: evented changes, explicit zero‑address checks, governance‑only setters, snapshot + rollback tooling.
+
+## Motivation
+Integrations need a narrow, auditable way to mint balances through the Bridge without broad privileges. Introducing an “authorized balance increaser” allowlist lets governance approve specific controller contracts to call controlled minting functions, minimizing surface area while preserving the existing flow and roles.
+
+This model provides:
+- Least‑privilege controller minting gate on the Bridge.
+- On‑chain audit trail via events.
+- Operationally simple management via BridgeGovernance.
+
+## What Changed (Contracts)
+- Bridge (proxy):
+ - New state: `authorizedBalanceIncreasers` mapping (storage layout extended).
+ - New events: `AuthorizedBalanceIncreaserUpdated(address,bool)`.
+ - New methods (gated at runtime by allowlist):
+ - `controllerIncreaseBalance(address,uint256)`
+ - `controllerIncreaseBalances(address[],uint256[])`
+ - `authorizedBalanceIncreasers(address) -> bool`
+ - New governance setter: `setAuthorizedBalanceIncreaser(address,bool)` (onlyGovernance).
+- BridgeGovernance (regular contract):
+ - New owner‑only forwarder: `setAuthorizedBalanceIncreaser(address,bool)` that calls into Bridge.
+- New interface for integrators: `IBridgeMintingAuthorization` (minimal surface consumed by account‑control).
+
+## Why Governance Redeploy Is Needed
+- Bridge’s governance role is the BridgeGovernance contract address. To manage the new allowlist on Bridge, BridgeGovernance itself must expose the matching forwarder function.
+- The legacy governance contract does not have it, so we deploy a fresh BridgeGovernance and transfer Bridge governance to the new instance (governance delay applies).
+
+## Upgrade Plan (High‑Level)
+1) Pre‑upgrade snapshot of Bridge state (implementation/admin/governance, parameters, allowlists).
+2) Upgrade Bridge proxy implementation via ProxyAdmin to the version with controller allowlist.
+3) Redeploy BridgeGovernance (fresh instance) and transfer governance:
+ - Begin transfer, wait governance delay, finalize.
+4) Optionally sync authorized controllers from env/config; emit events for adds/removals.
+5) Post‑upgrade snapshot; compare and archive.
+
+Supporting scripts (names as in repo):
+- `solidity/deploy/80_upgrade_bridge_v2.ts` — upgrade Bridge, resolve libraries/addresses, conditional Tenderly verify.
+- `solidity/deploy/09_deploy_bridge_governance.ts` — deploy BridgeGovernance (+Parameters), conditional Tenderly verify.
+- `solidity/deploy/21_transfer_bridge_governance.ts` — initiate/wait/finalize governance transfer.
+- `solidity/deploy/99_configure_bridge_controllers.ts` + `solidity/deploy/utils/bridge-controller-authorization.ts` — sync allowlist from env.
+- `solidity/scripts/upgrade-bridge-sepolia.ts` — end‑to‑end orchestration incl. snapshots and optional allowlist sync.
+- `solidity/scripts/rollback-bridge-sepolia.ts` — revert implementation and governance, reapply allowlist from snapshot.
+
+## Risks & Mitigations
+- Storage layout changes: uses mapped slot and reduces the storage gap accordingly; upgrade path accounted for in implementation.
+- Misconfiguration risk: snapshot + rollback scripts provided; allowlist sync is explicit and evented.
+- Tenderly availability: verification is conditional on local Tenderly config to avoid deployment failures.
+
+## Environment Notes
+- Env keys used during orchestration include: `BRIDGE_ADDRESS`, `PROXY_ADMIN_PK`, `BRIDGE_GOVERNANCE_PK`, `BRIDGE_AUTHORIZED_INCREASERS`, and library/core contract address fallbacks.
+- Sepolia RPC: prefer `SEPOLIA_CHAIN_API_URL`/`SEPOLIA_PRIVATE_KEYS` where applicable.
+
+---
+
+## Appendix: Sepolia Execution Report
+
+The following section preserves the original Sepolia run report for traceability.
+
+### Overview
+- Proxy address: `0x9b1a7fE5a16A15F2f9475C5B231750598b113403`
+- New implementation: `0x1c19BBF9afAfe5e8EA4F78c1178752cE62683694`
+- Proxy admin: `0x39f60B25C4598Caf7e922d6fC063E9002db45845`
+- New BridgeGovernance: `0x78c99F5B8981A7FDa02E9700778c8493b2eb7D6b`
+- Upgrade signer: `0x68ad60CC5e8f3B7cC53beaB321cf0e6036962dBc`
+- Governance owner: `0xF4767Aecf1dFB3a63791538E065c0C9F7f8920C3`
+
+### Actions Performed
+1) Bridge proxy upgrade
+ Executed `ProxyAdmin.upgrade` (tx `0x05e00adfc9f091443eb44ea619cac497ff9aa32a27e49539716a93ae8ed5a7fd`), swapping the Bridge proxy’s implementation to `0x1c19…`. The proxy address and admin remained unchanged (`deployments/sepolia/Bridge.json`).
+
+2) BridgeGovernance redeployment
+ Deployed a fresh governance contract at `0x78c99F5…` with governance delay `60` seconds and finalized ownership to the treasury signer (`deployments/sepolia/BridgeGovernance.json`).
+
+3) Environment updates
+ Updated `.env` and `.env.sepolia` to point at the new governance address and to use the treasury signer private key for governance actions.
+
+4) Snapshots & tooling
+ Captured pre/post-upgrade snapshots (`deployments/sepolia/bridge-upgrade.json`) and verified proxy admin / implementation slots to confirm the upgrade.
+
+### Post‑Upgrade State Verification
+- `Bridge` proxy implementation slot resolves to `0x1c19…`; proxy admin slot remains `0x39f60B25…`.
+- `Bridge.governance()` returns the new governance address `0x78c99F5…`.
+- Bridge parameter structs, trusted vault list (vault `0xB5679dE…`), and SPV maintainers (`0x3Bc9a80…`, `0x68ad60…`) match pre-upgrade values.
+- BridgeGovernance reports the treasury signer as owner and retains the 60 second governance delay.
+
+### Summary
+The Sepolia bridge stack now runs the refreshed Bridge implementation behind the existing proxy while delegating governance to the newly deployed BridgeGovernance contract. All intended configuration, allowlists, and operational parameters were carried forward without deviation. No outstanding issues were observed.
+
diff --git a/solidity/.env.sepolia.example b/solidity/.env.sepolia.example
new file mode 100644
index 000000000..c3ed7d5ed
--- /dev/null
+++ b/solidity/.env.sepolia.example
@@ -0,0 +1,41 @@
+# Sepolia environment example (sanitize and copy to solidity/.env.sepolia)
+
+# RPC and accounts
+SEPOLIA_CHAIN_API_URL="https://sepolia.infura.io/v3/"
+SEPOLIA_PRIVATE_KEYS="0x[,0x,...]"
+
+# Upgrade orchestration
+# Address of the Bridge proxy on Sepolia
+BRIDGE_ADDRESS="0x"
+# ProxyAdmin private key (controls upgrade)
+PROXY_ADMIN_PK="0x"
+# Bridge governance private key (controls governance actions)
+BRIDGE_GOVERNANCE_PK="0x"
+
+# Snapshot output
+SNAPSHOT_OUTFILE="./deployments/sepolia/bridge-upgrade.json"
+# Optionally skip events on pre-upgrade snapshot to minimize RPC load
+BRIDGE_SNAPSHOT_SKIP_EVENTS="true"
+
+# Core dependencies (fallbacks only; deployments cache preferred)
+BANK_ADDRESS="0x"
+REIMBURSEMENT_POOL_ADDRESS="0x"
+WALLET_REGISTRY_ADDRESS="0x"
+LIGHT_RELAY_ADDRESS="0x"
+BRIDGE_TREASURY_ADDRESS="0x"
+
+# Bridge library addresses (reuse previously deployed libs)
+DEPOSIT_LIB_ADDRESS="0x"
+DEPOSITSWEEP_LIB_ADDRESS="0x"
+REDEMPTION_LIB_ADDRESS="0x"
+WALLETS_LIB_ADDRESS="0x"
+FRAUD_LIB_ADDRESS="0x"
+MOVINGFUNDS_LIB_ADDRESS="0x"
+
+# Controller allowlist (optional)
+# Comma-separated list of addresses to authorize via BridgeGovernance
+# BRIDGE_AUTHORIZED_INCREASERS="0x,0x"
+
+# Etherscan/Tenderly (optional)
+# ETHERSCAN_API_KEY=""
+# TENDERLY_ACCESS_TOKEN=""
From 0f5f209aeaa58958bad8c55283723c08ab3e0aff Mon Sep 17 00:00:00 2001
From: Piotr Roslaniec
Date: Sun, 9 Nov 2025 20:31:41 +0100
Subject: [PATCH 03/74] deploy(upgrade-bridge): prefer cached deployment, use
PROXY_ADMIN_PK only, verify library bytecodes before linking
---
solidity/deploy/80_upgrade_bridge_v2.ts | 251 +++++++++++++++++++++---
1 file changed, 221 insertions(+), 30 deletions(-)
diff --git a/solidity/deploy/80_upgrade_bridge_v2.ts b/solidity/deploy/80_upgrade_bridge_v2.ts
index 3353f8588..100ecea89 100644
--- a/solidity/deploy/80_upgrade_bridge_v2.ts
+++ b/solidity/deploy/80_upgrade_bridge_v2.ts
@@ -1,15 +1,61 @@
import { HardhatRuntimeEnvironment } from "hardhat/types"
import { DeployFunction } from "hardhat-deploy/types"
+import fs from "fs"
+import path from "path"
+import os from "os"
const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
const { ethers, helpers, deployments, getNamedAccounts } = hre
- const { get } = deployments
- const { deployer, treasury } = await getNamedAccounts()
+ const { deploy } = deployments
+ const { deployer, treasury: namedTreasury } = await getNamedAccounts()
- const Bank = await deployments.get("Bank")
- const LightRelay = await deployments.get("LightRelay")
- const WalletRegistry = await deployments.get("WalletRegistry")
- const ReimbursementPool = await deployments.get("ReimbursementPool")
+ // Prefer cached deployment; fall back to env if cache missing.
+ const cachedBridge = await deployments.getOrNull("Bridge")
+ const bridgeAddress = cachedBridge?.address ?? process.env.BRIDGE_ADDRESS
+ if (!bridgeAddress) {
+ throw new Error(
+ "Bridge address not found. Provide BRIDGE_ADDRESS or ensure deployments cache exists."
+ )
+ }
+
+ // Use only the ProxyAdmin key for proxy operations; do not mix with
+ // governance key to avoid role confusion.
+ const proxyAdminPrivateKey = process.env.PROXY_ADMIN_PK
+
+ let signer = await ethers.getSigner(deployer)
+ let signerAddress = await signer.getAddress()
+ if (proxyAdminPrivateKey) {
+ signer = new ethers.Wallet(proxyAdminPrivateKey, ethers.provider)
+ signerAddress = await signer.getAddress()
+ } else {
+ deployments.log(
+ "⚠️ PROXY_ADMIN_PK not set; using deployer signer for proxy upgrade. Ensure deployer controls ProxyAdmin."
+ )
+ }
+
+ const bankAddress = await resolveCoreAddress(
+ deployments,
+ "Bank",
+ "BANK_ADDRESS"
+ )
+ const lightRelayAddress = await resolveCoreAddress(
+ deployments,
+ "LightRelay",
+ "LIGHT_RELAY_ADDRESS"
+ )
+ const walletRegistryAddress = await resolveCoreAddress(
+ deployments,
+ "WalletRegistry",
+ "WALLET_REGISTRY_ADDRESS"
+ )
+ const reimbursementPoolAddress = await resolveCoreAddress(
+ deployments,
+ "ReimbursementPool",
+ "REIMBURSEMENT_POOL_ADDRESS"
+ )
+
+ const treasuryAddress =
+ process.env.BRIDGE_TREASURY_ADDRESS ?? namedTreasury ?? ethers.constants.AddressZero
const txProofDifficultyFactor = 6
@@ -17,12 +63,55 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
// `get` function to load the ones that were already published before.
// If there are any changes in the external libraries make sure to deploy fresh
// versions of the libraries and link them to the implementation.
- const Deposit = await get("Deposit")
- const DepositSweep = await get("DepositSweep")
- const Redemption = await get("Redemption")
- const Wallets = await get("Wallets")
- const Fraud = await get("Fraud")
- const MovingFunds = await get("MovingFunds")
+ const depositLib = await resolveLibrary(
+ deployments,
+ signerAddress,
+ "Deposit"
+ )
+ const depositSweepLib = await resolveLibrary(
+ deployments,
+ signerAddress,
+ "DepositSweep"
+ )
+ const redemptionLib = await resolveLibrary(
+ deployments,
+ signerAddress,
+ "Redemption"
+ )
+ const walletsLib = await resolveLibrary(
+ deployments,
+ signerAddress,
+ "Wallets"
+ )
+ const fraudLib = await resolveLibrary(
+ deployments,
+ signerAddress,
+ "Fraud"
+ )
+ const movingFundsLib = await resolveLibrary(
+ deployments,
+ signerAddress,
+ "MovingFunds"
+ )
+
+ await ensureDeploymentRecord(
+ deployments,
+ "Bridge",
+ bridgeAddress,
+ "Bridge"
+ )
+
+ const libraryAddresses = {
+ Deposit: depositLib,
+ DepositSweep: depositSweepLib,
+ Redemption: redemptionLib,
+ Wallets: walletsLib,
+ Fraud: fraudLib,
+ MovingFunds: movingFundsLib,
+ }
+
+ // Verify on-chain library bytecodes match compiled artifacts.
+ await verifyLibraryBytecodes(hre, libraryAddresses)
const [bridge, proxyDeployment] = await helpers.upgrades.upgradeProxy(
"Bridge",
@@ -30,23 +119,16 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
{
contractName: "Bridge",
initializerArgs: [
- Bank.address,
- LightRelay.address,
- treasury,
- WalletRegistry.address,
- ReimbursementPool.address,
+ bankAddress,
+ lightRelayAddress,
+ treasuryAddress,
+ walletRegistryAddress,
+ reimbursementPoolAddress,
txProofDifficultyFactor,
],
factoryOpts: {
- signer: await ethers.getSigner(deployer),
- libraries: {
- Deposit: Deposit.address,
- DepositSweep: DepositSweep.address,
- Redemption: Redemption.address,
- Wallets: Wallets.address,
- Fraud: Fraud.address,
- MovingFunds: MovingFunds.address,
- },
+ signer,
+ libraries: libraryAddresses,
},
proxyOpts: {
kind: "transparent",
@@ -70,11 +152,39 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
}
if (hre.network.tags.tenderly) {
- await hre.tenderly.verify({
- name: "Bridge",
- address: bridge.address,
- })
+ const tenderlyConfigPath = path.join(
+ os.homedir(),
+ ".tenderly",
+ "config.yaml"
+ )
+ if (fs.existsSync(tenderlyConfigPath)) {
+ await hre.tenderly.verify({
+ name: "Bridge",
+ address: bridge.address,
+ })
+ } else {
+ deployments.log(
+ "Skipping Tenderly verification; /.tenderly/config.yaml not found."
+ )
+ }
+ }
+}
+
+async function ensureDeploymentRecord(
+ deployments: HardhatRuntimeEnvironment["deployments"],
+ name: string,
+ address: string,
+ artifactName: string
+): Promise {
+ const existing = await deployments.getOrNull(name)
+ if (existing?.address) {
+ return
}
+ const artifact = await deployments.getArtifact(artifactName)
+ await deployments.save(name, {
+ address,
+ abi: artifact.abi,
+ })
}
export default func
@@ -83,3 +193,84 @@ func.tags = ["UpgradeBridge"]
// When running an upgrade uncomment the skip below and run the command:
// yarn deploy --tags UpgradeBridge --network
func.skip = async () => true
+
+async function resolveCoreAddress(
+ deployments: HardhatRuntimeEnvironment["deployments"],
+ name: string,
+ envVar: string
+): Promise {
+ const deployment = await deployments.getOrNull(name)
+ if (deployment?.address) {
+ return deployment.address
+ }
+ const envAddress = process.env[envVar]
+ if (!envAddress || envAddress.length === 0) {
+ throw new Error(
+ `Address for ${name} not found in deployments cache. Provide ${envVar}.`
+ )
+ }
+ return envAddress
+}
+
+async function resolveLibrary(
+ deployments: HardhatRuntimeEnvironment["deployments"],
+ signerAddress: string,
+ libName: string
+): Promise {
+ const existing = await deployments.getOrNull(libName)
+ if (existing?.address) {
+ return existing.address
+ }
+
+ const envVar = `${libName.toUpperCase()}_LIB_ADDRESS`
+ const envValue = process.env[envVar]
+ if (envValue && envValue.length > 0) {
+ return envValue
+ }
+
+ const fqn = `contracts/bridge/${libName}.sol:${libName}`
+ const deployment = await deployments.deploy(libName, {
+ from: signerAddress,
+ log: true,
+ skipIfAlreadyDeployed: true,
+ contract: fqn,
+ library: true,
+ })
+ if (!deployment.address) {
+ throw new Error(`Failed to deploy library ${libName}`)
+ }
+ return deployment.address
+}
+
+async function verifyLibraryBytecodes(
+ hre: HardhatRuntimeEnvironment,
+ libs: Record
+): Promise {
+ const { deployments, ethers } = hre
+ for (const [name, address] of Object.entries(libs)) {
+ try {
+ const artifact = await deployments.getArtifact(name)
+ const expected = (artifact.deployedBytecode || artifact.bytecode || "").toLowerCase()
+ const onchain = (await ethers.provider.getCode(address)).toLowerCase()
+
+ if (!onchain || onchain === "0x") {
+ deployments.log(
+ `⚠️ Library ${name} at ${address} has no code on-chain. Check address.`
+ )
+ continue
+ }
+
+ // Some toolchains include metadata; direct equality is fine here since we
+ // compare runtime bytecode to on-chain code. Warn if mismatch.
+ if (expected && expected !== "0x" && onchain !== expected) {
+ deployments.log(
+ `⚠️ Bytecode mismatch for ${name} at ${address}. Using on-chain code; verify library compatibility.`
+ )
+ }
+ } catch (error) {
+ deployments.log(
+ `⚠️ Skipping bytecode check for ${name} at ${address}: ${String(error)}`
+ )
+ }
+ }
+}
From 3cb3a0a8e6ff6d1272952bde2eb12b0770f0791b Mon Sep 17 00:00:00 2001
From: Piotr Roslaniec
Date: Sun, 9 Nov 2025 20:31:43 +0100
Subject: [PATCH 04/74] deploy(transfer-governance): use governance signer
(BRIDGE_GOVERNANCE_PK or named account)
---
.../deploy/21_transfer_bridge_governance.ts | 74 +++++++++++++++++--
1 file changed, 68 insertions(+), 6 deletions(-)
diff --git a/solidity/deploy/21_transfer_bridge_governance.ts b/solidity/deploy/21_transfer_bridge_governance.ts
index 40f2d11ae..e1712ade7 100644
--- a/solidity/deploy/21_transfer_bridge_governance.ts
+++ b/solidity/deploy/21_transfer_bridge_governance.ts
@@ -1,18 +1,76 @@
import { HardhatRuntimeEnvironment } from "hardhat/types"
import { DeployFunction } from "hardhat-deploy/types"
+import { ethers } from "hardhat"
const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
const { getNamedAccounts, deployments } = hre
- const { deployer } = await getNamedAccounts()
+ const { governance } = await getNamedAccounts()
- const BridgeGovernance = await deployments.get("BridgeGovernance")
+ // Use the governance key or named governance account; do not fall back to
+ // ProxyAdmin signer for governance actions.
+ let signer = undefined as any
+ const governancePk = process.env.BRIDGE_GOVERNANCE_PK
+ if (governancePk) {
+ signer = new ethers.Wallet(governancePk, ethers.provider)
+ } else {
+ signer = await ethers.getSigner(governance)
+ }
- await deployments.execute(
+ const bridgeDeployment = await deployments.get("Bridge")
+ const bridge = await ethers.getContractAt(
"Bridge",
- { from: deployer, log: true, waitConfirmations: 1 },
- "transferGovernance",
- BridgeGovernance.address
+ bridgeDeployment.address,
+ signer
)
+
+ const currentGovernance = await bridge.governance()
+ const newGovernanceDeployment = await deployments.get("BridgeGovernance")
+ const newGovernance = newGovernanceDeployment.address
+
+ if (currentGovernance.toLowerCase() === newGovernance.toLowerCase()) {
+ deployments.log("Bridge governance already transferred; skipping.")
+ return
+ }
+
+ const bridgeGovernance = await ethers.getContractAt(
+ "BridgeGovernance",
+ currentGovernance,
+ signer
+ )
+
+ const governanceDelay = await bridgeGovernance.governanceDelays(0)
+ const changeInitiated =
+ await bridgeGovernance.bridgeGovernanceTransferChangeInitiated()
+
+ if (changeInitiated.eq(0)) {
+ const beginTx = await bridgeGovernance.beginBridgeGovernanceTransfer(
+ newGovernance
+ )
+ deployments.log(
+ `Initiated bridge governance transfer (tx: ${beginTx.hash}), waiting for delay…`
+ )
+ await beginTx.wait(1)
+ } else {
+ deployments.log("Bridge governance transfer already initiated; skipping.")
+ }
+
+ const currentTimestamp = (await ethers.provider.getBlock("latest")).timestamp
+ const initiatedAt =
+ await bridgeGovernance.bridgeGovernanceTransferChangeInitiated()
+ const earliestFinalize = initiatedAt.add(governanceDelay)
+ if (currentTimestamp < earliestFinalize.toNumber()) {
+ const waitSeconds = earliestFinalize.toNumber() - currentTimestamp + 5
+ deployments.log(
+ `Waiting ${waitSeconds} seconds for governance delay to elapse…`
+ )
+ await delay(waitSeconds * 1000)
+ }
+
+ const finalizeTx = await bridgeGovernance.finalizeBridgeGovernanceTransfer()
+ deployments.log(
+ `Finalized bridge governance transfer in tx: ${finalizeTx.hash}`
+ )
+ await finalizeTx.wait(1)
}
export default func
@@ -30,3 +88,7 @@ func.dependencies = [
"AuthorizeSpvMaintainer",
]
func.runAtTheEnd = true
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
From 20a8e8f7ca1bbac4d915d2365c6d8271239964cb Mon Sep 17 00:00:00 2001
From: Piotr Roslaniec
Date: Sun, 9 Nov 2025 20:31:44 +0100
Subject: [PATCH 05/74] scripts(sepolia): call BridgeGovernance ownership
transfer; fix rollback to use governanceDelays(0)
---
solidity/scripts/rollback-bridge-sepolia.ts | 299 +++++++++++
solidity/scripts/upgrade-bridge-sepolia.ts | 517 ++++++++++++++++++++
2 files changed, 816 insertions(+)
create mode 100644 solidity/scripts/rollback-bridge-sepolia.ts
create mode 100644 solidity/scripts/upgrade-bridge-sepolia.ts
diff --git a/solidity/scripts/rollback-bridge-sepolia.ts b/solidity/scripts/rollback-bridge-sepolia.ts
new file mode 100644
index 000000000..b03cec6fd
--- /dev/null
+++ b/solidity/scripts/rollback-bridge-sepolia.ts
@@ -0,0 +1,299 @@
+/* eslint-disable no-console */
+import fs from "fs"
+import path from "path"
+import hre from "hardhat"
+import type { Contract } from "ethers"
+import { Wallet, BigNumber } from "ethers"
+import { syncBridgeControllerAuthorizations } from "../deploy/utils/bridge-controller-authorization"
+
+type Address = string
+
+interface BigNumberRecord {
+ [key: string]: string
+}
+
+interface BridgeSnapshot {
+ label: string
+ network: string
+ timestamp: string
+ bridgeAddress: Address
+ bridgeImplementation: Address
+ bridgeGovernance: Address
+ proxyAdmin?: Address
+ depositParameters: BigNumberRecord
+ redemptionParameters: BigNumberRecord
+ movingFundsParameters: BigNumberRecord
+ walletParameters: BigNumberRecord
+ treasury: Address
+ authorizedControllers: { address: Address; authorized: boolean }[]
+ trustedVaults: { address: Address; trusted: boolean }[]
+ spvMaintainers: { address: Address; trusted: boolean }[]
+}
+
+const SNAPSHOT_FILE =
+ process.env.BRIDGE_SNAPSHOT_FILE || process.env.SNAPSHOT_OUTFILE
+const ROLLBACK_DRY_RUN =
+ process.env.BRIDGE_ROLLBACK_DRY_RUN === "true" ||
+ process.env.DRY_RUN === "true"
+
+if (
+ !process.env.BRIDGE_GOVERNANCE_PK &&
+ process.env.TLABS_SEPOLIA_BANK_OWNER_PK
+) {
+ process.env.BRIDGE_GOVERNANCE_PK =
+ process.env.TLABS_SEPOLIA_BANK_OWNER_PK
+ console.log(
+ "ℹ️ Falling back to TLABS_SEPOLIA_BANK_OWNER_PK for BRIDGE_GOVERNANCE_PK"
+ )
+}
+
+function requireSnapshotPath(): string {
+ if (!SNAPSHOT_FILE) {
+ throw new Error(
+ "Snapshot file not provided. Set BRIDGE_SNAPSHOT_FILE or SNAPSHOT_OUTFILE."
+ )
+ }
+ return path.resolve(SNAPSHOT_FILE)
+}
+
+function loadPreUpgradeSnapshot(): BridgeSnapshot {
+ const file = requireSnapshotPath()
+ if (!fs.existsSync(file)) {
+ throw new Error(`Snapshot file not found: ${file}`)
+ }
+ const raw = fs.readFileSync(file, "utf8")
+ const parsed = JSON.parse(raw) as BridgeSnapshot | BridgeSnapshot[]
+ const snapshots: BridgeSnapshot[] = Array.isArray(parsed)
+ ? parsed
+ : [parsed]
+
+ const preSnapshot =
+ snapshots.find((s) => s.label === "pre-upgrade") ?? snapshots[0]
+
+ if (!preSnapshot) {
+ throw new Error("Pre-upgrade snapshot not found in snapshot file.")
+ }
+
+ return preSnapshot
+}
+
+async function main(): Promise {
+ if (hre.network.name !== "sepolia") {
+ throw new Error(
+ `Rollback script is tailored for Sepolia. Current network: ${hre.network.name}`
+ )
+ }
+
+ const snapshot = loadPreUpgradeSnapshot()
+ console.log(
+ `Loaded pre-upgrade snapshot (${snapshot.timestamp}) targeting Bridge ${snapshot.bridgeAddress}`
+ )
+
+ if (ROLLBACK_DRY_RUN) {
+ console.log("🛑 Dry-run enabled, no transactions will be sent.")
+ }
+
+ const { deployments, ethers, getNamedAccounts } = hre
+
+ const bridgeDeployment = await deployments.getOrNull("Bridge")
+ const bridgeAddress =
+ process.env.BRIDGE_ADDRESS ?? bridgeDeployment?.address
+ if (!bridgeAddress) {
+ throw new Error(
+ "Bridge address not available. Provide BRIDGE_ADDRESS or ensure deployments cache exists."
+ )
+ }
+
+ const proxyAdminDeployment = await deployments.getOrNull(
+ "BridgeProxyAdminWithDeputy"
+ )
+ const proxyAdminAddress =
+ proxyAdminDeployment?.address ?? process.env.BRIDGE_PROXY_ADMIN_ADDRESS
+ if (!proxyAdminAddress) {
+ throw new Error(
+ "Bridge proxy admin address not available. Provide BRIDGE_PROXY_ADMIN_ADDRESS."
+ )
+ }
+
+ const currentBridge: Contract = await ethers.getContractAt(
+ "Bridge",
+ bridgeAddress
+ )
+ const currentGovernance: string = await currentBridge.governance()
+
+ console.log(
+ `Current Bridge governance: ${currentGovernance}. Pre-upgrade governance: ${snapshot.bridgeGovernance}`
+ )
+
+ if (!ROLLBACK_DRY_RUN) {
+ await revertBridgeImplementation(
+ ethers,
+ proxyAdminAddress,
+ bridgeAddress,
+ snapshot.bridgeImplementation
+ )
+
+ await revertBridgeGovernance(
+ ethers,
+ getNamedAccounts,
+ snapshot.bridgeAddress,
+ currentGovernance,
+ snapshot.bridgeGovernance
+ )
+
+ await restoreControllerAllowlist(
+ snapshot,
+ snapshot.bridgeGovernance
+ )
+ } else {
+ console.log(
+ `Dry-run: would revert proxy to implementation ${snapshot.bridgeImplementation}`
+ )
+ console.log(
+ `Dry-run: would transfer governance back to ${snapshot.bridgeGovernance}`
+ )
+ console.log(
+ `Dry-run: would re-sync controller allowlist to ${snapshot.authorizedControllers
+ .filter((c) => c.authorized)
+ .map((c) => c.address)
+ .join(", ")}`
+ )
+ }
+
+ console.log("Rollback procedure completed.")
+}
+
+async function revertBridgeImplementation(
+ ethers: typeof hre.ethers,
+ proxyAdminAddress: string,
+ bridgeAddress: string,
+ targetImplementation: string
+): Promise {
+ console.log("\n[1/3] Reverting Bridge implementation via proxy admin…")
+
+ const signer = await resolveProxyAdminSigner(ethers)
+ const proxyAdmin = new ethers.Contract(
+ proxyAdminAddress,
+ ["function upgrade(address proxy, address implementation) external"],
+ signer
+ )
+
+ const tx = await proxyAdmin.upgrade(bridgeAddress, targetImplementation)
+ console.log(
+ ` • Sent upgrade transaction ${tx.hash}, waiting for confirmations…`
+ )
+ await tx.wait()
+ console.log(" • Bridge implementation reverted successfully.")
+}
+
+async function resolveProxyAdminSigner(ethers: typeof hre.ethers) {
+ if (process.env.PROXY_ADMIN_PK) {
+ return new Wallet(process.env.PROXY_ADMIN_PK, ethers.provider)
+ }
+ const { deployer } = await hre.getNamedAccounts()
+ return await ethers.getSigner(deployer)
+}
+
+async function revertBridgeGovernance(
+ ethers: typeof hre.ethers,
+ getNamedAccounts: typeof hre.getNamedAccounts,
+ bridgeAddress: string,
+ currentGovernance: string,
+ targetGovernance: string
+): Promise {
+ console.log("\n[2/3] Restoring Bridge governance…")
+
+ if (currentGovernance.toLowerCase() === targetGovernance.toLowerCase()) {
+ console.log(" • Governance already matches pre-upgrade value, skipping.")
+ return
+ }
+
+ const governanceSigner = await resolveGovernanceSigner(ethers, getNamedAccounts)
+ const bridgeGovernance = await ethers.getContractAt(
+ "BridgeGovernance",
+ currentGovernance,
+ governanceSigner
+ )
+
+ // BridgeGovernance exposes governance delay via the public array slot [0].
+ const delay: BigNumber = await bridgeGovernance.governanceDelays(0)
+ const changeInitiated: BigNumber =
+ await bridgeGovernance.bridgeGovernanceTransferChangeInitiated()
+
+ if (changeInitiated.eq(0)) {
+ const beginTx = await bridgeGovernance.beginBridgeGovernanceTransfer(
+ targetGovernance
+ )
+ console.log(
+ ` • Initiated governance transfer -> ${beginTx.hash}, waiting for receipt…`
+ )
+ await beginTx.wait()
+ console.log(
+ ` • Governance transfer initiated. Minimum delay: ${delay.toString()} seconds`
+ )
+ } else {
+ console.log(" • Governance transfer already initiated, skipping begin.")
+ }
+
+ const earliestFinalization =
+ changeInitiated.eq(0)
+ ? (await bridgeGovernance.bridgeGovernanceTransferChangeInitiated()).add(
+ delay
+ )
+ : changeInitiated.add(delay)
+
+ const block = await ethers.provider.getBlock("latest")
+ if (block.timestamp < earliestFinalization.toNumber()) {
+ const waitSeconds = earliestFinalization.toNumber() - block.timestamp
+ console.log(
+ ` • Waiting ${waitSeconds} seconds for governance delay to elapse…`
+ )
+ await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000))
+ }
+
+ const finalizeTx = await bridgeGovernance.finalizeBridgeGovernanceTransfer()
+ console.log(
+ ` • Finalizing governance transfer -> ${finalizeTx.hash}, waiting for receipt…`
+ )
+ await finalizeTx.wait()
+
+ const bridge = await ethers.getContractAt("Bridge", bridgeAddress)
+ const newGovernance = await bridge.governance()
+ console.log(
+ ` • Governance restored to ${newGovernance}. Expected: ${targetGovernance}`
+ )
+}
+
+async function resolveGovernanceSigner(
+ ethers: typeof hre.ethers,
+ getNamedAccounts: typeof hre.getNamedAccounts
+) {
+ if (process.env.BRIDGE_GOVERNANCE_PK) {
+ return new Wallet(process.env.BRIDGE_GOVERNANCE_PK, ethers.provider)
+ }
+ const { governance } = await getNamedAccounts()
+ return await ethers.getSigner(governance)
+}
+
+async function restoreControllerAllowlist(
+ snapshot: BridgeSnapshot,
+ currentGovernance: string
+): Promise {
+ console.log("\n[3/3] Restoring controller allowlist to pre-upgrade state…")
+
+ const desiredControllers = snapshot.authorizedControllers
+ .filter((c) => c.authorized)
+ .map((c) => c.address)
+
+ await syncBridgeControllerAuthorizations(hre, {
+ bridgeAddress: snapshot.bridgeAddress,
+ bridgeGovernanceAddress: currentGovernance,
+ increaserAddresses: desiredControllers,
+ governancePrivateKey: process.env.BRIDGE_GOVERNANCE_PK || undefined,
+ })
+}
+
+main().catch((error) => {
+ console.error("Bridge rollback failed:", error)
+ process.exitCode = 1
+})
diff --git a/solidity/scripts/upgrade-bridge-sepolia.ts b/solidity/scripts/upgrade-bridge-sepolia.ts
new file mode 100644
index 000000000..170f69dad
--- /dev/null
+++ b/solidity/scripts/upgrade-bridge-sepolia.ts
@@ -0,0 +1,517 @@
+/* eslint-disable no-console */
+import fs from "fs"
+import path from "path"
+import { BigNumber } from "ethers"
+import hre from "hardhat"
+import type { Contract } from "ethers"
+import upgradeBridge from "../deploy/80_upgrade_bridge_v2"
+import deployBridgeGovernance from "../deploy/09_deploy_bridge_governance"
+import transferBridgeGovernance from "../deploy/21_transfer_bridge_governance"
+import transferBridgeGovernanceOwnership from "../deploy/22_transfer_bridge_governance_ownership"
+import configureBridgeControllers from "../deploy/99_configure_bridge_controllers"
+
+type Address = string
+
+interface BigNumberRecord {
+ [key: string]: string
+}
+
+interface BridgeSnapshot {
+ label: string
+ network: string
+ timestamp: string
+ bridgeAddress: Address
+ bridgeImplementation: Address
+ bridgeGovernance: Address
+ proxyAdmin?: Address
+ depositParameters: BigNumberRecord
+ redemptionParameters: BigNumberRecord
+ movingFundsParameters: BigNumberRecord
+ walletParameters: BigNumberRecord
+ treasury: Address
+ authorizedControllers: { address: Address; authorized: boolean }[]
+ trustedVaults: { address: Address; trusted: boolean }[]
+ spvMaintainers: { address: Address; trusted: boolean }[]
+}
+
+const SNAPSHOT_OUTFILE = process.env.SNAPSHOT_OUTFILE
+const SHOULD_SYNC_CONTROLLERS =
+ process.env.SYNC_BRIDGE_CONTROLLERS === "true" ||
+ (process.env.BRIDGE_AUTHORIZED_INCREASERS?.length ?? 0) > 0
+const DRY_RUN =
+ process.env.BRIDGE_UPGRADE_DRY_RUN === "true" ||
+ process.env.DRY_RUN === "true" ||
+ process.argv.includes("--dry-run")
+
+if (
+ !process.env.BRIDGE_GOVERNANCE_PK &&
+ process.env.TLABS_SEPOLIA_BANK_OWNER_PK
+) {
+ process.env.BRIDGE_GOVERNANCE_PK =
+ process.env.TLABS_SEPOLIA_BANK_OWNER_PK
+ console.log(
+ "ℹ️ Falling back to TLABS_SEPOLIA_BANK_OWNER_PK for BRIDGE_GOVERNANCE_PK"
+ )
+}
+
+if (!SNAPSHOT_OUTFILE) {
+ console.warn(
+ "⚠️ SNAPSHOT_OUTFILE not set; snapshots will only be printed to the console."
+ )
+}
+
+function assertPrerequisites(): void {
+ if (!process.env.USE_EXTERNAL_DEPLOY) {
+ console.warn(
+ "⚠️ USE_EXTERNAL_DEPLOY is not set; make sure Sepolia uses external deploy keys."
+ )
+ }
+ if (SHOULD_SYNC_CONTROLLERS && !process.env.BRIDGE_GOVERNANCE_PK) {
+ console.warn(
+ "⚠️ Controller sync requested but BRIDGE_GOVERNANCE_PK is not configured. Falling back to Hardhat named account `governance`."
+ )
+ }
+ const networkUrl = (hre.network.config as any).url ?? ""
+ if (!networkUrl || networkUrl.trim().length === 0) {
+ const message =
+ "Sepolia RPC URL is not configured. Set SEPOLIA_CHAIN_API_URL in your environment."
+ if (DRY_RUN) {
+ console.warn(`⚠️ ${message}`)
+ } else {
+ throw new Error(message)
+ }
+ }
+}
+
+async function main(): Promise {
+ if (hre.network.name !== "sepolia") {
+ throw new Error(
+ `This script is tailored for Sepolia. Current network: ${hre.network.name}`
+ )
+ }
+
+ assertPrerequisites()
+
+ console.log("🚀 Starting Bridge upgrade orchestration for Sepolia")
+
+ let preSnapshot: BridgeSnapshot | undefined
+ try {
+ preSnapshot = await snapshotWithRetry("pre-upgrade")
+ await persistSnapshot(preSnapshot)
+ } catch (error) {
+ if (DRY_RUN) {
+ console.warn(
+ "⚠️ Failed to capture pre-upgrade snapshot during dry run:",
+ error
+ )
+ } else {
+ throw error
+ }
+ }
+
+ if (DRY_RUN) {
+ console.log(
+ "\n🛑 Dry-run mode enabled. Skipping on-chain transactions after recording pre-upgrade snapshot."
+ )
+ return
+ }
+
+ console.log("\n[1/4] Upgrading Bridge implementation…")
+ await upgradeBridge(hre)
+
+ console.log("[2/4] Redeploying BridgeGovernance…")
+ await deleteDeploymentIfExists("BridgeGovernance")
+ await deleteDeploymentIfExists("BridgeGovernanceParameters")
+ await deployBridgeGovernance(hre)
+
+ console.log("[3/4] Transferring Bridge governance to the new contract…")
+ await transferBridgeGovernance(hre)
+
+ // Ensure the freshly deployed BridgeGovernance is owned by the configured
+ // governance account (not the deployer), to match mainnet practices.
+ try {
+ await transferBridgeGovernanceOwnership(hre)
+ } catch (error) {
+ console.warn(
+ "⚠️ BridgeGovernance ownership transfer step failed or was skipped:",
+ error
+ )
+ }
+
+ if (!preSnapshot) {
+ throw new Error("Pre-upgrade snapshot missing; cannot reapply governance state.")
+ }
+ await reapplyGovernanceState(preSnapshot)
+
+ if (SHOULD_SYNC_CONTROLLERS) {
+ console.log("[4/4] Synchronizing authorized controller allowlist…")
+ await configureBridgeControllers(hre)
+ } else {
+ console.log(
+ "[4/4] Skipping controller allowlist sync (set SYNC_BRIDGE_CONTROLLERS=true or provide BRIDGE_AUTHORIZED_INCREASERS to run automatically)"
+ )
+ }
+
+ const postSnapshot = await snapshotWithRetry("post-upgrade")
+ await persistSnapshot(postSnapshot)
+
+ logSummary(preSnapshot, postSnapshot)
+}
+
+async function snapshotBridgeState(label: string): Promise {
+ const { deployments, ethers } = hre
+
+ const bridgeDeployment = await deployments.getOrNull("Bridge")
+ const bridgeAddress =
+ process.env.BRIDGE_ADDRESS ?? bridgeDeployment?.address
+ if (!bridgeAddress) {
+ throw new Error(
+ "Bridge address not found. Provide BRIDGE_ADDRESS in environment or ensure deployments cache exists."
+ )
+ }
+ const bridge: Contract = await ethers.getContractAt(
+ "Bridge",
+ bridgeAddress
+ )
+
+ const proxyAdminDeployment = await deployments.getOrNull(
+ "BridgeProxyAdminWithDeputy"
+ )
+ let proxyImplementation = "0x0000000000000000000000000000000000000000"
+ let proxyAdminAddress: string | undefined
+ if (proxyAdminDeployment) {
+ proxyAdminAddress = proxyAdminDeployment.address
+ const proxyAdmin = await ethers.getContractAt(
+ ["function getProxyImplementation(address) view returns (address)"],
+ proxyAdminDeployment.address
+ )
+ proxyImplementation = await proxyAdmin.getProxyImplementation(
+ bridgeAddress
+ )
+ } else {
+ proxyAdminAddress = process.env.BRIDGE_PROXY_ADMIN_ADDRESS
+ if (!proxyAdminAddress) {
+ throw new Error(
+ "BridgeProxyAdminWithDeputy deployment not found and BRIDGE_PROXY_ADMIN_ADDRESS not provided."
+ )
+ }
+ const proxyContract = await ethers.getContractAt(
+ ["function implementation() view returns (address)"],
+ bridgeAddress
+ )
+ try {
+ proxyImplementation = await proxyContract.implementation()
+ } catch {
+ proxyImplementation =
+ process.env.BRIDGE_IMPLEMENTATION_ADDRESS ?? proxyImplementation
+ }
+ }
+
+ const bridgeGovernanceAddress: string =
+ process.env.BRIDGE_GOVERNANCE_ADDRESS ?? (await bridge.governance())
+
+ const depositParameters = await bridge.depositParameters()
+ const redemptionParameters = await bridge.redemptionParameters()
+ const movingFundsParameters = await bridge.movingFundsParameters()
+ const walletParameters = await bridge.walletParameters()
+ const treasuryAddress: string = await bridge.treasury()
+
+ const skipEvents =
+ process.env.BRIDGE_SNAPSHOT_SKIP_EVENTS === "true" && label === "pre-upgrade"
+
+ const controllerMap = await fetchAuthorizedControllers(
+ bridge,
+ getBridgeDeployBlock(bridgeDeployment),
+ label,
+ skipEvents
+ )
+ const vaultStatusMap = await fetchAddressStatuses(
+ bridge,
+ bridge.filters.VaultStatusUpdated(),
+ getBridgeDeployBlock(bridgeDeployment),
+ label,
+ skipEvents
+ )
+ const spvStatusMap = await fetchAddressStatuses(
+ bridge,
+ bridge.filters.SpvMaintainerStatusUpdated(),
+ getBridgeDeployBlock(bridgeDeployment),
+ label,
+ skipEvents
+ )
+
+ return {
+ label,
+ network: hre.network.name,
+ timestamp: new Date().toISOString(),
+ bridgeAddress,
+ bridgeImplementation: proxyImplementation,
+ bridgeGovernance: bridgeGovernanceAddress,
+ proxyAdmin: proxyAdminAddress,
+ depositParameters: toRecord(depositParameters),
+ redemptionParameters: toRecord(redemptionParameters),
+ movingFundsParameters: toRecord(movingFundsParameters),
+ walletParameters: toRecord(walletParameters),
+ treasury: treasuryAddress,
+ authorizedControllers: Array.from(controllerMap.entries())
+ .map(([address, authorized]) => ({ address, authorized }))
+ .sort((a, b) => a.address.localeCompare(b.address)),
+ trustedVaults: Array.from(vaultStatusMap.entries())
+ .map(([address, trusted]) => ({ address, trusted }))
+ .sort((a, b) => a.address.localeCompare(b.address)),
+ spvMaintainers: Array.from(spvStatusMap.entries())
+ .map(([address, trusted]) => ({ address, trusted }))
+ .sort((a, b) => a.address.localeCompare(b.address)),
+ }
+}
+
+async function snapshotWithRetry(
+ label: string,
+ attempts = 5
+): Promise {
+ let lastError: unknown
+ for (let i = 0; i < attempts; i += 1) {
+ try {
+ return await snapshotBridgeState(label)
+ } catch (error: any) {
+ lastError = error
+ const isRateLimited =
+ typeof error?.message === "string" &&
+ /Too Many Requests/i.test(error.message)
+ const isNetworkError =
+ error?.code === "NETWORK_ERROR" ||
+ (typeof error?.message === "string" &&
+ /could not detect network/i.test(error.message))
+ if ((isRateLimited || isNetworkError) && i < attempts - 1) {
+ const backoffMs = 2000 * (i + 1)
+ console.warn(
+ `⚠️ Provider issue (${isRateLimited ? "rate limited" : "network error"}) while capturing ${label} snapshot. Retrying in ${
+ backoffMs / 1000
+ }s…`
+ )
+ await delay(backoffMs)
+ continue
+ }
+ throw error
+ }
+ }
+ throw lastError
+}
+
+async function fetchAuthorizedControllers(
+ bridge: Contract,
+ fromBlock: number | undefined,
+ label: string,
+ skip: boolean
+): Promise