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.
- Node.js 22 or later. pnpm and Bun work too.
- 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
npm create @zodiaceco@latest my-constellation
cd my-constellationThe command copies the project template into my-constellation, installs its
dependencies, and commits it to a new git repository. Run it as
pnpm dlx @zodiaceco/create or bunx @zodiaceco/create to install with pnpm or Bun
instead.
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 editThe template comes with an example on Ethereum: a new Safe, a Roles Modifier on it, and a
role treasury_ops. This quickstart replaces it with a role on a Safe you already have.
Step 2: connect your account
The command offers to connect the project to Zodiac. Accept, and the browser opens for
sign-in. The command writes an API key to .env, generates typed code into .zodiac/,
and points the constellation at your workspace.
If you skipped it, connect later:
npm run pullThe API key in .env gives write access to your workspace drafts. Do not
commit it. Revoke keys in the Zodiac App under Profile → API keys.
CI and agent runtimes have no browser. Write an existing key to .env as
ZODIAC_API_KEY=… before the first npm run pull. The command then skips the
sign-in. npx 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. The template
already registers WETH:
import { defineConfig } from '@zodiaceco/sdk/cli/config'
export default defineConfig({
contracts: {
eth: {
weth: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
},
},
})When you add a contract, run npm run pull-contracts. The SDK fetches its ABI into
abis/ 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
Import constellation, allow, c, and the Permissions and Members types from
@zodiaceco/sdk.
Create constellation/roles/ops/permissions.ts:
import { allow, type Permissions } from '@zodiaceco/sdk'
export default [
allow.eth.weth.deposit({ send: true }), // wrap: deposit() with Ether
allow.eth.weth.withdraw(), // unwrap: withdraw(uint256), any amount
] satisfies PermissionsCreate constellation/roles/ops/members.ts with the operator address:
import type { Members } from '@zodiaceco/sdk'
export default ['0xYourOperatorAddress'] satisfies MembersCreate constellation/roles/ops/index.ts to combine the two files:
export { default as members } from './members'
export { default as permissions } from './permissions'In constellation/roles/index.ts, replace the template’s role with ops:
export * as ops from './ops'Delete the template’s constellation/roles/treasury_ops/.
Replace constellation/index.ts to wire the role to your Safe:
import { constellation } from '@zodiaceco/sdk'
import { ops } from './roles'
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, npm run push stops with the error: Node "Treasury" is referenced but not included in the push() call.
Step 5: review and apply
Check what you are about to push. This prints the constellation without sending it, and needs no API key:
npx zodiac push --dry-runThen push it:
npm run pushThe 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
execTransactionWithRoledirectly. 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
- The model you just used — nodes, the apply loop, the project layout: Constellations.
- Limit parameters and amounts: Conditions and Allowances.
- Use tested permission presets for Aave, CoW, Lido, and more: DeFi Kit presets.
- Add a veto window in front of the Safe: Quickstart: Delay.