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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -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
95 changes: 69 additions & 26 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
<https://github.com/uniswapfoundation/franchiser-expiry>.

**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**: <https://github.com/ScopeLift/franchiser-expiry>. 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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.

Expand Down
131 changes: 124 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
6 changes: 6 additions & 0 deletions foundry.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
"rev": "620536fa5277db4e3fd46772d5cbc1ea0696fb43"
}
},
"lib/franchiser-expiry": {
"branch": {
"name": "repo-updates",
"rev": "06bcec71e3c7deb15a09d52ac8c1323101dfffaa"
}
},
"lib/openzeppelin-contracts": {
"tag": {
"name": "v5.6.1",
Expand Down
3 changes: 3 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/franchiser-expiry
Submodule franchiser-expiry added at 06bcec
Loading
Loading