SailGovernance & Timelock
SailGovernance is the protocol parameter store. It holds two categories of settings — constitutional caps that are immutable after deployment, and tunable parameters that the current governance address may change, always through a 48-hour timelock. License: GPL-2.0-or-later.
Constitutional caps (immutable; no procedure can raise them)
Section titled “Constitutional caps (immutable; no procedure can raise them)”| Cap | Value | Bounds |
|---|---|---|
MAX_PROTOCOL_CUT_BPS | 2500 (25%) | the protocol’s share of any fee collection |
MAX_PERMISSION_FEE_WEI | set at deploy, ≤ 0.01 ether | the per-permission registration fee |
MAX_PERMISSIONS_CAP | 100 | the per-account permission limit |
MAX_PERMISSION_FEE_WEI is immutable and the constructor itself reverts if it is set above the 0.01 ether bytecode ceiling. The other two are constant.
Tunable parameters (within the caps, via timelock)
Section titled “Tunable parameters (within the caps, via timelock)”| Parameter | Setter | Default | Bound |
|---|---|---|---|
currentProtocolCutBps | setProtocolCutBps | 0 | MAX_PROTOCOL_CUT_BPS |
permissionRegistrationFee | setPermissionRegistrationFee | 0 | MAX_PERMISSION_FEE_WEI |
maxPermissionsPerAccount | setMaxPermissionsPerAccount | 20 | 1 … MAX_PERMISSIONS_CAP |
Every setter is onlyTimelock: changes are scheduled on the timelock, wait 48 hours, then execute. The protocol cut and registration fee are zero at launch.
The injected timelock
Section titled “The injected timelock”SailGovernance does not build its timelock inline. A standalone OpenZeppelin TimelockController is deployed separately and injected via the constructor — this is what makes every constructor argument chain-independent and enables the same-address deployment. The constructor then validates the injected timelock and reverts if any invariant fails:
| Check | Error |
|---|---|
| Minimum delay is exactly 48 hours | TimelockDelayMismatch |
initialGovernance holds PROPOSER_ROLE | GovernanceNotProposer |
initialGovernance holds EXECUTOR_ROLE (rejects open-executor timelocks) | GovernanceNotExecutor |
| The timelock self-administers its roles; the governance EOA does not hold the proposer-admin role | TimelockNotSelfAdministered |
REQUIRED_TIMELOCK_DELAY is 48 hours; an exact match (not a lower bound) is required so the injected timelock reproduces the audited behaviour precisely.
Two-step governance transfer
Section titled “Two-step governance transfer”Governance transfer is propose → accept to prevent loss from a mistyped successor:
- Current governance calls
proposeGovernance(candidate)(via timelock). - Current governance schedules + executes
rotateTimelockRoles(old, new)(via timelock) to handPROPOSER/EXECUTOR/CANCELLERroles to the candidate and revoke them from the outgoing governance. - The candidate calls
acceptGovernance(). This reverts withRolesNotYetRotatedunless the candidate already holdsPROPOSER_ROLE— eliminating any window where old governance retains timelock keys after handoff.
Trusted allowlists
Section titled “Trusted allowlists”The kernel consults these governance-held allowlists at account creation and fee-policy changes:
| Allowlist | Gates |
|---|---|
trustedSafeFactory | which Safe proxy factories createAccount may use |
trustedSafeSingleton | which Safe singletons createAccount may use |
trustedModuleSetup | which helper may be the to of Safe.setup’s delegatecall (blocks attacker setup delegatecalls) |
trustedSafeProxyCodehash | which proxy runtime codehashes count as a genuine Safe |
trustedFeePolicy | which fee policies may be attached (blocks upgradeable/metamorphic policies) |
All five setters are onlyTimelock. The one exception is genesis: bootstrapAllowlists(...) lets the initial governance seed all five once, in the deployment transaction, bypassing the timelock — then allowlistBootstrapped latches true forever and every later change must go through the 48-hour timelock.
Emergency pause
Section titled “Emergency pause”A separate emergencyAdmin (rotatable only via timelock) may pause() the kernel without a timelock delay, for fast incident response:
- The pause auto-expires after 72 hours (
pauseExpiry);isPaused()returnsblock.timestamp < pauseExpiry. - A
PAUSE_COOLDOWNof 72 hours sits between consecutive pauses to prevent spam. unpause()lifts it early and clears the cooldown.
While paused, dispatch, dispatchBatch, collectFees, and permission registration revert — but revocation, session revocation, manager rotation, and clearing a fee policy stay available, so owners can always reduce exposure during an incident.
Continue to the MandateFactory, or the full fee model and governance pages.