Skip to Content
DevelopersQuickstart: Roles

Quickstart: permission a Safe with Roles

Goal — In about 20 minutes, you deploy a Roles Modifier on a Safe and give an operator address one narrow power: wrap and unwrap ETH through the WETH contract. Nothing else.

You need:

  • A Safe in your Zodiac  workspace. See Import a Safe if it is not there yet.
  • Bun  and Git. The project template uses Bun.
  • A browser wallet that can sign for your Zodiac account.

This quickstart uses the Zodiac SDK, which applies changes through the Zodiac API.

Step 1: create the project

git clone https://github.com/gnosisguild/zodiac-constellation-template.git my-constellation cd my-constellation bun install

The folders that matter:

constellation/index.ts # entry point; exported nodes get deployed constellation/roles/<role>/ # one folder per role: index, members, permissions zodiac.config.ts # contract registry .zodiac/ # generated code; do not edit

Step 2: connect your account

bun pull

The browser opens for sign-in. The command writes an API key to .env and generates typed code into .zodiac/.

The API key in .env gives write access to your workspace drafts. Do not commit it. Revoke keys at app.zodiac.eco under API keys.

CI and agent runtimes have no browser. Write an existing key to .env as ZODIAC_API_KEY=… before the first bun pull. The command then skips the sign-in. zodiac init mints a new key for the current directory.

Step 3: register the WETH contract

Contracts you want to permission go into zodiac.config.ts, per chain:

import { defineConfig } from '@zodiaceco/sdk/cli/config' export default defineConfig({ contracts: { eth: { weth: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', }, }, })

Run bun pull-contracts. The SDK fetches the ABI and generates a typed permission builder.

The keys you choose here become the paths in the allow kit. The chain key eth gives you allow.eth.weth.…. Keep the same chain key across the whole project.

Step 4: define the role

In a template project, constellation, allow, and c are globals, and Permissions and Members are global types.

Create constellation/roles/ops/permissions.ts:

export default [ allow.eth.weth.deposit({ send: true }), // wrap: deposit() with Ether allow.eth.weth.withdraw(), // unwrap: withdraw(uint256), any amount ] satisfies Permissions

Create constellation/roles/ops/members.ts with the operator address:

export default ['0xYourOperatorAddress'] satisfies Members

Create constellation/roles/ops/index.ts to combine the two files:

export { default as members } from './members' export { default as permissions } from './permissions'

Wire the role to your Safe in constellation/index.ts:

import * as ops from './roles/ops' const eth = constellation({ workspace: 'Default workspace', label: 'Ops permissions', chain: 1, }) // Reference the existing Safe from your workspace. Export it: push() // resolves referenced nodes by export name. export const treasury = eth.safe['Treasury'] export const opsRoles = eth.roles['Ops Roles']({ nonce: 0n, // deploy a new instance owner: treasury, avatar: treasury, target: treasury, roles: { ops }, })

A Roles node takes nonce for a new deployment, or address to bind an existing instance. Never both.

Export every node that another node references, including a Safe that already exists in your workspace. When a referenced node is not an export, bun push stops with the error: Node "Treasury" is referenced but not included in the push() call.

Step 5: review and apply

bun push

The SDK computes the difference between your files and the chain. The browser opens the Zodiac App with the change set: one deployment, the enableModule call, and the permission calls. Review it, then sign with the Safe.

The result on chain: a new Roles Modifier proxy, enabled on the Safe, with the role ops, its member, and the two WETH permissions.

Step 6: execute as the operator

The operator can now wrap ETH through the role — and can do nothing else. Everything you did not allow reverts onchain.

The operator has two ways to execute:

  • Zodiac Pilot — the browser extension routes normal dapp usage through the role.
  • A script — call execTransactionWithRole directly. See Execute as a role member for a complete viem example.

Test the boundary: try a USDC transfer as the operator. It must revert with ConditionViolation — see how to read revert reasons.

Where to go next

Last updated on