Multisig Policy Engine (spec 049)¶
On-chain, opt-in fund policies for the Protect (custody) Safe vaults from spec 043. A policy
constrains what an approved vault transaction may do — after the owners' threshold is met,
before execution. Feature artifacts: specs/049-multisig-policy-engine/.
Architecture¶
Two immutable contracts per chain (no proxies, no admin, no upgrade keys):
| Contract | Deployment key | Role |
|---|---|---|
contracts/custody/SafePolicyGuard.sol |
safePolicyGuard |
Singleton Safe v1.4.1 transaction guard holding every vault's rule config + live accounting. |
contracts/custody/PolicyGuardSetup.sol |
policyGuardSetup |
Stateless Safe.setup delegatecall helper that attaches the guard + initial rules at vault creation. |
A vault opts in either at creation (setup helper) or later via a threshold-approved
setGuard self-transaction. The vault itself is the only authority over its policy: every
SafePolicyGuard.configureRules call requires msg.sender == safe, which is only reachable as a
Safe self-transaction (or from the setup helper's call, which runs in the new proxy's context).
FR-007 (threshold-gated changes) therefore needs no extra approval plumbing — it rides the spec
043 propose/approve queue.
Rules (v1)¶
Per vault, each independently optional:
- Per-transaction limit — per asset (
address(0)= native, or an ERC-20 address). - 24-hour-window limit — per asset; the window opens at the first counted spend and resets 24 h later (fixed-reset, not rolling — see accepted risks).
- Recipient allowlist — token actions gate the decoded beneficiary (
transfer/transferFromrecipient,approvespender); all other calls gate the target address. - Cooldown — minimum delay between counted (fund-moving) transactions.
Counted actions: native value > 0 and the ERC-20 selectors transfer, transferFrom,
approve (approvals are spending grants; counting them closes the approve-then-pull bypass).
Hard denials while any rule is active¶
operation == DELEGATECALL— foreign code in the Safe's context can bypass any guard (and rewrite the guard slot). Consequence: MultiSend batching is unavailable on policy vaults (spec 043 flows are single-call, so nothing regresses).gasPrice != 0— Safe gas refunds payrefundReceiverfrom the vault, an uncounted outflow.
Lockout-proofing (FR-008 / SC-003)¶
Transactions targeting the Safe itself (owner/threshold/guard management) or the guard
(policy config, value == 0 enforced) bypass all fund rules — both still require the vault's
threshold. A policy can therefore always be loosened; no vault can be bricked by its own rules.
Proven on the real Safe in test/integration/policy-guard-safe.test.js.
Accepted risks (documented, disclosed in UI)¶
- Window straddle — fixed-reset windows admit up to 2× the limit across a span straddling a reset. A true rolling window needs unbounded per-tx history; rejected (research.md R3).
- Unvalued calldata — calls the guard cannot value (DEX swaps, arbitrary contract calls) pass spending limits unvalued; they remain subject to the allowlist (call target) and can be fully locked down by enabling it.
- Conservative accounting — window/cooldown state commits in
checkTransaction; if the Safe's inner call fails without reverting the outer transaction, the spend still counts. Overcounting only restricts. - Slither — remaining findings are informational and deliberate: timestamp comparisons (inherent to time-window rules; miner drift of seconds is immaterial against 24 h windows), annotated memory-safe inline assembly (guard-slot sstore, typed-error revert bubbling), and one low-level call in the setup helper (bubbles reverts by design).
Frontend¶
- Library:
frontend/src/lib/custody/policy.js(status/read/encode/preview/decode/describe). Pre-flight uses the guard's ownpreviewTransactionview, so client display can never drift from enforcement. - ABI:
frontend/src/abis/SafePolicyGuard.js. - Surfaces: Policy step in
CreateVaultWizard,PolicyPanelinVaultDetail,PolicyBadgeinVaultCardList+ theVaultSheet(spec 102), pre-flight warnings inProposeTransactionForm, policy-change decoding inProposalQueue. Guard events feed the existingcustodynotification domain. - Network gating:
getContractAddressForChain('safePolicyGuard'|'policyGuardSetup', chainId);undefined⇒ policy UI renders its unsupported states and custody behaves exactly as spec 043. - Attach-to-existing ordering: queue
configureRulesfirst (inert without the guard), thensetGuard(activates) — no half-set gap.
Deployment / rollout¶
npx hardhat run scripts/deploy/custody/deploy-policy-guard.js --network <localhost|mordor|polygon>
npm run sync:frontend-contracts -- --network <net> --chainId <id>
Deterministic CREATE2 via the Safe singleton factory (same salt prefix as the v2 suite), recorded
in deployments/<net>-chain<id>-v2.json under safePolicyGuard / policyGuardSetup. Rollout
follows custody support: Mordor (63) → Polygon (137); both contracts are admin-free so the
deploy key holds no ongoing power.
Deploy-owner checklist per network: after running the script + sync, also add a
safePolicyGuardentry toDEPLOYMENT_BLOCKS_BY_CHAINinfrontend/src/config/contracts.js(the deploy block is printed by the script and recorded indeployments/). The policy-event notification source (FR-016) deliberately no-ops until that bounded scan-start block exists — the never-scan-from-genesis rule (issue #703/#704) gates it, same as the proposal hub. Upgrades ship as a NEW guard deployment that vaults adopt via threshold-approvedsetGuard— never an in-place upgrade (check:storage-layoutis not applicable; there is no proxy).
Tests¶
test/custody/SafePolicyGuard.test.js,test/custody/PolicyGuardSetup.test.js— unit, againstcontracts/mocks/MockSafe.sol.test/integration/policy-guard-safe.test.js— real Safe v1.4.1 (devDependency@safe-global/safe-contracts, compiled test-only viacontracts/mocks/vendor/SafeVendorImports.solWITHOUT viaIR — Safe's assembly is not memory-safe-annotated; see hardhat.config.js overrides).frontend/src/test/custody/policy.test.js+ component suites.