diff --git a/.gitmodules b/.gitmodules index 690924b..b6697f7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,7 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/franchiser-expiry"] + path = lib/franchiser-expiry + url = https://github.com/scopelift/franchiser-expiry + branch = repo-updates diff --git a/AGENTS.md b/AGENTS.md index df77b5b..b117729 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,28 +83,52 @@ removing a module, expect to update this override set. ### Franchiser workstream The Franchiser contracts let a token holder delegate voting power to a delegatee who can in turn -sub-delegate it. Gitcoin will use the **"Expiry" variant** maintained by the Uniswap Foundation: -. - -**Compatibility (initial research).** The Franchiser interacts with **only the voting token** — it -delegates and moves tokens, and touches no Governor, Timelock, or other governance contract. At -runtime it calls only `delegate`, `balanceOf`, `transfer`/`transferFrom`, `allowance`, and `permit`, -all of which the COMP-style GTC token supports, so GTC is **runtime-compatible**. - -A verbatim import is nonetheless not straightforward, for reasons that are dependency- and -interface-level rather than behavioral: - -- Upstream pins **OpenZeppelin v4.x** and **solmate**, and types the token as OZ's `IVotes`. This - repo uses **OZ v5.6.1**, and GTC is a COMP-style token (`getPriorVotes` returning `uint96`) that - does not satisfy the `IVotes` interface. -- The likely path is therefore a **light adaptation**: point the Franchiser at a narrow, - COMP/EIP-2612-shaped token interface (this repo already has `src/interfaces/IComp.sol` as a - starting point) and reconcile dependencies (e.g. use OZ v5 `SafeERC20` in place of solmate's - transfer helpers). No changes to the Franchiser's core delegation logic appear necessary. - -The exact integration mechanism — vendoring an adapted copy vs. importing with an adapter, and how -the DAO administers delegations — is still to be settled; deploy scripts and fork tests follow once -it is. +sub-delegate it. Gitcoin uses the **"Expiry" variant** originally maintained by the Uniswap +Foundation, consumed as a git submodule at `lib/franchiser-expiry` pinned to the `repo-updates` +branch of **ScopeLift's fork**: . The fork updates +the upstream toolchain to match this repo — OpenZeppelin pinned to the **same v5.6.1 commit** as +`lib/openzeppelin-contracts`, solc 0.8.35, `via_ir` off — while leaving the contract logic +untouched: the source diff is mechanical (pragma bumps, import-path updates, and +`Address.isContract` → `code.length`, an API OZ v5 removed). +Remappings resolve the fork's `openzeppelin-contracts/` imports to this repo's OZ copy, and +`solmate/` to the fork's nested submodule. + +**Compatibility.** The Franchiser interacts with **only the voting token** — it touches no +Governor, Timelock, or other governance contract. At runtime it calls only `delegate`, +`balanceOf`, `transfer`/`transferFrom`, `allowance`, and `permit`, each verified present in GTC's +deployed bytecode. The upstream `IVotingToken` interface (`IERC20 + IERC20Permit + IVotes`) +declares functions GTC does not have (`getVotes`, `getPastVotes`, `DOMAIN_SEPARATOR`), but nothing +in the contracts calls them, and the interface is **deliberately left as-is**: Solidity does not +enforce interfaces at runtime, and keeping it avoids diverging further from the audited upstream +than the mechanical toolchain updates above (the fork carries a ChainSecurity audit; it covered +the 0.8.15/`via_ir` build, so the logic-level findings carry over but the compiled bytecode +differs). The `permitAndFund` entry points go unused +here — the funder is the Timelock, which cannot produce signatures. + +**Operations model.** The factory has no owner or admin; its only parameter is the token. Each +position is a `Franchiser` clone keyed by `(owner, delegatee)`, where the owner is the Timelock. +Funding and early recall are Timelock actions, i.e. governance proposals: `approve` + `fundMany` +to delegate (re-funding a live position tops it up and **overwrites its expiration**; a zero +amount adjusts the expiration alone), `recallMany` to unwind early. Once a position's expiration +passes, `recallExpired` is **permissionless** and always returns the tokens to the owner, so +expired delegations unwind without a proposal. Sub-delegation is the delegatee's own prerogative +(up to 8 sub-delegatees at the root, halving each nesting level). + +**Scripts** (each an abstract base plus a mainnet concrete, like the Governor's; all script bases +share `LoggedScript` for logging, and the two proposal bases share `ProposeFranchiserBase` for +their common validation): + +- `DeployFranchiser[Mainnet]` — deploys the `FranchiserExpiryFactory` (its constructor deploys the + canonical `Franchiser` implementation) and the read-only `FranchiserLens`. +- `ProposeFranchiserDelegation[Mainnet]` — a delegation round; **reused** by editing and + committing the round's delegatees/amounts/expiration, so git holds the delegation history. + Validates wiring, treasury balance, proposer threshold, and that the expiration outlives the + proposal pipeline (voting delay + period + potential late-quorum extension, Timelock delay, + grace period). +- `ProposeFranchiserRecall[Mainnet]` — early unwind of live positions, recipients ordinarily the + Timelock; a non-Timelock recipient is legal but triggers a prominent dry-run warning. +- `RecallExpiredFranchisers[Mainnet]` — permissionless sweep of expired positions; its delegatee + list is a candidate set filtered on-chain, so a superset (every delegatee ever funded) is safe. ## Deliverables @@ -200,8 +224,11 @@ secret). CI supplies it via the `MAINNET_RPC_URL` repository secret. for overrides; the override-resolution functions are documented with a short `@dev` explaining they disambiguate inherited modules. - **Tests:** structured for `scopelint spec` (test contracts/functions named after the unit under - test). None exist yet — the Governor's behavior and the migration both need coverage, including - mainnet fork tests. + test); the mainnet fork suites are described under [Current status](#current-status). `assert*` + is reserved for the claims a test makes about the system under test; checks on test assumptions + or scaffolding (setUp fork state, helper lifecycle checkpoints, scenario preconditions) instead + revert with a developer-aimed message naming the broken assumption, so a failure reads as + "repair the test setup," not "a behavior regressed." - **Keep docs current.** `README.md` is intentionally lightweight and reflects the project's in-progress status. As scripts, tests, and contracts mature, update the README in the **same change** that introduces them — document a script's usage when the script lands, and drop the "under @@ -230,8 +257,24 @@ secret). CI supplies it via the `MAINNET_RPC_URL` repository secret. `PROPOSER` delegate still clears the proposal threshold and the electorate still clears quorum (`setUp` asserts both weights loudly, and quorum-boundary tests assert their own weight preconditions). -- No Franchiser code yet. -- Up next: the Franchiser workstream (see [Deliverables](#deliverables)). +- The Franchiser contracts are in place as the `lib/franchiser-expiry` submodule (ScopeLift fork, + `repo-updates` branch), and all four **Franchiser script pairs** are written: + `DeployFranchiser[Mainnet]`, `ProposeFranchiserDelegation[Mainnet]`, + `ProposeFranchiserRecall[Mainnet]`, and `RecallExpiredFranchisers[Mainnet]`. The deploy script + dry-runs clean against a mainnet fork; the proposal and sweep concretes carry `TODO`s (factory + and new-Governor addresses, proposer, per-round delegations) and revert until those are set. +- The **Franchiser fork integration suites** (`test/PostUpgradeFranchiser*.integration.t.sol`) + are in place: each runs the full Governor upgrade in `setUp` (the production sequence), deploys + the Franchiser system with the real deploy script via a `_fetchOrDeployFranchiser` provenance + hook, and drives the operations scripts through constructor-injected test configs in + `test/helpers/`. Coverage spans delegation rounds (fresh/existing delegatees, top-ups and + expiration overwrites, zero-amount adjustments, defeats), early recalls (including + sub-delegation clawback and the in-flight-snapshot property — a recall cannot strip weight from + proposals already snapshotted), expiry sweeps (permissionless, candidate filtering, weight + persists until swept), and the scripts' validation reverts. Shared helpers live in + `test/helpers/PostUpgradeFranchiserTestBase.sol`. +- Up next: confirm the outstanding `TODO`s with Gitcoin stakeholders, deploy the new Governor, + and run the upgrade proposal (see [Deliverables](#deliverables)). - CI runs `forge build`, `forge test`, and `scopelint check`. Coverage and Slither jobs are scaffolded but commented out in `.github/workflows/ci.yml`. diff --git a/README.md b/README.md index 0fdcb7e..6f1ea06 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,16 @@ scopelint check # verify formatting and conventions (also run in CI) - `src/extensions/` — custom Governor extensions: `GovernorVotesComp` (sources votes from the COMP-style GTC token) and `GovernorSettableFixedQuorum` (a fixed quorum the DAO can update). - `src/interfaces/` — supporting interfaces (e.g. `IComp`). +- `lib/franchiser-expiry` — the Franchiser contracts, consumed as a git submodule of + [ScopeLift's fork](https://github.com/ScopeLift/franchiser-expiry) of the Uniswap Foundation's + [franchiser-expiry](https://github.com/uniswapfoundation/franchiser-expiry). The fork updates + the build toolchain to match this repo (OpenZeppelin v5, solc 0.8.35) without changing the + contracts' logic. - `AGENTS.md` — project context, architecture, and conventions for contributors and coding agents. - `foundry.toml` — Foundry build profiles and formatting configuration. -- `script/` — deployment and governance-proposal scripts (see [Scripts](#scripts)). The Governor - deploy and upgrade-proposal scripts are in place; Franchiser scripts are still being built. +- `script/` — deployment and governance-proposal scripts (see [Scripts](#scripts)) for both the + Governor upgrade and the Franchiser system. - `test/` — mainnet fork integration tests simulating the full upgrade and exercising the upgraded Governor (see [Testing](#testing)). @@ -106,8 +111,103 @@ forge script script/ProposeGovernorUpgradeMainnet.s.sol:ProposeGovernorUpgradeMa --broadcast ``` -> 🚧 **Still under development.** The Franchiser scripts — deployment and delegation — are not yet -> available. Usage instructions will be documented here as they land. +### Deploy the Franchiser system + +`script/DeployFranchiser.s.sol` holds the reusable deployment mechanics, and +`script/DeployFranchiserMainnet.s.sol` supplies the mainnet configuration — just the GTC token, +since the Franchiser contracts have no owner, admin, or other parameters. The script deploys the +`FranchiserExpiryFactory` (whose constructor also deploys the canonical `Franchiser` implementation +that every delegation is cloned from) and the read-only `FranchiserLens` for inspecting delegations. + +Dry-run first and review the two transactions it would send: + +```sh +forge script script/DeployFranchiserMainnet.s.sol:DeployFranchiserMainnet \ + --rpc-url "$MAINNET_RPC_URL" +``` + +Then broadcast and verify (using an encrypted keystore account set up with `cast wallet import`): + +```sh +forge script script/DeployFranchiserMainnet.s.sol:DeployFranchiserMainnet \ + --rpc-url "$MAINNET_RPC_URL" \ + --account deployer \ + --broadcast \ + --verify +``` + +### Propose Franchiser delegations + +`script/ProposeFranchiserDelegation.s.sol` holds the reusable proposal mechanics, and +`script/ProposeFranchiserDelegationMainnet.s.sol` supplies the configuration for a delegation +round. Run by a delegate, it submits a two-action proposal to the Governor: the Timelock approves +the factory for the round's total amount, and the factory pulls the tokens into one Franchiser per +delegatee (`fundMany`), delegating each balance to its delegatee until the round's expiration. + +Unlike the one-time upgrade proposal, this script is reused: each round edits the delegatees, +amounts, expiration, and proposal text in the mainnet configuration, and commits the edit so the +repository keeps a history of every round. Funding a delegatee who already has a live position +tops it up and overwrites the position's expiration — a zero amount adjusts the expiration alone. + +Before broadcasting, the script validates the round: the factory and Governor share the same +token, the Timelock holds the total being delegated, the proposer clears the proposal threshold, +no delegatee is duplicated or the zero address, and the expiration outlives the full proposal +pipeline (voting delay and period plus the potential late-quorum extension, Timelock delay, and +grace period), since funding reverts if the expiration has passed by execution. + +```sh +# Dry-run, then broadcast as the proposer: +forge script script/ProposeFranchiserDelegationMainnet.s.sol:ProposeFranchiserDelegationMainnet \ + --rpc-url "$MAINNET_RPC_URL" + +forge script script/ProposeFranchiserDelegationMainnet.s.sol:ProposeFranchiserDelegationMainnet \ + --rpc-url "$MAINNET_RPC_URL" \ + --account proposer \ + --broadcast +``` + +### Propose Franchiser recalls + +`script/ProposeFranchiserRecall.s.sol` and `script/ProposeFranchiserRecallMainnet.s.sol` unwind +delegations **before** they expire — the DAO's lever if a delegatee goes inactive or rogue. The +proposal carries one action, `factory.recallMany`, returning each position's tokens (including any +the delegatee sub-delegated) to a recipient, ordinarily the Timelock — any other recipient sends +treasury funds elsewhere, so the dry run prints a prominent warning for each one. Like the +delegation script, each recall edits and commits the mainnet configuration, and the script +validates the positions exist before proposing. Expired positions don't need a proposal — see the +next section. + +```sh +# Dry-run, then broadcast as the proposer: +forge script script/ProposeFranchiserRecallMainnet.s.sol:ProposeFranchiserRecallMainnet \ + --rpc-url "$MAINNET_RPC_URL" + +forge script script/ProposeFranchiserRecallMainnet.s.sol:ProposeFranchiserRecallMainnet \ + --rpc-url "$MAINNET_RPC_URL" \ + --account proposer \ + --broadcast +``` + +### Recall expired Franchiser positions + +`script/RecallExpiredFranchisers.s.sol` and `script/RecallExpiredFranchisersMainnet.s.sol` sweep +expired positions back to the Timelock with `factory.recallManyExpired`. This is **not** a +governance action: recalling an expired position is permissionless and the tokens always return +to the position's owner, so anyone can run it from any funded account. The configured delegatee +list is a candidate set — the script checks each candidate on-chain and recalls only the positions +that exist and have expired — so the intended maintenance is to keep every delegatee the DAO has +ever funded on the list. + +```sh +# Dry-run, then broadcast from any account: +forge script script/RecallExpiredFranchisersMainnet.s.sol:RecallExpiredFranchisersMainnet \ + --rpc-url "$MAINNET_RPC_URL" + +forge script script/RecallExpiredFranchisersMainnet.s.sol:RecallExpiredFranchisersMainnet \ + --rpc-url "$MAINNET_RPC_URL" \ + --account keeper \ + --broadcast +``` ## Testing @@ -127,11 +227,28 @@ which the suites exercise the upgraded Governor in place: - `PostUpgradeProposalGuardian` — the Proposal Guardian cancelling proposals at every cancelable lifecycle stage, the limits of that power, and the DAO replacing the guardian. +The Franchiser suites run the same full upgrade in `setUp` — matching the production sequence, +where Franchiser adoption follows the Governor upgrade — then deploy the Franchiser system with +the real deploy script and drive the operations scripts end-to-end: + +- `PostUpgradeFranchiserDeploy` — the deployed factory, `Franchiser` implementation, and lens are + wired to GTC and to each other. +- `PostUpgradeFranchiserDelegation` — delegation rounds passing (and failing) through governance: + fresh and already-delegated delegatees, multi-delegatee rounds, top-ups that overwrite a + position's expiration, zero-amount expiration adjustments, snapshot timing, and the delegation + script's validation rules. +- `PostUpgradeFranchiserRecall` — early recalls returning tokens and weight to the Timelock, + clawing back sub-delegated tokens, the snapshot weight a recall cannot reach, and negative + tests that neither delegatees nor third parties can move delegated tokens. +- `PostUpgradeFranchiserExpiry` — the permissionless sweep returning expired positions, its + on-chain candidate filtering, the weight that persists until a sweep actually runs, and the + guard protecting live positions. + Each suite is written against an abstract base that leaves *how the system comes into being* to a small concrete contract at the bottom of the file. Today each file has a `…MainnetScript` concrete -that deploys via the real deploy script; once the new Governor is live on mainnet, a -`…MainnetDeployed` concrete pointing at the deployed address can rerun the same suites as a -post-deployment acceptance check. +that deploys via the real deploy scripts; once the new Governor and the Franchiser system are live +on mainnet, `…MainnetDeployed` concretes pointing at the deployed addresses can rerun the same +suites as a post-deployment acceptance check. ## License diff --git a/foundry.lock b/foundry.lock index 4b4a8ae..2bc5810 100644 --- a/foundry.lock +++ b/foundry.lock @@ -5,6 +5,12 @@ "rev": "620536fa5277db4e3fd46772d5cbc1ea0696fb43" } }, + "lib/franchiser-expiry": { + "branch": { + "name": "repo-updates", + "rev": "06bcec71e3c7deb15a09d52ac8c1323101dfffaa" + } + }, "lib/openzeppelin-contracts": { "tag": { "name": "v5.6.1", diff --git a/foundry.toml b/foundry.toml index aae3860..922f4c1 100644 --- a/foundry.toml +++ b/foundry.toml @@ -4,6 +4,9 @@ "lib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol", "lib/openzeppelin-contracts/contracts/utils/structs/DoubleEndedQueue.sol", "lib/openzeppelin-contracts/contracts/utils/structs/Checkpoints.sol", + "lib/openzeppelin-contracts/contracts/utils/structs/EnumerableSet.sol", + "lib/franchiser-expiry/lib/solmate/src/auth/Owned.sol", + "lib/franchiser-expiry/lib/solmate/src/utils/SafeTransferLib.sol", ] optimizer = true optimizer_runs = 10_000_000 diff --git a/lib/franchiser-expiry b/lib/franchiser-expiry new file mode 160000 index 0000000..06bcec7 --- /dev/null +++ b/lib/franchiser-expiry @@ -0,0 +1 @@ +Subproject commit 06bcec71e3c7deb15a09d52ac8c1323101dfffaa diff --git a/script/DeployFranchiser.s.sol b/script/DeployFranchiser.s.sol new file mode 100644 index 0000000..fb9aefb --- /dev/null +++ b/script/DeployFranchiser.s.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {FranchiserLens} from "franchiser-expiry/src/FranchiserLens.sol"; +import {IVotingToken} from "franchiser-expiry/src/interfaces/IVotingToken.sol"; +import {LoggedScript} from "script/LoggedScript.sol"; + +/// @notice Abstract base that holds the mechanics of deploying the Franchiser system: the +/// `FranchiserExpiryFactory` (whose constructor also deploys the canonical `Franchiser` +/// implementation it clones) and the read-only `FranchiserLens` for inspecting delegations. +/// A concrete contract supplies the configuration for a specific deployment by implementing +/// `_getDeploymentParams`. +abstract contract DeployFranchiser is LoggedScript { + struct DeploymentParams { + IVotingToken votingToken; + } + + FranchiserExpiryFactory public factory; + FranchiserLens public lens; + + function run() public virtual { + DeploymentParams memory _params = _getDeploymentParams(); + _validateDeploymentParams(_params); + + _log("Deploying the Franchiser system with:"); + _log(string.concat(" votingToken: ", vm.toString(address(_params.votingToken)))); + + vm.startBroadcast(); + // BROADCAST: deploy the FranchiserExpiryFactory (its constructor also deploys the canonical + // Franchiser implementation that all positions are cloned from) + _log("[1/2] Deploying FranchiserExpiryFactory"); + factory = new FranchiserExpiryFactory(_params.votingToken); + // BROADCAST: deploy the FranchiserLens wired to the factory + _log("[2/2] Deploying FranchiserLens"); + lens = new FranchiserLens(_params.votingToken, factory); + vm.stopBroadcast(); + + _log(string.concat("FranchiserExpiryFactory deployed at ", vm.toString(address(factory)))); + _log( + string.concat( + "Franchiser implementation deployed at ", + vm.toString(address(factory.franchiserImplementation())) + ) + ); + _log(string.concat("FranchiserLens deployed at ", vm.toString(address(lens)))); + + _validateDeployment(_params); + } + + function _getDeploymentParams() internal view virtual returns (DeploymentParams memory); + + function _validateDeploymentParams(DeploymentParams memory _params) internal view { + if (address(_params.votingToken) == address(0)) { + revert( + "DeployFranchiser: votingToken is the zero address; " + "set it to the address of the COMP-style GTC token" + ); + } + if (address(_params.votingToken).code.length == 0) { + revert( + string.concat( + "DeployFranchiser: votingToken ", + vm.toString(address(_params.votingToken)), + " has no code on this network; check the address and the RPC endpoint" + ) + ); + } + } + + function _validateDeployment(DeploymentParams memory _params) internal view { + if (address(factory.votingToken()) != address(_params.votingToken)) { + revert( + string.concat( + "DeployFranchiser: the deployed factory's voting token is ", + vm.toString(address(factory.votingToken())), + " but expected ", + vm.toString(address(_params.votingToken)) + ) + ); + } + if (address(factory.franchiserImplementation()).code.length == 0) { + revert( + "DeployFranchiser: the factory did not deploy a Franchiser implementation; " + "this should be impossible and indicates a broken factory" + ); + } + if (address(factory.franchiserImplementation().votingToken()) != address(_params.votingToken)) { + revert( + string.concat( + "DeployFranchiser: the Franchiser implementation's voting token is ", + vm.toString(address(factory.franchiserImplementation().votingToken())), + " but expected ", + vm.toString(address(_params.votingToken)) + ) + ); + } + if (address(lens.votingToken()) != address(_params.votingToken)) { + revert( + string.concat( + "DeployFranchiser: the deployed lens's voting token is ", + vm.toString(address(lens.votingToken())), + " but expected ", + vm.toString(address(_params.votingToken)) + ) + ); + } + if (address(lens.franchiserFactory()) != address(factory)) { + revert( + string.concat( + "DeployFranchiser: the deployed lens's factory is ", + vm.toString(address(lens.franchiserFactory())), + " but expected the factory deployed by this run, ", + vm.toString(address(factory)) + ) + ); + } + } +} diff --git a/script/DeployFranchiserMainnet.s.sol b/script/DeployFranchiserMainnet.s.sol new file mode 100644 index 0000000..154f7e4 --- /dev/null +++ b/script/DeployFranchiserMainnet.s.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {IVotingToken} from "franchiser-expiry/src/interfaces/IVotingToken.sol"; +import {DeployFranchiser} from "script/DeployFranchiser.s.sol"; + +/// @notice Mainnet deployment configuration for the Franchiser system. The only parameter is the +/// GTC token: the factory has no owner, no admin, and nothing else to configure. GTC does not +/// implement the full `IVotingToken` interface the Franchiser contracts declare (it is a +/// COMP-style token), but it exposes every function they actually call — `delegate`, `balanceOf`, +/// `transfer`, `transferFrom`, `allowance`, and `permit` — so the cast below is sound at runtime. +contract DeployFranchiserMainnet is DeployFranchiser { + // Fixed value that can't change + IVotingToken constant GTC_TOKEN = IVotingToken(0xDe30da39c46104798bB5aA3fe8B9e0e1F348163F); + + // Boilerplate override so this file declares the public `run()` that scopelint's script rule + // looks for; the mechanics all live in the base. + function run() public override { + super.run(); + } + + function _getDeploymentParams() internal pure override returns (DeploymentParams memory) { + return DeploymentParams({votingToken: GTC_TOKEN}); + } +} diff --git a/script/DeployGitcoinGovernorWithGuardian.s.sol b/script/DeployGitcoinGovernorWithGuardian.s.sol index b8293a5..df94806 100644 --- a/script/DeployGitcoinGovernorWithGuardian.s.sol +++ b/script/DeployGitcoinGovernorWithGuardian.s.sol @@ -1,16 +1,15 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.35; -import {Script} from "forge-std/Script.sol"; -import {console2} from "forge-std/console2.sol"; import {ICompoundTimelock} from "@openzeppelin/contracts/vendor/compound/ICompoundTimelock.sol"; +import {LoggedScript} from "script/LoggedScript.sol"; import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; import {IComp} from "src/interfaces/IComp.sol"; /// @notice Abstract base that holds the mechanics of deploying a `GitcoinGovernorWithGuardian`. /// A concrete contract supplies the configuration for a specific deployment by implementing /// `_getDeploymentParams`. -abstract contract DeployGitcoinGovernorWithGuardian is Script { +abstract contract DeployGitcoinGovernorWithGuardian is LoggedScript { struct DeploymentParams { string name; uint256 initialQuorum; @@ -24,11 +23,10 @@ abstract contract DeployGitcoinGovernorWithGuardian is Script { } GitcoinGovernorWithGuardian public governor; - bool internal isLogging = true; function run() public virtual { DeploymentParams memory _params = _getDeploymentParams(); - _revertIfDeploymentParamsAreInvalid(_params); + _validateDeploymentParams(_params); _log("Deploying GitcoinGovernorWithGuardian with:"); _log(string.concat(" name: ", _params.name)); @@ -63,22 +61,12 @@ abstract contract DeployGitcoinGovernorWithGuardian is Script { _log(string.concat("GitcoinGovernorWithGuardian deployed at ", vm.toString(address(governor)))); - _revertIfDeploymentIsInvalid(_params); - } - - function disableLogging() public { - isLogging = false; + _validateDeployment(_params); } function _getDeploymentParams() internal view virtual returns (DeploymentParams memory); - function _log(string memory _msg) internal view { - if (isLogging) { - console2.log(_msg); - } - } - - function _revertIfDeploymentParamsAreInvalid(DeploymentParams memory _params) internal pure { + function _validateDeploymentParams(DeploymentParams memory _params) internal pure { if (address(_params.token) == address(0)) { revert( "DeployGitcoinGovernorWithGuardian: token is the zero address; " @@ -100,7 +88,7 @@ abstract contract DeployGitcoinGovernorWithGuardian is Script { } } - function _revertIfDeploymentIsInvalid(DeploymentParams memory _params) internal view { + function _validateDeployment(DeploymentParams memory _params) internal view { if (address(governor.token()) != address(_params.token)) { revert( string.concat( diff --git a/script/LoggedScript.sol b/script/LoggedScript.sol new file mode 100644 index 0000000..2c329e3 --- /dev/null +++ b/script/LoggedScript.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; + +/// @notice Shared logging infrastructure for the script bases: an `isLogging` flag that defaults +/// to on, the `_log` helper every script routes its terminal output through, and the public +/// `disableLogging` affordance a test calls to keep a script run out of its output. +abstract contract LoggedScript is Script { + bool internal isLogging = true; + + function disableLogging() public { + isLogging = false; + } + + function _log(string memory _msg) internal view { + if (isLogging) { + console2.log(_msg); + } + } +} diff --git a/script/ProposeFranchiserBase.sol b/script/ProposeFranchiserBase.sol new file mode 100644 index 0000000..6a37923 --- /dev/null +++ b/script/ProposeFranchiserBase.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {LoggedScript} from "script/LoggedScript.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; + +/// @notice Shared mechanics for the scripts that submit a Franchiser operation to the Governor as +/// a proposal (`ProposeFranchiserDelegation` and `ProposeFranchiserRecall`): the submitted +/// proposal id, and the validations every such proposal needs — the governance wiring, the +/// delegatee list, and the proposer clearing the proposal threshold. Checks specific to one +/// operation stay in its script; `_scriptName` prefixes the shared revert messages so each still +/// names the script that raised it. +abstract contract ProposeFranchiserBase is LoggedScript { + uint256 public proposalId; + + function _scriptName() internal pure virtual returns (string memory); + + function _validateGovernanceWiring( + GitcoinGovernorWithGuardian _governor, + FranchiserExpiryFactory _factory, + address _proposer, + string memory _description + ) internal view { + if (address(_governor) == address(0)) { + revert( + string.concat( + _scriptName(), + ": governor is the zero address; " + "set it to the address of the Governor that controls the Timelock" + ) + ); + } + if (address(_factory) == address(0)) { + revert( + string.concat( + _scriptName(), + ": factory is the zero address; " + "set it to the address of the deployed FranchiserExpiryFactory" + ) + ); + } + if (_proposer == address(0)) { + revert( + string.concat( + _scriptName(), + ": proposer is the zero address; set it to the delegate submitting the proposal" + ) + ); + } + if (bytes(_description).length == 0) { + revert( + string.concat(_scriptName(), ": description is empty; set it to the text of the proposal") + ); + } + if (address(_factory.votingToken()) != address(_governor.token())) { + revert( + string.concat( + _scriptName(), + ": the factory's voting token is ", + vm.toString(address(_factory.votingToken())), + " but the governor's token is ", + vm.toString(address(_governor.token())), + "; the factory and the governor must be wired to the same token" + ) + ); + } + } + + // Every proposal delegatee list must be non-empty and hold no zero or repeated addresses. What + // to do about a repeated address differs by operation, so the script supplies the hint that + // completes the duplicate-entry message. + function _validateDelegatees(address[] memory _delegatees, string memory _duplicateEntryHint) + internal + pure + { + if (_delegatees.length == 0) { + revert( + string.concat(_scriptName(), ": no delegatees; populate the delegatees for this proposal") + ); + } + for (uint256 _index = 0; _index < _delegatees.length; _index += 1) { + if (_delegatees[_index] == address(0)) { + revert( + string.concat( + _scriptName(), ": the delegatee at index ", vm.toString(_index), " is the zero address" + ) + ); + } + for (uint256 _priorIndex = 0; _priorIndex < _index; _priorIndex += 1) { + if (_delegatees[_priorIndex] == _delegatees[_index]) { + revert( + string.concat( + _scriptName(), + ": the delegatee ", + vm.toString(_delegatees[_index]), + " appears more than once; ", + _duplicateEntryHint + ) + ); + } + } + } + } + + function _validateProposerMeetsThreshold(GitcoinGovernorWithGuardian _governor, address _proposer) + internal + view + { + uint256 _proposerVotes = _governor.getVotes(_proposer, block.number - 1); + if (_proposerVotes < _governor.proposalThreshold()) { + revert( + string.concat( + _scriptName(), + ": the proposer's voting weight of ", + vm.toString(_proposerVotes), + " is below the governor's proposal threshold of ", + vm.toString(_governor.proposalThreshold()), + "; the proposer must hold or be delegated at least the threshold" + ) + ); + } + } +} diff --git a/script/ProposeFranchiserDelegation.s.sol b/script/ProposeFranchiserDelegation.s.sol new file mode 100644 index 0000000..acaf8e0 --- /dev/null +++ b/script/ProposeFranchiserDelegation.s.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ICompoundTimelock} from "@openzeppelin/contracts/vendor/compound/ICompoundTimelock.sol"; +import {Franchiser} from "franchiser-expiry/src/Franchiser.sol"; +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {IVotingToken} from "franchiser-expiry/src/interfaces/IVotingToken.sol"; +import {ProposeFranchiserBase} from "script/ProposeFranchiserBase.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; + +/// @notice Abstract base that holds the mechanics of proposing a round of Franchiser delegations: +/// a proposal, submitted to the Governor that controls the DAO's Timelock, that delegates treasury +/// tokens held by the Timelock to one or more delegatees through the `FranchiserExpiryFactory`. +/// The proposal carries two actions: +/// +/// 1. `votingToken.approve(factory, totalAmount)` — the Timelock approves the factory for the sum +/// of all delegated amounts. +/// 2. `factory.fundMany(delegatees, amounts, expiration)` — the factory pulls the tokens into one +/// Franchiser per delegatee, each of which delegates its balance to its delegatee. +/// +/// Funding a delegatee who already has a live position tops it up and overwrites the position's +/// expiration; a zero amount for such a delegatee adjusts the expiration alone. All positions +/// funded by one proposal share a single expiration; propose multiple rounds for differing ones. +/// +/// A concrete contract supplies the configuration for a specific proposal by implementing +/// `_getProposalParams`. +abstract contract ProposeFranchiserDelegation is ProposeFranchiserBase { + struct ProposalParams { + GitcoinGovernorWithGuardian governor; + FranchiserExpiryFactory factory; + address proposer; + address[] delegatees; + uint256[] amounts; + uint256 expiration; + string description; + } + + // Mainnet block cadence, used to convert the Governor's block-denominated voting pipeline into + // seconds when validating the expiration. + uint256 constant SECONDS_PER_BLOCK = 12; + + function run() public virtual { + ProposalParams memory _params = _getProposalParams(); + _validateProposalParams(_params); + + (address[] memory _targets, uint256[] memory _values, bytes[] memory _calldatas) = + _buildProposalActions(_params); + + _logProposalSummary(_params); + + vm.startBroadcast(_params.proposer); + // BROADCAST: submit the delegation proposal to the Governor + _log("[1/1] Submitting the delegation proposal to the Governor"); + proposalId = _params.governor.propose(_targets, _values, _calldatas, _params.description); + vm.stopBroadcast(); + + _log(string.concat("Delegation proposal submitted with id ", vm.toString(proposalId))); + } + + function _getProposalParams() internal view virtual returns (ProposalParams memory); + + function _scriptName() internal pure override returns (string memory) { + return "ProposeFranchiserDelegation"; + } + + function _buildProposalActions(ProposalParams memory _params) + internal + view + returns (address[] memory _targets, uint256[] memory _values, bytes[] memory _calldatas) + { + _targets = new address[](2); + _values = new uint256[](2); + _calldatas = new bytes[](2); + + _targets[0] = address(_params.factory.votingToken()); + _calldatas[0] = + abi.encodeCall(IERC20.approve, (address(_params.factory), _sumOf(_params.amounts))); + + _targets[1] = address(_params.factory); + _calldatas[1] = abi.encodeCall( + _params.factory.fundMany, (_params.delegatees, _params.amounts, _params.expiration) + ); + } + + function _sumOf(uint256[] memory _amounts) internal pure returns (uint256 _total) { + for (uint256 _index = 0; _index < _amounts.length; _index += 1) { + _total += _amounts[_index]; + } + } + + function _logProposalSummary(ProposalParams memory _params) internal view { + address _timelock = _params.governor.timelock(); + _log("Proposing Franchiser delegations with:"); + _log(string.concat(" governor: ", vm.toString(address(_params.governor)))); + _log(string.concat(" factory: ", vm.toString(address(_params.factory)))); + _log(string.concat(" votingToken: ", vm.toString(address(_params.factory.votingToken())))); + _log(string.concat(" timelock: ", vm.toString(_timelock))); + _log(string.concat(" proposer: ", vm.toString(_params.proposer))); + _log(string.concat(" expiration: ", vm.toString(_params.expiration))); + _log(string.concat(" totalAmount: ", vm.toString(_sumOf(_params.amounts)))); + _log(string.concat(" description: ", _params.description)); + _log(" delegations:"); + for (uint256 _index = 0; _index < _params.delegatees.length; _index += 1) { + Franchiser _franchiser = _params.factory.getFranchiser(_timelock, _params.delegatees[_index]); + string memory _status; + if (address(_franchiser).code.length == 0) { + _status = "new position"; + } else { + _status = "existing position - overwrites its expiration"; + } + _log( + string.concat( + " ", + vm.toString(_params.amounts[_index]), + " -> ", + vm.toString(_params.delegatees[_index]), + " (franchiser ", + vm.toString(address(_franchiser)), + ", ", + _status, + ")" + ) + ); + } + } + + function _validateProposalParams(ProposalParams memory _params) internal view { + _validateGovernanceWiring( + _params.governor, _params.factory, _params.proposer, _params.description + ); + _validateDelegatees(_params.delegatees, "combine their amounts into a single entry"); + if (_params.delegatees.length != _params.amounts.length) { + revert( + string.concat( + "ProposeFranchiserDelegation: ", + vm.toString(_params.delegatees.length), + " delegatees but ", + vm.toString(_params.amounts.length), + " amounts; every delegatee needs exactly one amount" + ) + ); + } + + address _timelock = _params.governor.timelock(); + for (uint256 _index = 0; _index < _params.delegatees.length; _index += 1) { + address _delegatee = _params.delegatees[_index]; + if ( + _params.amounts[_index] == 0 + && address(_params.factory.getFranchiser(_timelock, _delegatee)).code.length == 0 + ) { + revert( + string.concat( + "ProposeFranchiserDelegation: the amount for delegatee ", + vm.toString(_delegatee), + " is zero but they have no existing position; a zero amount is only meaningful as " + "an expiration adjustment to an existing position" + ) + ); + } + } + + IVotingToken _votingToken = _params.factory.votingToken(); + uint256 _totalAmount = _sumOf(_params.amounts); + if (_votingToken.balanceOf(_timelock) < _totalAmount) { + revert( + string.concat( + "ProposeFranchiserDelegation: the proposal delegates ", + vm.toString(_totalAmount), + " tokens but the Timelock holds only ", + vm.toString(_votingToken.balanceOf(_timelock)), + "; the Timelock must hold the full amount (checked now, and required again at execution)" + ) + ); + } + _validateProposerMeetsThreshold(_params.governor, _params.proposer); + _validateExpirationOutlivesPipeline(_params); + } + + // `fund` reverts if the expiration has already passed when the proposal executes, and execution + // can legitimately happen as late as the Timelock's grace period after the proposal's eta. So + // the expiration must outlive the whole pipeline: the voting delay and period plus the + // late-quorum vote extension — quorum arriving at the deadline extends voting by up to that + // many + // blocks — (block-denominated and converted at SECONDS_PER_BLOCK), the Timelock delay, and the + // grace period. This assumes the proposal is queued promptly once it succeeds. + function _validateExpirationOutlivesPipeline(ProposalParams memory _params) internal view { + ICompoundTimelock _timelock = ICompoundTimelock(payable(_params.governor.timelock())); + uint256 _votingPipelineSeconds = SECONDS_PER_BLOCK + * (_params.governor.votingDelay() + + _params.governor.votingPeriod() + + _params.governor.lateQuorumVoteExtension()); + uint256 _minimumExpiration = + block.timestamp + _votingPipelineSeconds + _timelock.delay() + _timelock.GRACE_PERIOD(); + if (_params.expiration < _minimumExpiration) { + revert( + string.concat( + "ProposeFranchiserDelegation: the expiration ", + vm.toString(_params.expiration), + " is too soon; it must be at least ", + vm.toString(_minimumExpiration), + " so the positions cannot expire before the proposal's last legitimate execution time" + ) + ); + } + } +} diff --git a/script/ProposeFranchiserDelegationMainnet.s.sol b/script/ProposeFranchiserDelegationMainnet.s.sol new file mode 100644 index 0000000..a4d47f0 --- /dev/null +++ b/script/ProposeFranchiserDelegationMainnet.s.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {ProposeFranchiserDelegation} from "script/ProposeFranchiserDelegation.s.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; + +/// @notice Mainnet configuration for a round of Franchiser delegations. Unlike the one-time +/// Governor upgrade proposal, this script is reused: each delegation round edits the delegatees, +/// amounts, expiration, and description below, and the edit is committed so the repository keeps +/// a history of every round the DAO has proposed. +contract ProposeFranchiserDelegationMainnet is ProposeFranchiserDelegation { + // TODO: Set to the GitcoinGovernorWithGuardian address once it is deployed to mainnet and + // controls the Timelock. The zero address makes this script revert until then. + GitcoinGovernorWithGuardian constant GOVERNOR = GitcoinGovernorWithGuardian(payable(address(0))); + + // TODO: Set to the FranchiserExpiryFactory address once it is deployed to mainnet. The zero + // address makes this script revert until then. + FranchiserExpiryFactory constant FACTORY = FranchiserExpiryFactory(address(0)); + + // TODO: Set to the delegate who will submit the proposal. They must hold or be delegated voting + // weight of at least the Governor's proposal threshold. The zero address makes this script + // revert until a proposer is confirmed. + address constant PROPOSER = address(0); + + // TODO: Set to this round's expiration as a unix timestamp. Every position funded by this round + // shares it, and funding a delegatee with a live position overwrites that position's + // expiration. It must be far enough out to survive the full proposal pipeline — voting delay, + // voting period, Timelock delay, and grace period — which the script enforces. + uint256 constant EXPIRATION = 0; + + // TODO: Finalize this round's proposal text before proposing. This description is stored + // on-chain (hashed) and displayed by governance UIs like Tally. + string constant DESCRIPTION = ""; + + // Boilerplate override so this file declares the public `run()` that scopelint's script rule + // looks for; the mechanics all live in the base. + function run() public override { + super.run(); + } + + function _getProposalParams() internal pure override returns (ProposalParams memory) { + // TODO: Populate this round's delegations. Each entry pairs a delegatee with the amount of + // GTC to delegate to them; a zero amount adjusts the expiration of an existing position + // without adding tokens. Empty arrays make this script revert until the round is filled in. + // For example: + // + // address[] memory _delegatees = new address[](2); + // uint256[] memory _amounts = new uint256[](2); + // _delegatees[0] = 0x0000000000000000000000000000000000000000; + // _amounts[0] = 500_000e18; + // _delegatees[1] = 0x0000000000000000000000000000000000000000; + // _amounts[1] = 250_000e18; + address[] memory _delegatees = new address[](0); + uint256[] memory _amounts = new uint256[](0); + + return ProposalParams({ + governor: GOVERNOR, + factory: FACTORY, + proposer: PROPOSER, + delegatees: _delegatees, + amounts: _amounts, + expiration: EXPIRATION, + description: DESCRIPTION + }); + } +} diff --git a/script/ProposeFranchiserRecall.s.sol b/script/ProposeFranchiserRecall.s.sol new file mode 100644 index 0000000..be9abad --- /dev/null +++ b/script/ProposeFranchiserRecall.s.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {Franchiser} from "franchiser-expiry/src/Franchiser.sol"; +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {IVotingToken} from "franchiser-expiry/src/interfaces/IVotingToken.sol"; +import {ProposeFranchiserBase} from "script/ProposeFranchiserBase.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; + +/// @notice Abstract base that holds the mechanics of proposing Franchiser recalls: a proposal, +/// submitted to the Governor that controls the DAO's Timelock, that unwinds one or more of the +/// Timelock's Franchiser positions before they expire. The proposal carries one action: +/// `factory.recallMany`, which recalls each position's tokens — including any the delegatee has +/// sub-delegated — to the corresponding recipient, ordinarily the Timelock. Any other recipient +/// is legal but sends treasury funds out of the treasury, so the script prints a prominent +/// warning for each such recipient during the dry run. +/// +/// This is the DAO's early-unwind lever. Positions that have already expired do not need it: +/// anyone can return those to the Timelock permissionlessly with `RecallExpiredFranchisers`. +/// +/// A concrete contract supplies the configuration for a specific proposal by implementing +/// `_getProposalParams`. +abstract contract ProposeFranchiserRecall is ProposeFranchiserBase { + struct ProposalParams { + GitcoinGovernorWithGuardian governor; + FranchiserExpiryFactory factory; + address proposer; + address[] delegatees; + address[] tokenRecipients; + string description; + } + + uint256 public offTreasuryRecipientCount; + + function run() public virtual { + ProposalParams memory _params = _getProposalParams(); + _validateProposalParams(_params); + + (address[] memory _targets, uint256[] memory _values, bytes[] memory _calldatas) = + _buildProposalActions(_params); + + _logProposalSummary(_params); + _warnIfTokenRecipientIsNotTheTimelock(_params); + + vm.startBroadcast(_params.proposer); + // BROADCAST: submit the recall proposal to the Governor + _log("[1/1] Submitting the recall proposal to the Governor"); + proposalId = _params.governor.propose(_targets, _values, _calldatas, _params.description); + vm.stopBroadcast(); + + _log(string.concat("Recall proposal submitted with id ", vm.toString(proposalId))); + } + + function _getProposalParams() internal view virtual returns (ProposalParams memory); + + function _scriptName() internal pure override returns (string memory) { + return "ProposeFranchiserRecall"; + } + + function _buildProposalActions(ProposalParams memory _params) + internal + pure + returns (address[] memory _targets, uint256[] memory _values, bytes[] memory _calldatas) + { + _targets = new address[](1); + _values = new uint256[](1); + _calldatas = new bytes[](1); + + _targets[0] = address(_params.factory); + _calldatas[0] = + abi.encodeCall(_params.factory.recallMany, (_params.delegatees, _params.tokenRecipients)); + } + + function _logProposalSummary(ProposalParams memory _params) internal view { + address _timelock = _params.governor.timelock(); + IVotingToken _votingToken = _params.factory.votingToken(); + _log("Proposing Franchiser recalls with:"); + _log(string.concat(" governor: ", vm.toString(address(_params.governor)))); + _log(string.concat(" factory: ", vm.toString(address(_params.factory)))); + _log(string.concat(" votingToken: ", vm.toString(address(_votingToken)))); + _log(string.concat(" timelock: ", vm.toString(_timelock))); + _log(string.concat(" proposer: ", vm.toString(_params.proposer))); + _log(string.concat(" description: ", _params.description)); + _log(" recalls:"); + for (uint256 _index = 0; _index < _params.delegatees.length; _index += 1) { + Franchiser _franchiser = _params.factory.getFranchiser(_timelock, _params.delegatees[_index]); + // The franchiser's own balance understates what the recall returns when the delegatee has + // sub-delegated: recalling also claws back the entire sub-delegation tree. + _log( + string.concat( + " ", + vm.toString(_params.delegatees[_index]), + " -> ", + vm.toString(_params.tokenRecipients[_index]), + " (franchiser ", + vm.toString(address(_franchiser)), + " holds ", + vm.toString(_votingToken.balanceOf(address(_franchiser))), + ", plus any sub-delegated tokens)" + ) + ); + } + } + + // Recalled tokens ordinarily return to the Timelock. Any other recipient is legal — the DAO + // can deliberately direct recalled funds elsewhere — but it sends treasury funds out of the + // treasury, so the dry run shouts about each one for the proposer to consciously confirm. The + // public counter lets tests pin the behavior despite logging being silenced. + function _warnIfTokenRecipientIsNotTheTimelock(ProposalParams memory _params) internal { + address _timelock = _params.governor.timelock(); + for (uint256 _index = 0; _index < _params.tokenRecipients.length; _index += 1) { + if (_params.tokenRecipients[_index] == _timelock) { + continue; + } + offTreasuryRecipientCount += 1; + _log(unicode"⚠️⚠️⚠️ WARNING ⚠️⚠️⚠️"); + _log( + string.concat( + "The recipient for delegatee ", + vm.toString(_params.delegatees[_index]), + " is ", + vm.toString(_params.tokenRecipients[_index]), + ", which is NOT the Timelock (", + vm.toString(_timelock), + ")." + ) + ); + _log("The tokens recalled from this position will NOT return to the DAO treasury."); + _log("Proceed only if this proposal intends to send treasury funds to this address."); + } + } + + function _validateProposalParams(ProposalParams memory _params) internal view { + _validateGovernanceWiring( + _params.governor, _params.factory, _params.proposer, _params.description + ); + _validateDelegatees(_params.delegatees, "remove the duplicate entry"); + if (_params.delegatees.length != _params.tokenRecipients.length) { + revert( + string.concat( + "ProposeFranchiserRecall: ", + vm.toString(_params.delegatees.length), + " delegatees but ", + vm.toString(_params.tokenRecipients.length), + " recipients; every delegatee needs exactly one recipient" + ) + ); + } + + address _timelock = _params.governor.timelock(); + for (uint256 _index = 0; _index < _params.delegatees.length; _index += 1) { + address _delegatee = _params.delegatees[_index]; + if (_params.tokenRecipients[_index] == address(0)) { + revert( + string.concat( + "ProposeFranchiserRecall: the recipient for delegatee ", + vm.toString(_delegatee), + " is the zero address; recalls are ordinarily sent back to the Timelock" + ) + ); + } + if (address(_params.factory.getFranchiser(_timelock, _delegatee)).code.length == 0) { + revert( + string.concat( + "ProposeFranchiserRecall: no position exists for delegatee ", + vm.toString(_delegatee), + "; recalling it would silently do nothing, so remove the entry" + ) + ); + } + } + + _validateProposerMeetsThreshold(_params.governor, _params.proposer); + } +} diff --git a/script/ProposeFranchiserRecallMainnet.s.sol b/script/ProposeFranchiserRecallMainnet.s.sol new file mode 100644 index 0000000..0d2be0d --- /dev/null +++ b/script/ProposeFranchiserRecallMainnet.s.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {ProposeFranchiserRecall} from "script/ProposeFranchiserRecall.s.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; + +/// @notice Mainnet configuration for a Franchiser recall proposal, the DAO's lever for unwinding +/// delegations before they expire. Like the delegation script, it is reused: each recall edits +/// the delegatees and recipients below, and the edit is committed so the repository keeps a +/// history. Expired positions do not need a proposal — anyone can return those to the Timelock +/// with `RecallExpiredFranchisersMainnet`. +contract ProposeFranchiserRecallMainnet is ProposeFranchiserRecall { + // Fixed value that can't change; the ordinary recipient of recalled tokens. + address constant TIMELOCK = 0x57a8865cfB1eCEf7253c27da6B4BC3dAEE5Be518; + + // TODO: Set to the GitcoinGovernorWithGuardian address once it is deployed to mainnet and + // controls the Timelock. The zero address makes this script revert until then. + GitcoinGovernorWithGuardian constant GOVERNOR = GitcoinGovernorWithGuardian(payable(address(0))); + + // TODO: Set to the FranchiserExpiryFactory address once it is deployed to mainnet. The zero + // address makes this script revert until then. + FranchiserExpiryFactory constant FACTORY = FranchiserExpiryFactory(address(0)); + + // TODO: Set to the delegate who will submit the proposal. They must hold or be delegated voting + // weight of at least the Governor's proposal threshold. The zero address makes this script + // revert until a proposer is confirmed. + address constant PROPOSER = address(0); + + // TODO: Finalize the proposal text before proposing. This description is stored on-chain + // (hashed) and displayed by governance UIs like Tally. + string constant DESCRIPTION = ""; + + // Boilerplate override so this file declares the public `run()` that scopelint's script rule + // looks for; the mechanics all live in the base. + function run() public override { + super.run(); + } + + function _getProposalParams() internal pure override returns (ProposalParams memory) { + // TODO: Populate the positions this proposal recalls. Each entry pairs a delegatee whose + // position is being unwound with the recipient of the recalled tokens — ordinarily TIMELOCK; + // any other recipient triggers a prominent dry-run warning. Empty arrays make this script + // revert until the recall is filled in. For example: + // + // address[] memory _delegatees = new address[](1); + // address[] memory _tokenRecipients = new address[](1); + // _delegatees[0] = 0x0000000000000000000000000000000000000000; + // _tokenRecipients[0] = TIMELOCK; + address[] memory _delegatees = new address[](0); + address[] memory _tokenRecipients = new address[](0); + + return ProposalParams({ + governor: GOVERNOR, + factory: FACTORY, + proposer: PROPOSER, + delegatees: _delegatees, + tokenRecipients: _tokenRecipients, + description: DESCRIPTION + }); + } +} diff --git a/script/ProposeGovernorUpgrade.s.sol b/script/ProposeGovernorUpgrade.s.sol index cff774e..5b0344d 100644 --- a/script/ProposeGovernorUpgrade.s.sol +++ b/script/ProposeGovernorUpgrade.s.sol @@ -1,9 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.35; -import {Script} from "forge-std/Script.sol"; -import {console2} from "forge-std/console2.sol"; import {ICompoundTimelock} from "@openzeppelin/contracts/vendor/compound/ICompoundTimelock.sol"; +import {LoggedScript} from "script/LoggedScript.sol"; import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; import {IGovernorBravo} from "src/interfaces/IGovernorBravo.sol"; @@ -17,7 +16,7 @@ import {IGovernorBravo} from "src/interfaces/IGovernorBravo.sol"; /// /// A concrete contract supplies the configuration for a specific proposal by implementing /// `_getProposalParams`. -abstract contract ProposeGovernorUpgrade is Script { +abstract contract ProposeGovernorUpgrade is LoggedScript { struct ProposalParams { IGovernorBravo oldGovernor; GitcoinGovernorWithGuardian newGovernor; @@ -26,11 +25,10 @@ abstract contract ProposeGovernorUpgrade is Script { } uint256 public proposalId; - bool internal isLogging = true; function run() public virtual { ProposalParams memory _params = _getProposalParams(); - _revertIfProposalParamsAreInvalid(_params); + _validateProposalParams(_params); (address[] memory _targets, uint256[] memory _values, bytes[] memory _calldatas) = _buildProposalActions(_params); @@ -51,10 +49,6 @@ abstract contract ProposeGovernorUpgrade is Script { _log(string.concat("Upgrade proposal submitted with id ", vm.toString(proposalId))); } - function disableLogging() public { - isLogging = false; - } - function _getProposalParams() internal view virtual returns (ProposalParams memory); function _buildProposalActions(ProposalParams memory _params) @@ -74,13 +68,7 @@ abstract contract ProposeGovernorUpgrade is Script { _calldatas[1] = abi.encodeCall(_params.newGovernor.__acceptAdmin, ()); } - function _log(string memory _msg) internal view { - if (isLogging) { - console2.log(_msg); - } - } - - function _revertIfProposalParamsAreInvalid(ProposalParams memory _params) internal view { + function _validateProposalParams(ProposalParams memory _params) internal view { if (address(_params.oldGovernor) == address(0)) { revert( "ProposeGovernorUpgrade: oldGovernor is the zero address; " diff --git a/script/RecallExpiredFranchisers.s.sol b/script/RecallExpiredFranchisers.s.sol new file mode 100644 index 0000000..a471c66 --- /dev/null +++ b/script/RecallExpiredFranchisers.s.sol @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {Franchiser} from "franchiser-expiry/src/Franchiser.sol"; +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {LoggedScript} from "script/LoggedScript.sol"; + +/// @notice Abstract base that holds the mechanics of sweeping expired Franchiser positions back +/// to their owner with `factory.recallManyExpired`. Unlike the proposal scripts, this is not a +/// governance action: recalling an expired position is permissionless and the tokens always +/// return to the position's owner (the Timelock), so anyone can run it from any account. +/// +/// The configured delegatee list is a candidate set, not a command: the script checks each +/// candidate on-chain and recalls only the positions that exist and have expired, logging why it +/// skips the rest. A superset — such as every delegatee the DAO has ever funded — is safe to +/// configure permanently. +/// +/// A concrete contract supplies the configuration for a specific sweep by implementing +/// `_getRecallParams`. +abstract contract RecallExpiredFranchisers is LoggedScript { + struct RecallParams { + FranchiserExpiryFactory factory; + address owner; + address[] delegatees; + } + + address[] public recalledDelegatees; + uint256 public recalledCount; + + function run() public virtual { + RecallParams memory _params = _getRecallParams(); + _validateRecallParams(_params); + + _log("Recalling expired Franchiser positions with:"); + _log(string.concat(" factory: ", vm.toString(address(_params.factory)))); + _log(string.concat(" owner: ", vm.toString(_params.owner))); + _log(" candidates:"); + (address[] memory _owners, address[] memory _delegatees) = _filterExpiredPositions(_params); + + if (_delegatees.length == 0) { + _log("No expired positions among the candidates; nothing to broadcast"); + return; + } + + vm.startBroadcast(); + // BROADCAST: recall every expired position back to the owner + _log( + string.concat( + "[1/1] Recalling ", vm.toString(_delegatees.length), " expired position(s) to the owner" + ) + ); + _params.factory.recallManyExpired(_owners, _delegatees); + vm.stopBroadcast(); + + for (uint256 _index = 0; _index < _delegatees.length; _index += 1) { + recalledDelegatees.push(_delegatees[_index]); + } + recalledCount = _delegatees.length; + _log(string.concat("Recalled ", vm.toString(recalledCount), " expired position(s)")); + + _validateNoTokensLeftBehind(_params, _delegatees); + } + + function _getRecallParams() internal view virtual returns (RecallParams memory); + + // Splits the candidate list into the positions that can be recalled now — those that exist and + // have expired — and logs a line explaining the status of every candidate. + function _filterExpiredPositions(RecallParams memory _params) + internal + view + returns (address[] memory _owners, address[] memory _delegatees) + { + bool[] memory _isRecallable = new bool[](_params.delegatees.length); + uint256 _recallableCount = 0; + for (uint256 _index = 0; _index < _params.delegatees.length; _index += 1) { + address _delegatee = _params.delegatees[_index]; + Franchiser _franchiser = _params.factory.getFranchiser(_params.owner, _delegatee); + if (address(_franchiser).code.length == 0) { + _log( + string.concat(" skipping ", vm.toString(_delegatee), ": no position exists for them") + ); + continue; + } + uint256 _expiration = _params.factory.expirations(_franchiser); + if (block.timestamp < _expiration) { + _log( + string.concat( + " skipping ", + vm.toString(_delegatee), + ": their position does not expire until ", + vm.toString(_expiration) + ) + ); + continue; + } + _log( + string.concat( + " recalling ", + vm.toString(_delegatee), + ": their position (franchiser ", + vm.toString(address(_franchiser)), + ") expired at ", + vm.toString(_expiration) + ) + ); + _isRecallable[_index] = true; + _recallableCount += 1; + } + + _owners = new address[](_recallableCount); + _delegatees = new address[](_recallableCount); + uint256 _recallableIndex = 0; + for (uint256 _index = 0; _index < _params.delegatees.length; _index += 1) { + if (_isRecallable[_index]) { + _owners[_recallableIndex] = _params.owner; + _delegatees[_recallableIndex] = _params.delegatees[_index]; + _recallableIndex += 1; + } + } + } + + function _validateRecallParams(RecallParams memory _params) internal pure { + if (address(_params.factory) == address(0)) { + revert( + "RecallExpiredFranchisers: factory is the zero address; " + "set it to the address of the deployed FranchiserExpiryFactory" + ); + } + if (_params.owner == address(0)) { + revert( + "RecallExpiredFranchisers: owner is the zero address; " + "set it to the owner of the positions being swept, ordinarily the Timelock" + ); + } + if (_params.delegatees.length == 0) { + revert( + "RecallExpiredFranchisers: no delegatees; " + "populate the candidate delegatees whose expired positions this script sweeps" + ); + } + for (uint256 _index = 0; _index < _params.delegatees.length; _index += 1) { + if (_params.delegatees[_index] == address(0)) { + revert( + string.concat( + "RecallExpiredFranchisers: the delegatee at index ", + vm.toString(_index), + " is the zero address" + ) + ); + } + for (uint256 _priorIndex = 0; _priorIndex < _index; _priorIndex += 1) { + if (_params.delegatees[_priorIndex] == _params.delegatees[_index]) { + revert( + string.concat( + "RecallExpiredFranchisers: the delegatee ", + vm.toString(_params.delegatees[_index]), + " appears more than once; remove the duplicate entry" + ) + ); + } + } + } + } + + function _validateNoTokensLeftBehind(RecallParams memory _params, address[] memory _recalled) + internal + view + { + for (uint256 _index = 0; _index < _recalled.length; _index += 1) { + Franchiser _franchiser = _params.factory.getFranchiser(_params.owner, _recalled[_index]); + uint256 _remainingBalance = _params.factory.votingToken().balanceOf(address(_franchiser)); + if (_remainingBalance != 0) { + revert( + string.concat( + "RecallExpiredFranchisers: the franchiser for delegatee ", + vm.toString(_recalled[_index]), + " still holds ", + vm.toString(_remainingBalance), + " tokens after the recall; this should be impossible and needs investigation" + ) + ); + } + } + } +} diff --git a/script/RecallExpiredFranchisersMainnet.s.sol b/script/RecallExpiredFranchisersMainnet.s.sol new file mode 100644 index 0000000..5bcfe45 --- /dev/null +++ b/script/RecallExpiredFranchisersMainnet.s.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {RecallExpiredFranchisers} from "script/RecallExpiredFranchisers.s.sol"; + +/// @notice Mainnet configuration for sweeping the Timelock's expired Franchiser positions. +/// Recalling expired positions is permissionless and the tokens always return to the Timelock, +/// so anyone can run this from any account. The delegatee list below is a candidate set — the +/// script recalls only the positions that exist and have expired — so the intended maintenance +/// is to keep every delegatee the DAO has ever funded on the list, appending as delegation +/// rounds add new ones. +contract RecallExpiredFranchisersMainnet is RecallExpiredFranchisers { + // Fixed value that can't change; the owner of every position the DAO funds. + address constant TIMELOCK = 0x57a8865cfB1eCEf7253c27da6B4BC3dAEE5Be518; + + // TODO: Set to the FranchiserExpiryFactory address once it is deployed to mainnet. The zero + // address makes this script revert until then. + FranchiserExpiryFactory constant FACTORY = FranchiserExpiryFactory(address(0)); + + // Boilerplate override so this file declares the public `run()` that scopelint's script rule + // looks for; the mechanics all live in the base. + function run() public override { + super.run(); + } + + function _getRecallParams() internal pure override returns (RecallParams memory) { + // TODO: List every delegatee the DAO has funded through the factory. The list is filtered + // on-chain, so keeping delegatees with live or already-swept positions on it is safe and + // expected. An empty list makes this script revert until it is populated. For example: + // + // address[] memory _delegatees = new address[](1); + // _delegatees[0] = 0x0000000000000000000000000000000000000000; + address[] memory _delegatees = new address[](0); + + return RecallParams({factory: FACTORY, owner: TIMELOCK, delegatees: _delegatees}); + } +} diff --git a/test/PostUpgradeFranchiserDelegation.integration.t.sol b/test/PostUpgradeFranchiserDelegation.integration.t.sol new file mode 100644 index 0000000..6a83f7e --- /dev/null +++ b/test/PostUpgradeFranchiserDelegation.integration.t.sol @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol"; +import {Franchiser} from "franchiser-expiry/src/Franchiser.sol"; +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {FranchiserLens} from "franchiser-expiry/src/FranchiserLens.sol"; +import {IFranchiserLens} from "franchiser-expiry/src/interfaces/IFranchiserLens.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; +import {PostUpgradeFranchiserTestBase} from "test/helpers/PostUpgradeFranchiserTestBase.sol"; +import { + ProposeFranchiserDelegationTestConfig +} from "test/helpers/ProposeFranchiserDelegationTestConfig.sol"; + +// Exercises delegation rounds after the Governor upgrade: proposals submitted with the real +// delegation script that move treasury GTC from the Timelock into Franchiser positions, and the +// voting weight those positions confer on their delegatees. +abstract contract PostUpgradeFranchiserDelegationTest is PostUpgradeFranchiserTestBase { + function test_PassedDelegationProposalFundsAFreshDelegateeWhoVotesWithTheDelegatedWeight() + external + { + address _delegatee = makeAddr("freshDelegatee"); + uint256 _amount = 500_000e18; + uint256 _expiration = _safeExpiration(); + uint256 _initialTimelockBalance = GTC_TOKEN.balanceOf(address(TIMELOCK)); + if (GTC_TOKEN.getCurrentVotes(_delegatee) != 0) { + revert( + "Test scaffolding: the fresh delegatee already has voting weight; the fresh-funding " + "scenario needs a delegatee with none" + ); + } + + // The DAO delegates treasury GTC to the fresh delegatee through a full governance proposal. + _delegateViaProposal(_delegatee, _amount, _expiration); + + // The tokens sit in the delegatee's Franchiser, delegated to them, and the Timelock's + // one-proposal approval of the factory is fully consumed. + Franchiser _franchiser = _franchiserFor(_delegatee); + assertEq(GTC_TOKEN.balanceOf(address(_franchiser)), _amount); + assertEq(GTC_TOKEN.balanceOf(address(TIMELOCK)), _initialTimelockBalance - _amount); + assertEq(GTC_TOKEN.allowance(address(TIMELOCK), address(factory)), 0); + assertEq(GTC_TOKEN.getCurrentVotes(_delegatee), _amount); + assertEq(factory.expirations(_franchiser), _expiration); + + // The delegatee votes on a new proposal, and the tally reflects the delegated weight. + ProposalDetails memory _proposal = _buildGtcSendProposal( + makeAddr("receiver"), 1000e18, "A proposal the newly franchised delegate votes on" + ); + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + _castVote(_delegatee, _proposal.id, FOR); + (, uint256 _forVotes,) = governor.proposalVotes(_proposal.id); + assertEq(_forVotes, _amount); + } + + /// forge-config: default.fuzz.runs = 25 + /// forge-config: ci.fuzz.runs = 25 + /// forge-config: lite.fuzz.runs = 5 + function testFuzz_PassedDelegationProposalFundsAFreshDelegateeWithAnyTreasuryAmount( + uint256 _amount, + uint256 _expiration + ) external { + // The round draws on the GTC the Timelock genuinely holds at the fork block, and any + // expiration the delegation script accepts is exercised. + _amount = bound(_amount, 1, GTC_TOKEN.balanceOf(address(TIMELOCK))); + _expiration = bound(_expiration, _minimumExpiration(), _minimumExpiration() + 3650 days); + address _delegatee = makeAddr("freshDelegatee"); + uint256 _initialTimelockBalance = GTC_TOKEN.balanceOf(address(TIMELOCK)); + + _delegateViaProposal(_delegatee, _amount, _expiration); + + Franchiser _franchiser = _franchiserFor(_delegatee); + assertEq(GTC_TOKEN.balanceOf(address(_franchiser)), _amount); + assertEq(GTC_TOKEN.balanceOf(address(TIMELOCK)), _initialTimelockBalance - _amount); + assertEq(GTC_TOKEN.getCurrentVotes(_delegatee), _amount); + assertEq(factory.expirations(_franchiser), _expiration); + } + + function test_PassedDelegationProposalStacksWeightOnADelegateeWithExistingTokenDelegation() + external + { + // lefteris.eth already holds real delegated weight at the fork block; a Franchiser + // delegation stacks on top of it. + uint256 _initialWeight = GTC_TOKEN.getCurrentVotes(LEFTERIS); + if (_initialWeight == 0) { + revert( + "Test scaffolding: lefteris.eth has no delegated weight at FORK_BLOCK; pick a delegate " + "with existing weight for the stacking scenario" + ); + } + uint256 _amount = 250_000e18; + + _delegateViaProposal(LEFTERIS, _amount, _safeExpiration()); + + assertEq(GTC_TOKEN.getCurrentVotes(LEFTERIS), _initialWeight + _amount); + + // Their vote on a new proposal carries the combined weight. + ProposalDetails memory _proposal = _buildGtcSendProposal( + makeAddr("receiver"), 1000e18, "A proposal an already-delegated delegate votes on" + ); + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + _castVote(LEFTERIS, _proposal.id, FOR); + (, uint256 _forVotes,) = governor.proposalVotes(_proposal.id); + assertEq(_forVotes, _initialWeight + _amount); + } + + function test_SingleDelegationProposalFundsMultipleDelegateesInOneRound() external { + address[] memory _delegatees = new address[](3); + uint256[] memory _amounts = new uint256[](3); + _delegatees[0] = makeAddr("firstFreshDelegatee"); + _amounts[0] = 100_000e18; + _delegatees[1] = makeAddr("secondFreshDelegatee"); + _amounts[1] = 200_000e18; + _delegatees[2] = LEFTERIS; + _amounts[2] = 50_000e18; + uint256 _initialLefterisWeight = GTC_TOKEN.getCurrentVotes(LEFTERIS); + uint256 _initialTimelockBalance = GTC_TOKEN.balanceOf(address(TIMELOCK)); + uint256 _expiration = _safeExpiration(); + + _delegateViaProposal(_delegatees, _amounts, _expiration); + + // Every position is funded from the single round, fresh and existing delegatees alike. + assertEq(GTC_TOKEN.getCurrentVotes(_delegatees[0]), _amounts[0]); + assertEq(GTC_TOKEN.getCurrentVotes(_delegatees[1]), _amounts[1]); + assertEq(GTC_TOKEN.getCurrentVotes(LEFTERIS), _initialLefterisWeight + _amounts[2]); + for (uint256 _index = 0; _index < _delegatees.length; _index += 1) { + Franchiser _franchiser = _franchiserFor(_delegatees[_index]); + assertEq(GTC_TOKEN.balanceOf(address(_franchiser)), _amounts[_index]); + assertEq(factory.expirations(_franchiser), _expiration); + } + assertEq( + GTC_TOKEN.balanceOf(address(TIMELOCK)), + _initialTimelockBalance - _amounts[0] - _amounts[1] - _amounts[2] + ); + assertEq(GTC_TOKEN.allowance(address(TIMELOCK), address(factory)), 0); + } + + function test_SecondDelegationRoundTopsUpAPositionAndReplacesItsExpirationWithAnEarlierOne() + external + { + address _delegatee = makeAddr("renewedDelegatee"); + uint256 _firstAmount = 300_000e18; + uint256 _firstExpiration = _safeExpiration() + 90 days; + _delegateViaProposal(_delegatee, _firstAmount, _firstExpiration); + Franchiser _franchiser = _franchiserFor(_delegatee); + + // The second round tops the position up with an expiration earlier than the first: funding + // overwrites a live position's expiration in either direction. + uint256 _secondAmount = 200_000e18; + uint256 _secondExpiration = _safeExpiration(); + if (_secondExpiration >= _firstExpiration) { + revert( + "Test scaffolding: the second round's expiration no longer lands before the first's; " + "the 90-day buffer no longer outruns the proposal pipeline, so widen it" + ); + } + _delegateViaProposal(_delegatee, _secondAmount, _secondExpiration); + + // The same clone is reused, the balances add, and the new (earlier) expiration governs. + assertEq(address(_franchiserFor(_delegatee)), address(_franchiser)); + assertEq(GTC_TOKEN.balanceOf(address(_franchiser)), _firstAmount + _secondAmount); + assertEq(GTC_TOKEN.getCurrentVotes(_delegatee), _firstAmount + _secondAmount); + assertEq(factory.expirations(_franchiser), _secondExpiration); + } + + function test_ZeroAmountDelegationRoundExtendsAPositionsExpirationWithoutMovingTokens() external { + address _delegatee = makeAddr("extendedDelegatee"); + uint256 _amount = 400_000e18; + uint256 _firstExpiration = _safeExpiration(); + _delegateViaProposal(_delegatee, _amount, _firstExpiration); + Franchiser _franchiser = _franchiserFor(_delegatee); + uint256 _timelockBalanceAfterFunding = GTC_TOKEN.balanceOf(address(TIMELOCK)); + + // A zero-amount round against the live position adjusts its expiration alone. + uint256 _secondExpiration = _firstExpiration + 365 days; + _delegateViaProposal(_delegatee, 0, _secondExpiration); + + assertEq(factory.expirations(_franchiser), _secondExpiration); + assertEq(GTC_TOKEN.balanceOf(address(_franchiser)), _amount); + assertEq(GTC_TOKEN.getCurrentVotes(_delegatee), _amount); + assertEq(GTC_TOKEN.balanceOf(address(TIMELOCK)), _timelockBalanceAfterFunding); + } + + function test_DelegationExecutedAfterAProposalSnapshotDoesNotCountOnThatProposal() external { + address _delegatee = makeAddr("lateDelegatee"); + uint256 _amount = 400_000e18; + + // The delegation round is submitted first, and an unrelated proposal a few blocks later — + // timed so the proposal's snapshot lands before the round executes but its deadline lands + // after (the Timelock delay passes in timestamps, not blocks). + ProposalDetails memory _round = + _submitDelegationRound(_asArray(_delegatee), _asArray(_amount), _safeExpiration()); + vm.roll(block.number + 10); + ProposalDetails memory _proposal = _buildGtcSendProposal( + makeAddr("receiver"), 1000e18, "A proposal snapshotted before the delegation lands" + ); + _submitProposal(_proposal); + + _passQueueAndExecuteSubmittedProposal(_round); + _guardProposalState( + governor.state(_proposal.id), + IGovernor.ProposalState.Active, + "the unrelated proposal must still be in its voting window when the delegation round " + "executes" + ); + + // The delegatee has the weight now, but had none at the proposal's snapshot... + assertEq(GTC_TOKEN.getCurrentVotes(_delegatee), _amount); + assertEq(GTC_TOKEN.getPriorVotes(_delegatee, governor.proposalSnapshot(_proposal.id)), 0); + + // ...so the Governor rejects their vote on it outright. + vm.prank(_delegatee); + vm.expectPartialRevert(IGovernor.GovernorAlreadyCastVote.selector); + governor.castVote(_proposal.id, FOR); + } + + function test_DefeatedDelegationProposalMovesNoTokensAndCreatesNoPosition() external { + address _delegatee = makeAddr("rejectedDelegatee"); + uint256 _initialTimelockBalance = GTC_TOKEN.balanceOf(address(TIMELOCK)); + + // The electorate votes the round down. + ProposalDetails memory _round = + _submitDelegationRound(_asArray(_delegatee), _asArray(500_000e18), _safeExpiration()); + _jumpToProposalActive(_round.id); + _delegatesCastVotes(_round.id, AGAINST); + _jumpPastProposalDeadline(_round.id); + assertEq(governor.state(_round.id), IGovernor.ProposalState.Defeated); + + vm.expectPartialRevert(IGovernor.GovernorUnexpectedProposalState.selector); + _queueProposal(_round); + + // Nothing moved: no position exists, no weight was conferred, no approval lingers. + assertEq(address(_franchiserFor(_delegatee)).code.length, 0); + assertEq(GTC_TOKEN.getCurrentVotes(_delegatee), 0); + assertEq(GTC_TOKEN.balanceOf(address(TIMELOCK)), _initialTimelockBalance); + assertEq(GTC_TOKEN.allowance(address(TIMELOCK), address(factory)), 0); + } + + function test_LensReportsTheRootDelegationForAFundedPosition() external { + address _delegatee = makeAddr("observedDelegatee"); + _delegateViaProposal(_delegatee, 100_000e18, _safeExpiration()); + + Franchiser _franchiser = _franchiserFor(_delegatee); + IFranchiserLens.Delegation memory _delegation = lens.getRootDelegation(_franchiser); + assertEq(_delegation.delegator, address(TIMELOCK)); + assertEq(_delegation.delegatee, _delegatee); + assertEq(address(_delegation.franchiser), address(_franchiser)); + } + + function test_RevertIf_DelegationRoundExpirationDoesNotOutliveTheProposalPipeline() external { + uint256 _expiration = _minimumExpiration() - 1; + ProposeFranchiserDelegationTestConfig _proposeScript = new ProposeFranchiserDelegationTestConfig( + governor, + factory, + PROPOSER, + _asArray(makeAddr("delegatee")), + _asArray(uint256(100_000e18)), + _expiration, + DELEGATION_PROPOSAL_DESCRIPTION + ); + _proposeScript.disableLogging(); + + vm.expectRevert( + bytes( + string.concat( + "ProposeFranchiserDelegation: the expiration ", + vm.toString(_expiration), + " is too soon; it must be at least ", + vm.toString(_minimumExpiration()), + " so the positions cannot expire before the proposal's last legitimate execution time" + ) + ) + ); + _proposeScript.run(); + } + + function test_RevertIf_DelegationRoundRepeatsADelegatee() external { + address _delegatee = makeAddr("repeatedDelegatee"); + address[] memory _delegatees = new address[](2); + uint256[] memory _amounts = new uint256[](2); + _delegatees[0] = _delegatee; + _amounts[0] = 100_000e18; + _delegatees[1] = _delegatee; + _amounts[1] = 200_000e18; + ProposeFranchiserDelegationTestConfig _proposeScript = new ProposeFranchiserDelegationTestConfig( + governor, + factory, + PROPOSER, + _delegatees, + _amounts, + _safeExpiration(), + DELEGATION_PROPOSAL_DESCRIPTION + ); + _proposeScript.disableLogging(); + + vm.expectRevert( + bytes( + string.concat( + "ProposeFranchiserDelegation: the delegatee ", + vm.toString(_delegatee), + " appears more than once; combine their amounts into a single entry" + ) + ) + ); + _proposeScript.run(); + } + + function test_RevertIf_DelegationRoundSendsZeroToADelegateeWithNoPosition() external { + address _delegatee = makeAddr("neverFundedDelegatee"); + ProposeFranchiserDelegationTestConfig _proposeScript = new ProposeFranchiserDelegationTestConfig( + governor, + factory, + PROPOSER, + _asArray(_delegatee), + _asArray(uint256(0)), + _safeExpiration(), + DELEGATION_PROPOSAL_DESCRIPTION + ); + _proposeScript.disableLogging(); + + vm.expectRevert( + bytes( + string.concat( + "ProposeFranchiserDelegation: the amount for delegatee ", + vm.toString(_delegatee), + " is zero but they have no existing position; a zero amount is only meaningful as " + "an expiration adjustment to an existing position" + ) + ) + ); + _proposeScript.run(); + } +} + +contract PostUpgradeFranchiserDelegationMainnetScript is PostUpgradeFranchiserDelegationTest { + function _setUpNetwork() internal override { + _createMainnetFork(); + } + + function _fetchOrDeploySystem() internal override returns (GitcoinGovernorWithGuardian) { + return _deployGovernorWithMainnetScript(); + } + + function _fetchOrDeployFranchiser() + internal + override + returns (FranchiserExpiryFactory, FranchiserLens) + { + return _deployFranchiserWithMainnetScript(); + } +} diff --git a/test/PostUpgradeFranchiserDeploy.integration.t.sol b/test/PostUpgradeFranchiserDeploy.integration.t.sol new file mode 100644 index 0000000..99ceab8 --- /dev/null +++ b/test/PostUpgradeFranchiserDeploy.integration.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {Franchiser} from "franchiser-expiry/src/Franchiser.sol"; +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {FranchiserLens} from "franchiser-expiry/src/FranchiserLens.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; +import {PostUpgradeFranchiserTestBase} from "test/helpers/PostUpgradeFranchiserTestBase.sol"; + +// Exercises the Franchiser deployment after the Governor upgrade has executed: the system the +// deploy script produces is wired to GTC, internally consistent, and coherent with the Governor +// that will administer it. +abstract contract PostUpgradeFranchiserDeployTest is PostUpgradeFranchiserTestBase { + function test_DeployedFranchiserSystemIsWiredToGtcAndInternallyConsistent() external view { + // The factory sources the same token the Governor sources voting weight from. + assertEq(address(factory.votingToken()), address(GTC_TOKEN)); + assertEq(address(factory.votingToken()), address(governor.token())); + + // The factory deployed a canonical Franchiser implementation wired to the same token, with + // the expected sub-delegation shape. + Franchiser _implementation = factory.franchiserImplementation(); + assertGt(address(_implementation).code.length, 0); + assertEq(address(_implementation.votingToken()), address(GTC_TOKEN)); + assertEq(factory.INITIAL_MAXIMUM_SUBDELEGATEES(), 8); + + // The lens reads through the same token and factory. + assertEq(address(lens.votingToken()), address(GTC_TOKEN)); + assertEq(address(lens.franchiserFactory()), address(factory)); + } + + function test_FreshlyDeployedFactoryHoldsNoPositionForTheTimelock() external { + // Position addresses are deterministic before they exist; none exists until the DAO's first + // delegation round funds one. + Franchiser _franchiser = _franchiserFor(makeAddr("someDelegatee")); + assertNotEq(address(_franchiser), address(0)); + assertEq(address(_franchiser).code.length, 0); + assertEq(factory.expirations(_franchiser), 0); + } +} + +contract PostUpgradeFranchiserDeployMainnetScript is PostUpgradeFranchiserDeployTest { + function _setUpNetwork() internal override { + _createMainnetFork(); + } + + function _fetchOrDeploySystem() internal override returns (GitcoinGovernorWithGuardian) { + return _deployGovernorWithMainnetScript(); + } + + function _fetchOrDeployFranchiser() + internal + override + returns (FranchiserExpiryFactory, FranchiserLens) + { + return _deployFranchiserWithMainnetScript(); + } +} diff --git a/test/PostUpgradeFranchiserExpiry.integration.t.sol b/test/PostUpgradeFranchiserExpiry.integration.t.sol new file mode 100644 index 0000000..d63e025 --- /dev/null +++ b/test/PostUpgradeFranchiserExpiry.integration.t.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {FranchiserLens} from "franchiser-expiry/src/FranchiserLens.sol"; +import { + IFranchiserExpiryFactoryErrors +} from "franchiser-expiry/src/interfaces/FranchiserExpiryFactory/IFranchiserExpiryFactoryErrors.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; +import {PostUpgradeFranchiserTestBase} from "test/helpers/PostUpgradeFranchiserTestBase.sol"; +import { + RecallExpiredFranchisersTestConfig +} from "test/helpers/RecallExpiredFranchisersTestConfig.sol"; + +// Exercises position expiry after the Governor upgrade: the permissionless sweep script +// returning lapsed delegations to the Timelock, what expiry does (and does not do) to voting +// weight, and the guard that keeps live positions out of reach. +abstract contract PostUpgradeFranchiserExpiryTest is PostUpgradeFranchiserTestBase { + function test_SweepScriptReturnsAnExpiredPositionsTokensAndRemovesTheDelegateesWeight() external { + address _delegatee = makeAddr("expiringDelegatee"); + uint256 _amount = 500_000e18; + uint256 _expiration = _safeExpiration(); + _delegateViaProposal(_delegatee, _amount, _expiration); + uint256 _timelockBalanceWhileDelegated = GTC_TOKEN.balanceOf(address(TIMELOCK)); + + // Time reaches the expiration exactly — the boundary is inclusive, so the position is + // already sweepable. The script broadcasts from Foundry's default sender, an EOA with no + // special standing; the fuzz test below carries the any-caller claim. + vm.warp(_expiration); + vm.roll(block.number + 1); + RecallExpiredFranchisersTestConfig _sweepScript = _sweepExpiredPositions(_asArray(_delegatee)); + + assertEq(_sweepScript.recalledCount(), 1); + assertEq(_sweepScript.recalledDelegatees(0), _delegatee); + assertEq(GTC_TOKEN.balanceOf(address(TIMELOCK)), _timelockBalanceWhileDelegated + _amount); + assertEq(GTC_TOKEN.balanceOf(address(_franchiserFor(_delegatee))), 0); + assertEq(GTC_TOKEN.getCurrentVotes(_delegatee), 0); + } + + /// forge-config: default.fuzz.runs = 25 + /// forge-config: ci.fuzz.runs = 25 + /// forge-config: lite.fuzz.runs = 5 + function testFuzz_AnyAccountCanSweepAnExpiredPosition(address _caller) external { + address _delegatee = makeAddr("expiringDelegatee"); + uint256 _amount = 500_000e18; + uint256 _expiration = _safeExpiration(); + _delegateViaProposal(_delegatee, _amount, _expiration); + uint256 _timelockBalanceWhileDelegated = GTC_TOKEN.balanceOf(address(TIMELOCK)); + + // The factory's expired-recall path reads nothing from the caller and always returns the + // tokens to the position's owner, so sweeping is permissionless for any account. + vm.warp(_expiration); + vm.roll(block.number + 1); + vm.prank(_caller); + factory.recallExpired(address(TIMELOCK), _delegatee); + + assertEq(GTC_TOKEN.balanceOf(address(TIMELOCK)), _timelockBalanceWhileDelegated + _amount); + assertEq(GTC_TOKEN.balanceOf(address(_franchiserFor(_delegatee))), 0); + assertEq(GTC_TOKEN.getCurrentVotes(_delegatee), 0); + } + + function test_ExpiredPositionRetainsVotingWeightUntilSomeoneSweepsIt() external { + address _delegatee = makeAddr("lingeringDelegatee"); + uint256 _amount = 500_000e18; + uint256 _expiration = _safeExpiration(); + _delegateViaProposal(_delegatee, _amount, _expiration); + + // Expiry is not self-executing: long after the expiration passes, the delegatee still holds + // the full delegated weight. Sweeping is a real operational obligation. + vm.warp(_expiration + 90 days); + vm.roll(block.number + 1); + assertEq(GTC_TOKEN.getCurrentVotes(_delegatee), _amount); + + _sweepExpiredPositions(_asArray(_delegatee)); + assertEq(GTC_TOKEN.getCurrentVotes(_delegatee), 0); + } + + function test_SweepRecallsOnlyExpiredPositionsFromAMixedCandidateList() external { + // Two positions with different expirations, funded in successive rounds, plus a candidate + // that was never funded at all. + address _expiredDelegatee = makeAddr("expiredDelegatee"); + uint256 _expiredAmount = 300_000e18; + uint256 _earlyExpiration = _safeExpiration(); + _delegateViaProposal(_expiredDelegatee, _expiredAmount, _earlyExpiration); + + address _liveDelegatee = makeAddr("liveDelegatee"); + uint256 _liveAmount = 200_000e18; + _delegateViaProposal(_liveDelegatee, _liveAmount, _earlyExpiration + 180 days); + + address _neverFunded = makeAddr("neverFundedDelegatee"); + address[] memory _candidates = new address[](3); + _candidates[0] = _expiredDelegatee; + _candidates[1] = _liveDelegatee; + _candidates[2] = _neverFunded; + + vm.warp(_earlyExpiration + 1); + vm.roll(block.number + 1); + RecallExpiredFranchisersTestConfig _sweepScript = _sweepExpiredPositions(_candidates); + + // Only the expired position is recalled; the live one keeps its tokens and weight, and the + // never-funded candidate is skipped harmlessly. + assertEq(_sweepScript.recalledCount(), 1); + assertEq(_sweepScript.recalledDelegatees(0), _expiredDelegatee); + assertEq(GTC_TOKEN.getCurrentVotes(_expiredDelegatee), 0); + assertEq(GTC_TOKEN.balanceOf(address(_franchiserFor(_liveDelegatee))), _liveAmount); + assertEq(GTC_TOKEN.getCurrentVotes(_liveDelegatee), _liveAmount); + assertEq(address(_franchiserFor(_neverFunded)).code.length, 0); + } + + function test_SweepWithNothingExpiredRecallsNothing() external { + address _delegatee = makeAddr("freshlyFundedDelegatee"); + uint256 _amount = 500_000e18; + _delegateViaProposal(_delegatee, _amount, _safeExpiration()); + uint256 _timelockBalanceWhileDelegated = GTC_TOKEN.balanceOf(address(TIMELOCK)); + + address[] memory _candidates = new address[](2); + _candidates[0] = _delegatee; + _candidates[1] = makeAddr("neverFundedDelegatee"); + RecallExpiredFranchisersTestConfig _sweepScript = _sweepExpiredPositions(_candidates); + + assertEq(_sweepScript.recalledCount(), 0); + assertEq(GTC_TOKEN.balanceOf(address(TIMELOCK)), _timelockBalanceWhileDelegated); + assertEq(GTC_TOKEN.balanceOf(address(_franchiserFor(_delegatee))), _amount); + assertEq(GTC_TOKEN.getCurrentVotes(_delegatee), _amount); + } + + function test_RevertIf_AnExpiredRecallIsForcedBeforeTheExpiration() external { + address _delegatee = makeAddr("protectedDelegatee"); + _delegateViaProposal(_delegatee, 500_000e18, _safeExpiration()); + + // The factory itself guards live positions: nobody can force an expired-style recall early, + // no matter who asks. + vm.prank(makeAddr("impatientKeeper")); + vm.expectRevert(IFranchiserExpiryFactoryErrors.FranchiserNotExpired.selector); + factory.recallExpired(address(TIMELOCK), _delegatee); + } +} + +contract PostUpgradeFranchiserExpiryMainnetScript is PostUpgradeFranchiserExpiryTest { + function _setUpNetwork() internal override { + _createMainnetFork(); + } + + function _fetchOrDeploySystem() internal override returns (GitcoinGovernorWithGuardian) { + return _deployGovernorWithMainnetScript(); + } + + function _fetchOrDeployFranchiser() + internal + override + returns (FranchiserExpiryFactory, FranchiserLens) + { + return _deployFranchiserWithMainnetScript(); + } +} diff --git a/test/PostUpgradeFranchiserRecall.integration.t.sol b/test/PostUpgradeFranchiserRecall.integration.t.sol new file mode 100644 index 0000000..2fe73ee --- /dev/null +++ b/test/PostUpgradeFranchiserRecall.integration.t.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol"; +import {Franchiser} from "franchiser-expiry/src/Franchiser.sol"; +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {FranchiserLens} from "franchiser-expiry/src/FranchiserLens.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; +import {PostUpgradeFranchiserTestBase} from "test/helpers/PostUpgradeFranchiserTestBase.sol"; +import { + ProposeFranchiserRecallTestConfig +} from "test/helpers/ProposeFranchiserRecallTestConfig.sol"; + +// Exercises early recalls after the Governor upgrade: proposals submitted with the real recall +// script that unwind live Franchiser positions before they expire, what that does to the +// delegatee's voting weight, and the limits of who can move the delegated tokens. +abstract contract PostUpgradeFranchiserRecallTest is PostUpgradeFranchiserTestBase { + function test_PassedRecallProposalReturnsDelegatedTokensAndRemovesTheDelegateesWeight() external { + address _delegatee = makeAddr("recalledDelegatee"); + uint256 _amount = 500_000e18; + uint256 _initialTimelockBalance = GTC_TOKEN.balanceOf(address(TIMELOCK)); + _delegateViaProposal(_delegatee, _amount, _safeExpiration()); + if (GTC_TOKEN.getCurrentVotes(_delegatee) != _amount) { + revert( + "Test scaffolding: the arranged delegation did not confer the expected weight; the " + "recall scenario cannot proceed" + ); + } + + // The DAO unwinds the position early through a full governance proposal. + _recallViaProposal(_delegatee); + + // The tokens are back in the treasury and the delegatee's conferred weight is gone. + assertEq(GTC_TOKEN.balanceOf(address(TIMELOCK)), _initialTimelockBalance); + assertEq(GTC_TOKEN.balanceOf(address(_franchiserFor(_delegatee))), 0); + assertEq(GTC_TOKEN.getCurrentVotes(_delegatee), 0); + } + + function test_RecalledDelegateeStillVotesWithSnapshotWeightOnAProposalInFlightDuringTheRecall() + external + { + address _delegatee = makeAddr("rogueDelegatee"); + uint256 _amount = 400_000e18; + _delegateViaProposal(_delegatee, _amount, _safeExpiration()); + + // The recall is submitted first, and an unrelated proposal a few blocks later — timed so the + // proposal's snapshot lands before the recall executes but its deadline lands after (the + // Timelock delay passes in timestamps, not blocks). + ProposalDetails memory _recallRound = + _submitRecallRound(_asArray(_delegatee), _asArray(address(TIMELOCK))); + vm.roll(block.number + 10); + ProposalDetails memory _proposal = _buildGtcSendProposal( + makeAddr("receiver"), 1000e18, "A proposal in flight while the recall executes" + ); + _submitProposal(_proposal); + + _passQueueAndExecuteSubmittedProposal(_recallRound); + _guardProposalState( + governor.state(_proposal.id), + IGovernor.ProposalState.Active, + "the unrelated proposal must still be in its voting window when the recall executes" + ); + if (GTC_TOKEN.getCurrentVotes(_delegatee) != 0) { + revert( + "Test scaffolding: the recall executed but the delegatee still holds current weight; " + "the arranged recall did not complete" + ); + } + + // The recall cannot reach weight already snapshotted: the recalled delegatee still votes + // with the full delegated weight on the in-flight proposal. The DAO's early-recall lever + // stops future proposals, not ones already underway. + _castVote(_delegatee, _proposal.id, FOR); + (, uint256 _forVotes,) = governor.proposalVotes(_proposal.id); + assertEq(_forVotes, _amount); + } + + function test_RecallClawsBackTokensTheDelegateeSubDelegated() external { + address _delegatee = makeAddr("subDelegatingDelegatee"); + address _subDelegatee = makeAddr("subDelegatee"); + uint256 _amount = 600_000e18; + uint256 _subDelegatedAmount = 200_000e18; + uint256 _initialTimelockBalance = GTC_TOKEN.balanceOf(address(TIMELOCK)); + _delegateViaProposal(_delegatee, _amount, _safeExpiration()); + + // The delegatee sub-delegates part of their position, splitting the voting weight. + Franchiser _franchiser = _franchiserFor(_delegatee); + vm.prank(_delegatee); + Franchiser _subFranchiser = _franchiser.subDelegate(_subDelegatee, _subDelegatedAmount); + if ( + GTC_TOKEN.getCurrentVotes(_delegatee) != _amount - _subDelegatedAmount + || GTC_TOKEN.getCurrentVotes(_subDelegatee) != _subDelegatedAmount + || GTC_TOKEN.balanceOf(address(_subFranchiser)) != _subDelegatedAmount + ) { + revert( + "Test scaffolding: the arranged sub-delegation did not split the position as expected; " + "the claw-back scenario cannot proceed" + ); + } + + // The DAO's recall claws back the entire tree, not just the top-level position. + _recallViaProposal(_delegatee); + + assertEq(GTC_TOKEN.balanceOf(address(TIMELOCK)), _initialTimelockBalance); + assertEq(GTC_TOKEN.balanceOf(address(_franchiser)), 0); + assertEq(GTC_TOKEN.balanceOf(address(_subFranchiser)), 0); + assertEq(GTC_TOKEN.getCurrentVotes(_delegatee), 0); + assertEq(GTC_TOKEN.getCurrentVotes(_subDelegatee), 0); + } + + function test_DelegateeCannotPullTokensOutOfTheirFranchiser() external { + address _delegatee = makeAddr("greedyDelegatee"); + uint256 _amount = 500_000e18; + _delegateViaProposal(_delegatee, _amount, _safeExpiration()); + Franchiser _franchiser = _franchiserFor(_delegatee); + + // The franchiser's owner is the factory, so even the delegatee cannot recall its tokens to + // themselves. Sub-delegation is the only power they hold, and it can only move tokens into + // another franchiser — never out of the system. + vm.prank(_delegatee); + vm.expectRevert(bytes("UNAUTHORIZED")); + _franchiser.recall(_delegatee); + + assertEq(GTC_TOKEN.balanceOf(address(_franchiser)), _amount); + assertEq(GTC_TOKEN.balanceOf(_delegatee), 0); + } + + function test_ThirdPartyCannotRecallTheTimelocksPositions() external { + address _delegatee = makeAddr("stableDelegatee"); + address _attacker = makeAddr("attacker"); + uint256 _amount = 500_000e18; + _delegateViaProposal(_delegatee, _amount, _safeExpiration()); + + // Recall through the factory is keyed to the caller, so an attacker's recall targets their + // own (nonexistent) position and silently does nothing to the Timelock's. + vm.prank(_attacker); + factory.recall(_delegatee, _attacker); + + assertEq(GTC_TOKEN.balanceOf(address(_franchiserFor(_delegatee))), _amount); + assertEq(GTC_TOKEN.getCurrentVotes(_delegatee), _amount); + assertEq(GTC_TOKEN.balanceOf(_attacker), 0); + } + + function test_RecallRoundToANonTimelockRecipientCountsAnOffTreasuryWarning() external { + address _delegatee = makeAddr("redirectedDelegatee"); + address _offTreasuryRecipient = makeAddr("offTreasuryRecipient"); + _delegateViaProposal(_delegatee, 500_000e18, _safeExpiration()); + + // A recipient other than the Timelock is legal, but the script warns the proposer that the + // recalled tokens leave the treasury. Tests silence logging, so the warning is observed + // through the script's public counter. + ProposeFranchiserRecallTestConfig _proposeScript = new ProposeFranchiserRecallTestConfig( + governor, + factory, + PROPOSER, + _asArray(_delegatee), + _asArray(_offTreasuryRecipient), + RECALL_PROPOSAL_DESCRIPTION + ); + _proposeScript.disableLogging(); + _proposeScript.run(); + + assertEq(_proposeScript.offTreasuryRecipientCount(), 1); + } + + function test_RecallRoundToTheTimelockCountsNoOffTreasuryWarning() external { + address _delegatee = makeAddr("standardDelegatee"); + _delegateViaProposal(_delegatee, 500_000e18, _safeExpiration()); + + ProposeFranchiserRecallTestConfig _proposeScript = new ProposeFranchiserRecallTestConfig( + governor, + factory, + PROPOSER, + _asArray(_delegatee), + _asArray(address(TIMELOCK)), + RECALL_PROPOSAL_DESCRIPTION + ); + _proposeScript.disableLogging(); + _proposeScript.run(); + + assertEq(_proposeScript.offTreasuryRecipientCount(), 0); + } + + function test_RevertIf_RecallRoundTargetsADelegateeWithNoPosition() external { + address _delegatee = makeAddr("neverFundedDelegatee"); + ProposeFranchiserRecallTestConfig _proposeScript = new ProposeFranchiserRecallTestConfig( + governor, + factory, + PROPOSER, + _asArray(_delegatee), + _asArray(address(TIMELOCK)), + RECALL_PROPOSAL_DESCRIPTION + ); + _proposeScript.disableLogging(); + + vm.expectRevert( + bytes( + string.concat( + "ProposeFranchiserRecall: no position exists for delegatee ", + vm.toString(_delegatee), + "; recalling it would silently do nothing, so remove the entry" + ) + ) + ); + _proposeScript.run(); + } +} + +contract PostUpgradeFranchiserRecallMainnetScript is PostUpgradeFranchiserRecallTest { + function _setUpNetwork() internal override { + _createMainnetFork(); + } + + function _fetchOrDeploySystem() internal override returns (GitcoinGovernorWithGuardian) { + return _deployGovernorWithMainnetScript(); + } + + function _fetchOrDeployFranchiser() + internal + override + returns (FranchiserExpiryFactory, FranchiserLens) + { + return _deployFranchiserWithMainnetScript(); + } +} diff --git a/test/PostUpgradeProposalGuardian.integration.t.sol b/test/PostUpgradeProposalGuardian.integration.t.sol index e23b61d..8a541b4 100644 --- a/test/PostUpgradeProposalGuardian.integration.t.sol +++ b/test/PostUpgradeProposalGuardian.integration.t.sol @@ -206,7 +206,11 @@ abstract contract PostUpgradeProposalGuardianTest is GitcoinGovernorPostUpgradeT _jumpToProposalActive(_proposal.id); _delegatesCastVotes(_proposal.id, FOR); _jumpPastProposalDeadline(_proposal.id); - assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Succeeded); + _guardProposalState( + governor.state(_proposal.id), + IGovernor.ProposalState.Succeeded, + "after the electorate voted the guardian-assessed proposal through" + ); } } diff --git a/test/PostUpgradeQuorumBehavior.integration.t.sol b/test/PostUpgradeQuorumBehavior.integration.t.sol index 95ccadc..83bfc2d 100644 --- a/test/PostUpgradeQuorumBehavior.integration.t.sol +++ b/test/PostUpgradeQuorumBehavior.integration.t.sol @@ -55,7 +55,12 @@ abstract contract PostUpgradeQuorumBehaviorTest is GitcoinGovernorPostUpgradeTes string.concat("Set the quorum to ", vm.toString(_newQuorum)) ); _submitPassQueueAndExecuteProposal(_proposal); - assertEq(governor.quorum(block.number), _newQuorum); + if (governor.quorum(block.number) != _newQuorum) { + revert( + "Test scaffolding: the setQuorum proposal executed but the quorum did not change to the " + "requested value; the quorum-adjustment step this suite builds on is broken" + ); + } } function test_ProposalMeetingOnlyTheOldQuorumIsDefeatedAfterTheQuorumIsRaised() external { diff --git a/test/helpers/GitcoinGovernorUpgradeTestBase.sol b/test/helpers/GitcoinGovernorUpgradeTestBase.sol index 189480f..ff0c523 100644 --- a/test/helpers/GitcoinGovernorUpgradeTestBase.sol +++ b/test/helpers/GitcoinGovernorUpgradeTestBase.sol @@ -165,6 +165,84 @@ abstract contract GitcoinGovernorUpgradeTestBase is Test { return _deployScript.governor(); } + //---------------------------------- Scaffolding guards ----------------------------------// + // Guards on the state the step helpers expect as they drive proposals through their + // lifecycle. Like the setUp guards, these revert rather than assert: a failure means a test + // scaffolding assumption broke — most often a FORK_BLOCK bump reshaping the electorate — not + // that a behavior under test regressed. Assertions are reserved for the claims test bodies + // make about the system under test. + + function _guardProposalState( + IGovernor.ProposalState _actual, + IGovernor.ProposalState _expected, + string memory _context + ) internal pure { + if (_actual == _expected) { + return; + } + revert( + string.concat( + "Test scaffolding expected a proposal to be ", + _proposalStateName(_expected), + " but it is ", + _proposalStateName(_actual), + " (", + _context, + "); a scaffolding assumption broke; check the setUp guards and FORK_BLOCK before " + "suspecting the behavior under test" + ) + ); + } + + function _guardProposalId(uint256 _actualId, uint256 _expectedId, string memory _context) + internal + pure + { + if (_actualId == _expectedId) { + return; + } + revert( + string.concat( + "Test scaffolding expected proposal id ", + vm.toString(_expectedId), + " but the governor assigned ", + vm.toString(_actualId), + " (", + _context, + "); the submitted actions and the test's mirror of them have diverged; realign them" + ) + ); + } + + function _proposalStateName(IGovernor.ProposalState _state) + internal + pure + returns (string memory) + { + if (_state == IGovernor.ProposalState.Pending) { + return "Pending"; + } + if (_state == IGovernor.ProposalState.Active) { + return "Active"; + } + if (_state == IGovernor.ProposalState.Canceled) { + return "Canceled"; + } + if (_state == IGovernor.ProposalState.Defeated) { + return "Defeated"; + } + if (_state == IGovernor.ProposalState.Succeeded) { + return "Succeeded"; + } + if (_state == IGovernor.ProposalState.Queued) { + return "Queued"; + } + if (_state == IGovernor.ProposalState.Expired) { + return "Expired"; + } + return "Executed"; + } + //---------------------------------- Electorate helpers ----------------------------------// // A delegate's voting weight as read from the fork in setUp. Stable for the run: nothing in @@ -241,11 +319,20 @@ abstract contract GitcoinGovernorUpgradeTestBase is Test { _proposeScript.disableLogging(); _proposeScript.run(); upgradeProposalId = _proposeScript.proposalId(); + _guardProposalId( + upgradeProposalId, + _upgradeProposalDetails().id, + "the upgrade proposal script vs the _upgradeProposalDetails mirror" + ); } function _jumpToUpgradeProposalActive() internal { vm.roll(OLD_GOVERNOR.proposalSnapshot(upgradeProposalId) + 1); - assertEq(OLD_GOVERNOR.state(upgradeProposalId), IGovernor.ProposalState.Active); + _guardProposalState( + OLD_GOVERNOR.state(upgradeProposalId), + IGovernor.ProposalState.Active, + "jumping to the upgrade proposal's voting window" + ); } function _jumpPastUpgradeProposalDeadline() internal { @@ -297,7 +384,17 @@ abstract contract GitcoinGovernorUpgradeTestBase is Test { _queueUpgradeProposal(); _jumpPastUpgradeProposalEta(); _executeUpgradeProposal(); - assertEq(TIMELOCK.admin(), address(governor)); + if (TIMELOCK.admin() != address(governor)) { + revert( + string.concat( + "Test scaffolding: the upgrade proposal executed but the Timelock's admin is ", + vm.toString(TIMELOCK.admin()), + " rather than the new Governor ", + vm.toString(address(governor)), + "; the upgrade journey the suites build on is broken" + ) + ); + } } //------------------------- Arbitrary proposals on the old Governor -------------------------// @@ -307,7 +404,7 @@ abstract contract GitcoinGovernorUpgradeTestBase is Test { uint256 _id = OLD_GOVERNOR.propose( _proposal.targets, _proposal.values, _proposal.calldatas, _proposal.description ); - assertEq(_id, _proposal.id); + _guardProposalId(_id, _proposal.id, "submitting a proposal directly to the old Governor"); } function _delegatesCastVotesOnOldGovernor(uint256 _proposalId, uint8 _support) internal { @@ -322,7 +419,11 @@ abstract contract GitcoinGovernorUpgradeTestBase is Test { vm.roll(OLD_GOVERNOR.proposalSnapshot(_proposal.id) + 1); _delegatesCastVotesOnOldGovernor(_proposal.id, FOR); vm.roll(OLD_GOVERNOR.proposalDeadline(_proposal.id) + 1); - assertEq(OLD_GOVERNOR.state(_proposal.id), IGovernor.ProposalState.Succeeded); + _guardProposalState( + OLD_GOVERNOR.state(_proposal.id), + IGovernor.ProposalState.Succeeded, + "after the electorate voted a proposal through on the old Governor" + ); } // Queues a succeeded proposal on the old Governor, waits out the Timelock delay, and executes. @@ -341,7 +442,11 @@ abstract contract GitcoinGovernorUpgradeTestBase is Test { _proposal.calldatas, keccak256(bytes(_proposal.description)) ); - assertEq(OLD_GOVERNOR.state(_proposal.id), IGovernor.ProposalState.Executed); + _guardProposalState( + OLD_GOVERNOR.state(_proposal.id), + IGovernor.ProposalState.Executed, + "after executing a queued proposal on the old Governor" + ); } //------------------------------ Proposals on the new Governor ------------------------------// @@ -351,13 +456,21 @@ abstract contract GitcoinGovernorUpgradeTestBase is Test { uint256 _id = governor.propose( _proposal.targets, _proposal.values, _proposal.calldatas, _proposal.description ); - assertEq(_id, _proposal.id); - assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Pending); + _guardProposalId(_id, _proposal.id, "submitting a proposal directly to the new Governor"); + _guardProposalState( + governor.state(_proposal.id), + IGovernor.ProposalState.Pending, + "immediately after submitting a proposal to the new Governor" + ); } function _jumpToProposalActive(uint256 _proposalId) internal { vm.roll(governor.proposalSnapshot(_proposalId) + 1); - assertEq(governor.state(_proposalId), IGovernor.ProposalState.Active); + _guardProposalState( + governor.state(_proposalId), + IGovernor.ProposalState.Active, + "jumping to a proposal's voting window on the new Governor" + ); } function _castVote(address _voter, uint256 _proposalId, uint8 _support) internal { @@ -416,7 +529,11 @@ abstract contract GitcoinGovernorUpgradeTestBase is Test { _jumpToProposalActive(_proposal.id); _delegatesCastVotes(_proposal.id, FOR); _jumpPastProposalDeadline(_proposal.id); - assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Succeeded); + _guardProposalState( + governor.state(_proposal.id), + IGovernor.ProposalState.Succeeded, + "after the electorate voted a proposal through on the new Governor" + ); } function _submitPassQueueAndExecuteProposal(ProposalDetails memory _proposal) internal { @@ -424,7 +541,11 @@ abstract contract GitcoinGovernorUpgradeTestBase is Test { _queueProposal(_proposal); _jumpPastProposalEta(_proposal.id); _executeProposal(_proposal); - assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Executed); + _guardProposalState( + governor.state(_proposal.id), + IGovernor.ProposalState.Executed, + "after executing a queued proposal on the new Governor" + ); } //----------------------------------------- Misc -----------------------------------------// diff --git a/test/helpers/IGtc.sol b/test/helpers/IGtc.sol index e6becde..339dd45 100644 --- a/test/helpers/IGtc.sol +++ b/test/helpers/IGtc.sol @@ -8,6 +8,8 @@ pragma solidity ^0.8.35; interface IGtc { function balanceOf(address _account) external view returns (uint256); function transfer(address _to, uint256 _amount) external returns (bool); + function allowance(address _owner, address _spender) external view returns (uint256); function delegate(address _delegatee) external; function getCurrentVotes(address _account) external view returns (uint96); + function getPriorVotes(address _account, uint256 _blockNumber) external view returns (uint96); } diff --git a/test/helpers/PostUpgradeFranchiserTestBase.sol b/test/helpers/PostUpgradeFranchiserTestBase.sol new file mode 100644 index 0000000..0496826 --- /dev/null +++ b/test/helpers/PostUpgradeFranchiserTestBase.sol @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Franchiser} from "franchiser-expiry/src/Franchiser.sol"; +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {FranchiserLens} from "franchiser-expiry/src/FranchiserLens.sol"; +import {DeployFranchiserMainnet} from "script/DeployFranchiserMainnet.s.sol"; +import {GitcoinGovernorPostUpgradeTestBase} from "test/helpers/GitcoinGovernorUpgradeTestBase.sol"; +import { + ProposeFranchiserDelegationTestConfig +} from "test/helpers/ProposeFranchiserDelegationTestConfig.sol"; +import { + ProposeFranchiserRecallTestConfig +} from "test/helpers/ProposeFranchiserRecallTestConfig.sol"; +import { + RecallExpiredFranchisersTestConfig +} from "test/helpers/RecallExpiredFranchisersTestConfig.sol"; + +// Shared base for the Franchiser fork integration suites. The full Governor upgrade executes in +// setUp — matching the production sequence, where Franchiser adoption follows the upgrade — and +// the Franchiser system then comes into being through its own provenance hook. Holds proposal +// mirrors for the operations scripts' actions and step helpers for driving each script and +// walking its proposal through the new Governor's lifecycle. +abstract contract PostUpgradeFranchiserTestBase is GitcoinGovernorPostUpgradeTestBase { + // Mirrors the delegation script's block-time assumption for converting the Governor's + // block-denominated voting pipeline into seconds when computing expirations. + uint256 constant SECONDS_PER_BLOCK = 12; + + string constant DELEGATION_PROPOSAL_DESCRIPTION = "Delegate treasury GTC through the Franchiser"; + string constant RECALL_PROPOSAL_DESCRIPTION = "Recall Franchiser delegations to the Timelock"; + + FranchiserExpiryFactory factory; + FranchiserLens lens; + + function setUp() public virtual override { + super.setUp(); + (factory, lens) = _fetchOrDeployFranchiser(); + } + + function _fetchOrDeployFranchiser() + internal + virtual + returns (FranchiserExpiryFactory, FranchiserLens); + + //-------------------------- Provenance implementation helpers --------------------------// + + // Deploys the Franchiser system onto the fork by running the real mainnet deploy script, + // exactly as the production deployment will run it. + function _deployFranchiserWithMainnetScript() + internal + returns (FranchiserExpiryFactory, FranchiserLens) + { + DeployFranchiserMainnet _deployScript = new DeployFranchiserMainnet(); + _deployScript.disableLogging(); + _deployScript.run(); + return (_deployScript.factory(), _deployScript.lens()); + } + + //---------------------------------- Expiration helpers ----------------------------------// + + // The earliest expiration the delegation script accepts, mirroring its validation: the + // proposal must remain executable through the voting pipeline (including the late-quorum vote + // extension, in case quorum arrives at the deadline), the Timelock delay, and the grace period + // without the positions expiring. + function _minimumExpiration() internal view returns (uint256) { + return block.timestamp + SECONDS_PER_BLOCK + * (governor.votingDelay() + governor.votingPeriod() + governor.lateQuorumVoteExtension()) + + TIMELOCK.delay() + TIMELOCK.GRACE_PERIOD(); + } + + // A comfortably valid expiration for rounds whose expiry timing is not the point of the test. + function _safeExpiration() internal view returns (uint256) { + return _minimumExpiration() + 30 days; + } + + //---------------------------------- Array construction ----------------------------------// + + function _asArray(address _single) internal pure returns (address[] memory _array) { + _array = new address[](1); + _array[0] = _single; + } + + function _asArray(uint256 _single) internal pure returns (uint256[] memory _array) { + _array = new uint256[](1); + _array[0] = _single; + } + + //----------------------------------- Proposal mirrors -----------------------------------// + + // The two actions of a delegation proposal, mirroring what the delegation script builds. The + // submit helpers guard that the mirror is faithful by recomputing the script-returned proposal + // id from these actions. + function _delegationProposalDetails( + address[] memory _delegatees, + uint256[] memory _amounts, + uint256 _expiration + ) internal view returns (ProposalDetails memory _proposal) { + uint256 _totalAmount = 0; + for (uint256 _index = 0; _index < _amounts.length; _index += 1) { + _totalAmount += _amounts[_index]; + } + _proposal.targets = new address[](2); + _proposal.values = new uint256[](2); + _proposal.calldatas = new bytes[](2); + _proposal.targets[0] = address(GTC_TOKEN); + _proposal.calldatas[0] = abi.encodeCall(IERC20.approve, (address(factory), _totalAmount)); + _proposal.targets[1] = address(factory); + _proposal.calldatas[1] = abi.encodeCall(factory.fundMany, (_delegatees, _amounts, _expiration)); + _proposal.description = DELEGATION_PROPOSAL_DESCRIPTION; + _proposal.id = _hashProposal(_proposal); + } + + // The single action of a recall proposal, mirroring what the recall script builds. + function _recallProposalDetails(address[] memory _delegatees, address[] memory _tokenRecipients) + internal + view + returns (ProposalDetails memory _proposal) + { + _proposal.targets = new address[](1); + _proposal.values = new uint256[](1); + _proposal.calldatas = new bytes[](1); + _proposal.targets[0] = address(factory); + _proposal.calldatas[0] = abi.encodeCall(factory.recallMany, (_delegatees, _tokenRecipients)); + _proposal.description = RECALL_PROPOSAL_DESCRIPTION; + _proposal.id = _hashProposal(_proposal); + } + + //---------------------------- Driving the operations scripts ----------------------------// + + // Submits a delegation round by running the proposal script, exactly as a delegate would, and + // returns the mirrored proposal details for driving the proposal's lifecycle. + function _submitDelegationRound( + address[] memory _delegatees, + uint256[] memory _amounts, + uint256 _expiration + ) internal returns (ProposalDetails memory _proposal) { + ProposeFranchiserDelegationTestConfig _proposeScript = new ProposeFranchiserDelegationTestConfig( + governor, + factory, + PROPOSER, + _delegatees, + _amounts, + _expiration, + DELEGATION_PROPOSAL_DESCRIPTION + ); + _proposeScript.disableLogging(); + _proposeScript.run(); + _proposal = _delegationProposalDetails(_delegatees, _amounts, _expiration); + _guardProposalId( + _proposeScript.proposalId(), + _proposal.id, + "the delegation script vs the _delegationProposalDetails mirror" + ); + } + + // Submits a recall round by running the proposal script, exactly as a delegate would, and + // returns the mirrored proposal details for driving the proposal's lifecycle. + function _submitRecallRound(address[] memory _delegatees, address[] memory _tokenRecipients) + internal + returns (ProposalDetails memory _proposal) + { + ProposeFranchiserRecallTestConfig _proposeScript = new ProposeFranchiserRecallTestConfig( + governor, factory, PROPOSER, _delegatees, _tokenRecipients, RECALL_PROPOSAL_DESCRIPTION + ); + _proposeScript.disableLogging(); + _proposeScript.run(); + _proposal = _recallProposalDetails(_delegatees, _tokenRecipients); + _guardProposalId( + _proposeScript.proposalId(), + _proposal.id, + "the recall script vs the _recallProposalDetails mirror" + ); + } + + // Walks a proposal submitted by an operations script through the rest of its lifecycle: the + // electorate passes it, then it is queued, waited out, and executed. + function _passQueueAndExecuteSubmittedProposal(ProposalDetails memory _proposal) internal { + _jumpToProposalActive(_proposal.id); + _delegatesCastVotes(_proposal.id, FOR); + _jumpPastProposalDeadline(_proposal.id); + _guardProposalState( + governor.state(_proposal.id), + IGovernor.ProposalState.Succeeded, + "after the electorate voted a script-submitted proposal through" + ); + _queueProposal(_proposal); + _jumpPastProposalEta(_proposal.id); + _executeProposal(_proposal); + _guardProposalState( + governor.state(_proposal.id), + IGovernor.ProposalState.Executed, + "after executing a script-submitted proposal" + ); + } + + // The full delegation journey: submit the round via the proposal script, pass, queue, execute. + function _delegateViaProposal( + address[] memory _delegatees, + uint256[] memory _amounts, + uint256 _expiration + ) internal { + ProposalDetails memory _proposal = _submitDelegationRound(_delegatees, _amounts, _expiration); + _passQueueAndExecuteSubmittedProposal(_proposal); + } + + function _delegateViaProposal(address _delegatee, uint256 _amount, uint256 _expiration) internal { + _delegateViaProposal(_asArray(_delegatee), _asArray(_amount), _expiration); + } + + // The full early-recall journey: submit the recall via the proposal script, pass, queue, + // execute. Recalled tokens return to the Timelock. + function _recallViaProposal(address _delegatee) internal { + ProposalDetails memory _proposal = + _submitRecallRound(_asArray(_delegatee), _asArray(address(TIMELOCK))); + _passQueueAndExecuteSubmittedProposal(_proposal); + } + + // Runs the permissionless sweep script over the candidate list and returns the script so tests + // can assert what it recalled. The script broadcasts from Foundry's default sender — an + // arbitrary EOA with no special standing, which is the point. + function _sweepExpiredPositions(address[] memory _candidates) + internal + returns (RecallExpiredFranchisersTestConfig) + { + RecallExpiredFranchisersTestConfig _sweepScript = + new RecallExpiredFranchisersTestConfig(factory, address(TIMELOCK), _candidates); + _sweepScript.disableLogging(); + _sweepScript.run(); + return _sweepScript; + } + + //----------------------------------- Franchiser lookups -----------------------------------// + + // The (deterministic) address of the Timelock's Franchiser for a delegatee, whether or not it + // exists yet. + function _franchiserFor(address _delegatee) internal view returns (Franchiser) { + return factory.getFranchiser(address(TIMELOCK), _delegatee); + } +} diff --git a/test/helpers/ProposeFranchiserDelegationTestConfig.sol b/test/helpers/ProposeFranchiserDelegationTestConfig.sol new file mode 100644 index 0000000..9bf9d33 --- /dev/null +++ b/test/helpers/ProposeFranchiserDelegationTestConfig.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {ProposeFranchiserDelegation} from "script/ProposeFranchiserDelegation.s.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; + +// Test-only configuration for the Franchiser delegation proposal script. The mainnet concrete +// hardcodes its values as committed constants, but a fork test deploys the Governor and factory +// at addresses that cannot be known ahead of time and varies the delegation round per test — so +// this config takes everything by constructor injection instead. All the substantive mechanics +// (action construction, validation, submission) still run in the inherited abstract base. +contract ProposeFranchiserDelegationTestConfig is ProposeFranchiserDelegation { + GitcoinGovernorWithGuardian internal immutable GOVERNOR; + FranchiserExpiryFactory internal immutable FACTORY; + address internal immutable PROPOSER; + uint256 internal immutable EXPIRATION; + address[] internal delegatees; + uint256[] internal amounts; + string internal description; + + constructor( + GitcoinGovernorWithGuardian _governor, + FranchiserExpiryFactory _factory, + address _proposer, + address[] memory _delegatees, + uint256[] memory _amounts, + uint256 _expiration, + string memory _description + ) { + GOVERNOR = _governor; + FACTORY = _factory; + PROPOSER = _proposer; + EXPIRATION = _expiration; + delegatees = _delegatees; + amounts = _amounts; + description = _description; + } + + function _getProposalParams() internal view override returns (ProposalParams memory) { + return ProposalParams({ + governor: GOVERNOR, + factory: FACTORY, + proposer: PROPOSER, + delegatees: delegatees, + amounts: amounts, + expiration: EXPIRATION, + description: description + }); + } +} diff --git a/test/helpers/ProposeFranchiserRecallTestConfig.sol b/test/helpers/ProposeFranchiserRecallTestConfig.sol new file mode 100644 index 0000000..c76e23d --- /dev/null +++ b/test/helpers/ProposeFranchiserRecallTestConfig.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {ProposeFranchiserRecall} from "script/ProposeFranchiserRecall.s.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; + +// Test-only configuration for the Franchiser recall proposal script. The mainnet concrete +// hardcodes its values as committed constants, but a fork test deploys the Governor and factory +// at addresses that cannot be known ahead of time and varies the recalled positions per test — so +// this config takes everything by constructor injection instead. All the substantive mechanics +// (action construction, validation, submission) still run in the inherited abstract base. +contract ProposeFranchiserRecallTestConfig is ProposeFranchiserRecall { + GitcoinGovernorWithGuardian internal immutable GOVERNOR; + FranchiserExpiryFactory internal immutable FACTORY; + address internal immutable PROPOSER; + address[] internal delegatees; + address[] internal tokenRecipients; + string internal description; + + constructor( + GitcoinGovernorWithGuardian _governor, + FranchiserExpiryFactory _factory, + address _proposer, + address[] memory _delegatees, + address[] memory _tokenRecipients, + string memory _description + ) { + GOVERNOR = _governor; + FACTORY = _factory; + PROPOSER = _proposer; + delegatees = _delegatees; + tokenRecipients = _tokenRecipients; + description = _description; + } + + function _getProposalParams() internal view override returns (ProposalParams memory) { + return ProposalParams({ + governor: GOVERNOR, + factory: FACTORY, + proposer: PROPOSER, + delegatees: delegatees, + tokenRecipients: tokenRecipients, + description: description + }); + } +} diff --git a/test/helpers/RecallExpiredFranchisersTestConfig.sol b/test/helpers/RecallExpiredFranchisersTestConfig.sol new file mode 100644 index 0000000..4605e5d --- /dev/null +++ b/test/helpers/RecallExpiredFranchisersTestConfig.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {FranchiserExpiryFactory} from "franchiser-expiry/src/FranchiserExpiryFactory.sol"; +import {RecallExpiredFranchisers} from "script/RecallExpiredFranchisers.s.sol"; + +// Test-only configuration for the expired-position sweep script. The mainnet concrete hardcodes +// its values as committed constants, but a fork test deploys the factory at an address that +// cannot be known ahead of time and varies the candidate list per test — so this config takes +// everything by constructor injection instead. All the substantive mechanics (filtering, +// validation, the recall broadcast) still run in the inherited abstract base. +contract RecallExpiredFranchisersTestConfig is RecallExpiredFranchisers { + FranchiserExpiryFactory internal immutable FACTORY; + address internal immutable OWNER; + address[] internal delegatees; + + constructor(FranchiserExpiryFactory _factory, address _owner, address[] memory _delegatees) { + FACTORY = _factory; + OWNER = _owner; + delegatees = _delegatees; + } + + function _getRecallParams() internal view override returns (RecallParams memory) { + return RecallParams({factory: FACTORY, owner: OWNER, delegatees: delegatees}); + } +}