[BIP-842] Deploy kpk Sub-Roles architecture

PR with payload

https://github.com/BalancerMaxis/multisig-ops/pull/1892

Tenderly simulation available here.

Abstract

Following our initial post, we’re proposing to roll out a new feature that boosts both flexibility and security: the Sub-Roles Modifier architecture.

The core benefits for the DAO—enabling the Swapper and Disassembler roles—remain unchanged from our previous posts. This updated version proposes a change in how these roles are managed—introducing a modular architecture that separates role execution from governance-level permissioning.

Rather than requiring a separate governance proposal for every sub-role update, the proposed structure allows the existing Manager Safe to manage sub-role creation, setup and updates directly. However, any transaction initiated through these sub-roles must still pass through the existing Manager Roles Modifier, meaning that the change is purely for efficient management of existing permissions, rather than permission creation or amendment.

This setup reduces the need for future DAO interactions, whilst retaining our existing guarantee that any transaction or function call outside the defined Manager Role cannot be executed—making it easier for the DAO to monitor and audit operations with confidence.

Motivation

The sub-roles setup brings several operational benefits:

  • Streamlined Operational Control
    Agile execution roles can be spun up, adding responsiveness for edge-case scenarios, without having to duplicate permissions that are already in the Manager Roles contract.
  • Clear Separation of Duties
    With sub-roles, we can define tightly-scoped permissions for different agents (i.e. Disassembler, Swapper and/or Harvester agents) while still routing all transactions through the Manager Roles Modifier. This maintains all the existing permissions in one place, but without sacrificing flexibility in having to reset permissions from the Avatar Safe every time an agent is added or changed.
  • Simplified Deployment & Management
    The Avatar Safe deploys a Sub-Roles Modifier as a module in the Manager Roles once. The target of the sub-roles will be the parent Manager Role, and the owner will be the Manager Safe. This way, the Manager Safe can handle maintenance on all sub-roles without the need to seek additional permissions from the Avatar Safe.

In the above flowchart you can see that any sub-role that is set up in the lower level can initiate a transaction through different agents. But regardless of how the transaction is initiated, the calldata’s arguments will need to fit within the previously-allowed/listed permissions granted to the existing Manager Role.

For example: a “Harvester EOA” could be automated to routinely claim all reward tokens on the managed treasury through the “Harvester Role”; subsequently, a “Swapper EOA” could be automated to routinely swap all claimed tokens for ETH using the “Swapper Role”. These actions will be executed on the Avatar Safe so long as the permissions exist on the Manager Role. The primary benefit is that these automations can then be configured, amended or even disabled independently with complete flexibility (within the scope of existing permissions), allowing for granular management of each operation.

These sub-roles can be configured as part of our internal execution app allowing greater flexibility and granular control with minimal effort and risk. The benefits were described in the first iteration of this proposal.

Technical implementation

The process involves deploying a new Sub-Roles Modifier, configuring it as a sub-role of the existing Roles contract, and assigning it the appropriate permissions. Here’s what happens under the hood:

  1. Deploy ZRM B via ModuleProxyFactory.
  2. Enable ZRM B as a module in ZRM A (existing Manager Roles).
  3. Set the default role for ZRM B inside ZRM A.
  4. Assign ZRM B to the MANAGER role in ZRM A.
  5. Configure ZRM B to target ZRM A.
  6. Transfer ownership of ZRM B from the Avatar Safe to the Manager Safe.

Upon execution of this payload, the Manager Safe will then have full power to create and amend sub-roles under ZRM B, within the existing permissions defined already under ZRM A. No further action is required to implement the sub-roles architecture beyond this payload.

Avatar Safe: 0x0EFcCBb9E2C09Ea29551879bd9Da32362b32fc89
Sub-Roles Modifier (ZRM B): 0xae7ee392Fd7C8aE7391b4Fc10fc4b942A994545C

Update for transparency:

We want to share the following Tenderly simulation using Virtual TestNets to showcase how Sub-Roles functions using a test EOA and Roles.

The simulation covers:

  1. Deployment and configuration of the Sub-Roles Modifier (first 8 transactions starting from the “deployModule” up; these are the same actions than the ones in the PR for this proposal).
  2. Assigning a sample EOA (0x5c0c7…ae43) to a new MANAGER role (9th txn).
  3. The following 2 transaction (10th and 11th) apply the policy to the MANAGER Role of the Sub-Roles Modifier, allowing the approval of wstETH with the Aave V3 Core Pool as spender.
  4. A successful transaction execution through the sub-role (approving wstETH with the Aave V3 Core Pool as spender).
  5. A failed transaction that correctly reverts due to insufficient permissions (attempting to approve USDC for the Aave V3 Core Pool, where only wstETH is allowed).

We hope these simulations help clarify the Sub-Roles architecture and its functionalities. As always, we’re happy to receive any questions or feedback.

Gnosis Chain Payload

Following the deployment of the kpk-managed treasury on Gnosis Chain via BIP-732, we also intend to update the Gnosis Chain setup to adopt the Sub-Roles Modifier architecture described in this proposal.

To support this, we’re sharing a Tenderly VTNet simulation, which demonstrates:

  • Deployment of the full payload for the Sub-Roles Modifier
  • Application of a test policy to a MANAGER role within the new architecture, replicating an existing permission from the main role
  • Two simulated transactions from a role member — one successful, one failing — confirming expected behaviour

Simulation: Tenderly VTNet
PR with payload: [BIP-XXX] Deploy kpk Sub-Roles architecture by JeronimoHoulin · Pull Request #1892 · BalancerMaxis/multisig-ops · GitHub

Preamble

I’m Alex The Entreprenerd a Lead Security Researcher, specialized in the EVM

Founder of Recon, a boutique audit firm that uses Invariant Testing for Security Reviews

As of today I’ve been awarded over $500,000 in public rewards and I’ve prevented around $20 MLN in damages to deployed contracts

I was tasked by the Balancer Maxis to perform DD for the payload above

Executive Summary

  • mainnet:0x0EFcCBb9E2C09Ea29551879bd9Da32362b32fc89 | 6 / 11
  • gnosis:0x0EFcCBb9E2C09Ea29551879bd9Da32362b32fc89 | 6 / 11

0xcf4fF1e03830D692F52EB094c52A5A6A2181Ab3F not present on mainnet, there’s a different signer between the two chains

Both payloads are the same except chainId

The payloads will perform the following:

  • Perform the same operation on both Gnosis Chain and Mainnet, the only difference in payload is the chainId, the bytecode must match exactly between the two
  • Deploy a copy of Roles (same bytecode as the already deployed Roles contracts on Mainnet and Gnosis Chain)
  • Grant ownership of the New Roles Contract to the kpk Multisig (2/9)
  • The New Roles Contract will receive the roleKey: 0x4d414e4147455200000000000000000000000000000000000000000000000000 on the Mainnet Roles
  • We believe this allows the kpk multisig to assign new roles on the New Roles Contract
  • While the operations that can be performed on the 0x13c6 Roles Contract will be limited
  • We are missing a tool to be able to determine accurately what the roleKey will do and it’s limitations

We could build a tool to scrape logs, but would ask if there’s already a tool available

Asks for Kpk

  1. Can you please provide us with a tool that would allow us and the public to determine the exact scopes and limitations for the roleKey: 0x4d414e4147455200000000000000000000000000000000000000000000000000?

We could build a tool to scrape logs if not available, but would expect such a tool to already exist

  1. The Payload seems to set a default role and then assign the same role, is this necessary?

We would not necessarily suggest changes but believe it’s worth clarifying why both settings have been enabled instead of just one

New Configuration After the Payload

Notable Difference between Mainnet and Gnosis Chain

The Safe on Mainnet has 2 Roles contract already setup

Roles | Address 0x13c61a25DB73e7a94a244bD2205aDba8b4a60F4a | Etherscan ← The one being integrated with
Roles | Address 0xd8dd9164E765bEF903E429c9462E51F0Ea8514F9 | Etherscan ← An old one, not present on Gnosis Chain

General Recommendations for the DAO

We Recommend reviewing old roles contracts and possibly deprecating them

We recommend updating signers to make sure they are consistent

Tx by Tx review

Below is a technical review of every transaction

Payload Review

  • Diffed for both chains, same payload, except chain ID
  • Contract that get’s deployer: 0xae7ee392Fd7C8aE7391b4Fc10fc4b942A994545C

TX1

      {
        "to": "0x000000000000aDdB49795b0f9bA5BC298cDda236", /// ModuleProxyFactory
        "value": "0",
        "data": null,
        "contractMethod": {
          "inputs": [
            {
              "name": "masterCopy",
              "type": "address",
              "internalType": "address"
            },
            {
              "name": "initializer",
              "type": "bytes",
              "internalType": "bytes"
            },
            {
              "name": "saltNonce",
              "type": "uint256",
              "internalType": "uint256"
            }
          ],
          "name": "deployModule",
          "payable": false
        },
        "contractInputsValues": {
          "masterCopy": "0x9646fDAD06d3e24444381f44362a3B0eB343D337",
          "initializer": "0xa4f9edbf000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000efccbb9e2c09ea29551879bd9da32362b32fc890000000000000000000000000efccbb9e2c09ea29551879bd9da32362b32fc890000000000000000000000000efccbb9e2c09ea29551879bd9da32362b32fc89",
          "saltNonce": "1742396655395"
        }
      }

// 0x9646fDAD06d3e24444381f44362a3B0eB343D337 → ROLES
// Same bytecode on both

setUp
0x0EFcCBb9E2C09Ea29551879bd9Da32362b32fc89 X 3

I guess this enforces there are no other modules on the code

  function setupModules() internal {
    if (modules[SENTINEL_MODULES] != address(0))
      revert SetupModulesAlreadyCalled();
    modules[SENTINEL_MODULES] = SENTINEL_MODULES;
  }

NOTE: This is setting modules inside of the Roles contract, these are roles that do not impact the safe directly

TX 2

      {
        "to": "0x13c61a25DB73e7a94a244bD2205aDba8b4a60F4a",
        "value": "0",
        "data": null,
        "contractMethod": {
          "inputs": [
            {
              "internalType": "address",
              "name": "module",
              "type": "address"
            }
          ],
          "name": "enableModule",
          "payable": false
        },
        "contractInputsValues": {
          "module": "0xae7ee392Fd7C8aE7391b4Fc10fc4b942A994545C"
        }
      },

Calls Original Roles Modifier:

And enable this module: 0xae7ee392Fd7C8aE7391b4Fc10fc4b942A994545C

  function enableModule(address module) public override onlyOwner {
    if (module == address(0) || module == SENTINEL_MODULES)
      revert InvalidModule(module);
    if (modules[module] != address(0)) revert AlreadyEnabledModule(module);
    modules[module] = modules[SENTINEL_MODULES];
    modules[SENTINEL_MODULES] = module;
    emit EnabledModule(module);
  }

Adds it to the linked list

TX 3

     {
        "to": "0xae7ee392Fd7C8aE7391b4Fc10fc4b942A994545C",
        "value": "0",
        "data": null,
        "contractMethod": {
          "inputs": [
            {
              "internalType": "address",
              "name": "to",
              "type": "address"
            },
            {
              "internalType": "bytes4",
              "name": "selector",
              "type": "bytes4"
            },
            {
              "internalType": "contract ITransactionUnwrapper",
              "name": "adapter",
              "type": "address"
            }
          ],
          "name": "setTransactionUnwrapper", 
          "payable": false
        },
        "contractInputsValues": {
          "to": "0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526",
          "selector": "0x8d80ff0a",
          "adapter": "0x93B7fCbc63ED8a3a24B59e1C3e6649D50B7427c0"
        }
      },
    function setTransactionUnwrapper(
        address to,
        bytes4 selector,
        ITransactionUnwrapper adapter
    ) external onlyOwner {
        unwrappers[bytes32(bytes20(to)) | (bytes32(selector) >> 160)] = adapter;
        emit SetUnwrapAdapter(to, selector, adapter);
    }

0x9641d764fc13c8B624c04430C7356C1C7C8102e2 - Mutisend Call Only

  • 0x8d80ff0a - multiSend(bytes)

Multisend unwrapper

Seems to allow to perform any arbitrary call


TX 4

Same as TX3 with 0x9641d764fc13c8B624c04430C7356C1C7C8102e2

Both are setting multicall target and paylod ability?

TX 5

      {
        "to": "0x13c61a25DB73e7a94a244bD2205aDba8b4a60F4a",
        "value": "0",
        "data": null,
        "contractMethod": {
          "inputs": [
            {
              "internalType": "address",
              "name": "module",
              "type": "address"
            },
            {
              "internalType": "bytes32",
              "name": "roleKey",
              "type": "bytes32"
            }
          ],
          "name": "setDefaultRole",
          "payable": false
        },
        "contractInputsValues": {
          "module": "0xae7ee392Fd7C8aE7391b4Fc10fc4b942A994545C",
          "roleKey": "0x4d414e4147455200000000000000000000000000000000000000000000000000"
        }
      },
    /// @param roleKey Role to be set as default.
    function setDefaultRole(
        address module,
        bytes32 roleKey
    ) external onlyOwner {
        defaultRoles[module] = roleKey;
        emit SetDefaultRole(module, roleKey);
    }

0x4d414e4147455200000000000000000000000000000000000000000000000000 UNCLEAR what this is

TX 6

{
        "to": "0x13c61a25DB73e7a94a244bD2205aDba8b4a60F4a",
        "value": "0",
        "data": null,
        "contractMethod": {
          "inputs": [
            {
              "internalType": "address",
              "name": "module",
              "type": "address"
            },
            {
              "internalType": "bytes32[]",
              "name": "roleKeys",
              "type": "bytes32[]"
            },
            {
              "internalType": "bool[]",
              "name": "memberOf",
              "type": "bool[]"
            }
          ],
          "name": "assignRoles",
          "payable": false
        },
        "contractInputsValues": {
          "module": "0xae7ee392Fd7C8aE7391b4Fc10fc4b942A994545C",
          "roleKeys": "[\"0x4d414e4147455200000000000000000000000000000000000000000000000000\"]",
          "memberOf": "[true]"
        }
      },

Assigns the role 0x4d414e4147455200000000000000000000000000000000000000000000000000 to 0xae7ee392Fd7C8aE7391b4Fc10fc4b942A994545C

Why do we need to do both? TODO | Can’t we just use one of the two options?

TX 7

      {
        "to": "0xae7ee392Fd7C8aE7391b4Fc10fc4b942A994545C",
        "value": "0",
        "data": null,
        "contractMethod": {
          "inputs": [
            {
              "internalType": "address",
              "name": "_target",
              "type": "address"
            }
          ],
          "name": "setTarget",
          "payable": false
        },
        "contractInputsValues": {
          "_target": "0x13c61a25DB73e7a94a244bD2205aDba8b4a60F4a"
        }
      },

We set the target for the contract we deployed to be the old roles (available in both Mainnet and Gnosis Chain)

This will allow performing operations on Target, which is the module originally installed

TX 8

      {
        "to": "0xae7ee392Fd7C8aE7391b4Fc10fc4b942A994545C",
        "value": "0",
        "data": null,
        "contractMethod": {
          "inputs": [
            {
              "internalType": "address",
              "name": "newOwner",
              "type": "address"
            }
          ],
          "name": "transferOwnership",
          "payable": false
        },
        "contractInputsValues": {
          "newOwner": "0x60716991aCDA9E990bFB3b1224f1f0fB81538267" 2 / 9 Multisig
        }
      }

Grants ownership to the 2/9 kpk multi

Owner can Set:

  • Target
  • Avatar
  • Default Role
  • Assign Roles
  • Enable Module

Notes off of the review

We noticed that the code for the roles, and their validation relies on low level operations, as well as calldata for signatures

We assume that the Roles module is safe and works as intended when a contract is granted a role

2 Likes

Thank you @Entreprenerd for your thorough review and the time you’ve dedicated to auditing these payloads. Your commitment and detailed feedback are deeply appreciated.

Any modifications to permission payloads must be approached with utmost diligence and transparency. This ensures the DAO can maintain confidence in the integrity and reliability of all executed transactions.

Below, we address each of the points you’ve raised:

  1. “0xcf4fF1e03830D692F52EB094c52A5A6A2181Ab3F not present on mainnet, there’s a different signer between the two chains”.

    Great catch — thanks for flagging this.

    This is a signer set change that has indeed been implemented on Ethereum Mainnet, and is currently in queue for Gnosis Chain. We’re coordinating its execution with Maxis.

  2. “Can you please provide us with a tool that would allow us and the public to determine the exact scopes and limitations for the roleKey: 0x4d414e4147455200000000000000000000000000000000000000000000000000”

    The Zodiac Roles UI has become a central part of our migration to Roles V2, as outlined in BIP-667.

    You can use the following link to inspect the live, on-chain permissions currently assigned to the Manager Roles Module.

    Where:

  • 0x13c61a25DB73e7a94a244bD2205aDba8b4a60F4a is the Roles V2 contract instance.
  • 0x4d414e4147455200000000000000000000000000000000000000000000000000 corresponds to the Manager Role key, assigned to the Manager Safe. (Note: You can use a Hex-to-Text decoder (e.g. this one) to convert the Role Key into its ASCII representation, which is; “MANAGER”).
  • All permissioned target contracts, their function selectors, and scoped parameters should be available for anyone to verify.
  1. “The Payload seems to set a default role and then assign the same role, is this necessary?”

    As you rightly pointed out—and as outlined in the architecture flowchart—we’ll deploy a Sub-Roles Module with its Role Key set to match the Manager Role Key (0x4d414e4147455200000000000000000000000000000000000000000000000000).

    The key distinction is that ownership of this Sub-Role Module will be assigned to the Manager Safe. This design ensures that any nested roles created within the Sub-Role Module remain fully scoped and cannot exceed the permissions defined in the MANAGER Roles Module.

  2. “We Recommend reviewing old roles contracts and possibly deprecating them”.

    To keep things clean and consistent, we’ll queue the removal of the Manager Role V1 from the Managed Treasury and coordinate its execution with the Maxis.

  3. “MultisendUnwrapper seems to allow you to perform any arbitrary call”.

    The MultiSendUnwrapper enables the Roles contract to decode and evaluate each call within a batched transaction, such as those sent through the multiSend(bytes) function. This allows complex actions—like “claim, wrap, and send”—to be permission-checked individually, even when bundled into a single transaction.
    You’ll find more about this function in the Zodiac Roles documentation.

1 Like

Thank you for the prompt reply and additional information @kpk

Replies and additional review

  1. Sounds good

  2. I have verified the permissions through the interface

It’s worth noting that due to tooling limitation (mainly due to the inability if not outright impossibility to price an asset at a specific instance)

The operations rely on the operator being benign as they may otherwise skim value through repeated transactions with high slippage / requesting a low minOut.

It’s worth noting that Cowswap generally offers protection against it.

However, for swaps of a sufficiently high size, Cowswap may also fail to provide swap protection.

This is probably a known issue with the current implementation of the Roles module, and warrants that the community monitors the Managed Treasury for a pattern that matches this.

A good recommendation is to always use an MEV Protected RPC

I have checked the last few transactions broadcasted to the Roles contract and that does seem to be the case:
https://rpc.mevblocker.io/tx/0x37a81d074df398e01462d289b329b250d40b92533ed260995df41e44987444c0

The community should track the possible ETH refunds with the expectation that those refunds will be sent back to the Managed Treasuery

  1. Setting a default role allows to use the shortcut function execTransactionFromModule as it will use the default role and then check for belonging to said role:
    function execTransactionFromModule(
        address to,
        uint256 value,
        bytes calldata data,
        Enum.Operation operation
    ) public override returns (bool success) {
        Consumption[] memory consumptions = _authorize(
            defaultRoles[msg.sender],
            to,
            value,
            data,
            operation
        );

This means that both calls (TX 5 and TX 6) are necessary as you will add the newly deployed Roles contract as Module of the current Roles Contract

  1. Sounds good

  2. I have verified the unwrapper logic, through checking the contract source as well as the provided documentation.

    function unwrap(
        address,
        uint256 value,
        bytes calldata data,
        Enum.Operation operation
    ) external pure returns (UnwrappedTransaction[] memory) {
        if (value != 0) {
            revert UnsupportedMode();
        }
        if (operation != Enum.Operation.DelegateCall) {
            revert UnsupportedMode();
        }
        _validateHeader(data);
        uint256 count = _validateEntries(data);
        return _unwrapEntries(data, count);
    }

Assuming the contract works as intended, it will parse the calldata length, and the offsets, then proceed to return each individual multiSend payload as an individual transaction to be validated by the Roles Module

This will allow multiSend to be used, while ensuring every call is verified by the roles modules.

Next steps

I have no more questions, and agree with the suggested next steps of the removal of the Manager Role V1 from the Managed Treasury

Happy to answer any questions the community may have.

I recommend that in the future, a calldata payload is also provided, ensuring that all steps of the public review match the exact code that will be executed onchain

2 Likes

https://snapshot.box/#/s:balancer.eth/proposal/0x4526a0204044f02a5902f0d3243d9874a7204586ede269f99df1b125f80875ce