Skip to content

Instantly share code, notes, and snippets.

@lucas-manuel
Last active August 26, 2026 07:25
Show Gist options
  • Select an option

  • Save lucas-manuel/04958d4ce6c21c59433f272f5b57c314 to your computer and use it in GitHub Desktop.

Select an option

Save lucas-manuel/04958d4ce6c21c59433f272f5b57c314 to your computer and use it in GitHub Desktop.
Diamond PAU Spark Upgrade

Onboarding the Diamond PAU Controller alongside the existing ALM Controller

Proposal

Spark proposes onboarding the Diamond PAU Controller on Ethereum mainnet in parallel with the existing MainnetController. Both controllers operate the same ALMProxy. The new controller receives its own dedicated RateLimits instance and is wired with exactly three integrations: UNISWAP_V4_FACET (swaps and LPing), CCTP_FACET (CCTP v2 transfers), and DUAL_POOL_FACET (LPing through the DualPoolHook).

  1. No funds are migrated. The custody contract keeps its bytecode, storage, approvals, and positions.
  2. The legacy MainnetController is completely untouched, so the upgrade is fully backwards compatible.
  3. The new controller can execute exactly seven value-moving operationsswap, mintPosition, increasePosition, decreasePosition (UniV4), transfer (CCTP), deposit, withdraw (DualPool). Every other call reverts.
  4. The new controller's rate limits are a separate instance, so it is impossible for it to consume, or even read into, the high limits configured in the live system.
  5. Rollback is one governance transaction: revoke CONTROLLER from the new controller.

Link to diagram image

Why the Diamond PAU Controller cannot do anything unexpected

ALMProxy grants the CONTROLLER role unrestricted call authority — that is true for the legacy controller today and for any controller ever onboarded. Containment therefore has to come from what the controller itself can be made to execute, and for the Diamond PAU Controller that is provable from four properties of the code:

1. The dispatch table is closed. The Controller has no execute(target, data) function and no other passthrough. Its fallback() does one thing: look up the incoming selector in a local dispatch table and delegatecall the wired facet, reverting with CallSelectorNotWired for anything else. The complete reachable surface is the synced integration set — 40 wired selectors across the three facets, of which seven move value. Nothing outside that list can execute with the proxy's authority, and the list is verifiable on-chain by anyone via getDispatch.

2. Arming new code requires two governance bodies. A facet becomes reachable only if Sky governance registers it on the Beacon and the Spark proxy syncs it via updateIntegrations. Neither body acting alone can make new code executable. The onboarding spell additionally pins the sync: it reads Beacon.getConfig(id) in the same transaction and requires the facet address and wire count (17 / 10 / 13) to match the hardcoded audited values, so what goes live is exactly the reviewed bytecode.

3. Within the seven operations, no allocator input can redirect value. Every external target is a bytecode immutable (positionManager, router, permit2, cctp, usdc, hook). Every recipient is either the ALMProxy itself or governance-set (mintRecipient per CCTP domain). Pool keys are resolved from the position manager and hash-checked against the caller's poolId; the DualPool facet requires key.hooks == hook. Token approvals are set and zeroed within the same call, and every function is nonReentrant. A fully compromised allocator key cannot choose a call target, forge a pool, pick a bridge recipient, or leave a standing approval — the worst it can do is perform the intended operations, sub-optimally, inside slippage bounds and rate limits.

4. Every value flow is metered by an independent, fail-closed rate limit. Each of the seven operations decrements one or more keys on the dedicated RateLimits instance before funds move, and an unset key (maxAmount == 0) reverts the call — the default state of the new controller is that it can do nothing. Because the instance is dedicated, the two controllers hold no roles on each other's rate limit storage: the Diamond controller cannot consume or refill any legacy budget, and vice versa. This also neutralizes the byte-identical key derivations that exist between the two codebases (UniV4 aggregate deposit/withdraw, CCTP total and per-domain), which under a shared instance would have coupled live budgets from the first call.

Taken together: the only code that can ever execute with the proxy's authority is the audited facet set pinned at sync time; within that set no input can redirect funds; and no flow escapes a rate limit that governance sets and that starts at zero. Absolute exposure is therefore capped by the rate limit configuration — a number governance chooses — independent of how new the system is.

The four identified risks

Risk 1 — a young system with access to a large balance. The containment above is structural, not reputational: rate limits bound the new controller's absolute exposure to whatever governance configures, from day one, regardless of the balance the proxy holds. Spark is happy to launch at minimal caps and ramp gradually — the absolute numbers are deliberately delegated to risk. What does not follow is gating onboarding on the Diamond PAU system first accumulating multi-billion-dollar Lindy elsewhere: exposure is decoupled from code age by the rate limits, so the ramp is the Lindy accumulation, at a cost bounded at every step by the caps in force.

Risk 2 — misconfiguration of a fresh deployment. Nothing is migrated and the four new contracts custody nothing, so exposure arises from a single role grant and is reversed by a single revoke — there are no funds to recover from a bad deployment. Granting unscoped CONTROLLER on this exact proxy to freshly deployed bytecode is also the same operation every previous ALM controller upgrade performed; what is new is only that the previous grant stays in place. Finally, the configuration surface fails closed: unset rate limit keys, tick limits, slippage bounds, and CCTP domain parameters each revert the corresponding operation, so the default consequence of a missed parameter is that the new controller cannot act — not that it acts wrongly. The handful of fail-open values (CCTP mintRecipient, fee caps, slippage bounds) get explicit per-value verification in the spell, and initial caps are sized so that a wrong value is survivable while it is discovered.

Risk 3 — a new facet reaching beyond its own integration. Facets run by delegatecall, so the per-facet trust root is the audited facet bytecode — which is why properties 1 and 2 matter: no facet is reachable unless it is in an audited release, registered by Sky, and pinned by the Spark spell. All three launch facets meet this bar: UniswapV4Facet and CCTPFacet are in audited releases today, and the launch is blocked on DualPoolFacet, which enters the September 7 audit and ships only as part of an audited release. Each facet also has its own kill switch — removeIntegrations([id]) deletes its dispatches without touching the other facets or the legacy controller — a finer-grained response than anything the monolithic controller offers. And the operation is strictly smaller than the accepted precedent: previous controller upgrades swapped 40+ integrations of fresh bytecode at once; this adds three facets to a dispatch table that starts empty.

Risk 4 — the non-standard double controller setup. This topology is not novel to auditors: the ChainSecurity Diamond PAU v1.13 report reviews it explicitly, describing "backing one ALMProxy with several Controller contracts," each with its own AccessControls, synced integration set, and dedicated or shared RateLimits. The report's sole multi-controller finding (CS-SKYDPAU-044, severity Low, risk accepted) concerns interleaving between sibling controllers, and requires an external call that yields control to untrusted code mid-operation. This configuration does not provide one: the new controller's external targets are Uniswap core contracts, CCTP, and the governance-owned DualPoolHook, and pool onboarding is a governance action restricted to hookless or reviewed, non-upgradeable hooks — with damage bounded by rate limits and slippage even in the counterfactual. The double-spend and shared-key concerns raised for Grove's setup are removed outright by the dedicated RateLimits instance (no shared keys exist), and the configurator is compatible as-is: it targets the same RateLimits code and interface, simply pointed at the new instance.

Rollout

  1. Deploy (permissionless, via PAUFactory and AdministeredAgentFactory): AccessControls, RateLimits, Controller, AdministeredAgent. Verify constructor arguments and admin cardinality on-chain before any grant.
  2. Spark spell — grants: CONTROLLER on the ALMProxy and on the new RateLimits to the new controller; ALLOCATOR_ROLE to the AdministeredAgent; relayer multisigs as agent actors, freezer multisig as revoker. The agent holds no role anywhere else, so it cannot reach anything but the Diamond PAU functions.
  3. Sky spell — Beacon registration of the three facets.
  4. Spark spell — sync and configure: pinned updateIntegrations, then rate limit keys and facet parameters, followed by on-chain verification (integrations(), getDispatch for wired and legacy selectors, role member counts on all four contracts, a small round trip per facet).
  5. Ramp: initial caps set by risk at minimal size, increased through subsequent spells as volume accumulates incident-free. For any operation both controllers can perform, total capacity is the sum of the two budgets; any spell changing one side states the other side's current value.

This same pattern is the intended migration path for existing functionality: onboard a facet on the new controller at a low limit while the legacy limit stays high, let it accumulate history, and shift volume gradually — with the legacy route available throughout, so migration is backwards compatible at every step.

Emergency response: freezing allocator activity across both controllers takes two transactions — MainnetController.removeRelayer on the legacy side and AdministeredAgent.removeActor on the Diamond side — both held by the existing freezer multisig and documented in the runbook. Disabling a single integration (removeIntegrations) or fully revoking the controller requires only the Spark proxy.

Rollback

ALMProxy.revokeRole(CONTROLLER, diamondController);
newRateLimits.revokeRole(CONTROLLER, diamondController);

The legacy RateLimits needs no cleanup — the Diamond controller never held a role on it. After revocation the new controller retains no authority; open UniV4 positions remain owned by the ALMProxy and can be unwound through the legacy controller (would require an upgrade), and in-flight CCTP messages complete at the destination as normal.

References

  • ChainSecurity, Diamond PAU v1.13 — multi-controller topology (System Overview) and CS-SKYDPAU-044
  • diamond-pau: Controller.sol (fallback, updateIntegrations), RateLimits.sol, Beacon.sol, facet sources
  • diamond-pau docs: ARCHITECTURE.md (Multi-Controller Topology), THREAT_MODEL.md, RATE_LIMITS.md, BEACON.md
  • sky-pau-registry — canonical audited addresses for the Diamond PAU core and facets
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment