Skip to Content
DevelopersRolesCustom conditions

Custom conditions

A custom condition is a Solidity contract that decides whether a call passes. The Roles Modifier calls it during the permission check. Write one when the built-in operators are not enough.

You need a custom condition when a check must:

  • Read chain state. Example: the token with this ID belongs to the Safe.
  • Compare the Ether value of the call with a value inside the calldata.
  • Decode a format that the condition tree cannot express, for example nested action-encodings of complex protocols.

Real example: the Uniswap V4 integration by DAMM Capital (UniswapV4-Zodiac-Roles ) uses nine small verifier contracts, one per Uniswap action. This page distills that pattern.

The interface

interface ICustomCondition { function check( address to, // target of the transaction under check uint256 value, // Ether value of the transaction bytes calldata data, // full calldata of the transaction uint8 operation, // 0 = call, 1 = delegate call uint256 location, // offset of this node's payload inside `data` uint256 size, // size of this node's payload bytes12 extra // 12 static config bytes from the condition ) external view returns (bool success, bytes32 reason); }

The adapter sees the whole transaction (to, value, data, operation) plus the slice of calldata that its condition node covers (location, size).

For a dynamic value (bytes, string), location points at the 32-byte length word, and size includes it. The content starts at location + 0x20. Adapters that skip this offset read garbage.

How the condition tree calls the adapter

Place a node with operator: Custom in the condition tree. Its compValue packs the adapter address and the extra bytes:

compValue = <20 bytes adapter address> ++ <12 bytes extra>

The 12 extra bytes are the only per-permission configuration. When 12 bytes are not enough:

  • Put fixed limits in immutable constructor values of the adapter, and deploy one adapter per limit set.
  • Compress with a hash. Example: store keccak256(token0)[0:6] ++ keccak256(token1)[0:6] to pin a token pair. State the collision risk in your threat model, because 6-byte tags are not globally unique.

The SDK does not author Custom nodes yet. Configure the function at the contract level with scopeFunction, and place the Custom node at the parameter that the adapter must check.

Read the avatar

The Roles Modifier is msg.sender during the check. This lets the adapter make checks relative to the Safe:

interface IModifier { function avatar() external view returns (address); } if (owner != IModifier(msg.sender).avatar()) { return (false, keccak256("INVALID_OWNER")); }

This is the pattern for “the position must belong to the Safe” and “the recipient must be the Safe”. An adapter can also read any other chain state, for example IERC721(to).ownerOf(tokenId).

Reason codes

Return (false, reason) to reject. The reason value surfaces in the ConditionViolation(Status.CustomConditionViolation, info) revert, so members can see why a call failed. Use stable tags:

bytes32 constant INVALID_FEE = keccak256("INVALID_FEE");

Hardening checklist

  1. Keep the adapter view and free of state. The contract requires it, and state breaks re-orgs and simulations.
  2. Check size before you decode. Remember that an empty dynamic field still adds 64 bytes (offset word plus length word) to a struct encoding.
  3. Decode inside try this.decode(...) (an external self-call), and catch decode reverts. Return (false, INVALID_ENCODING) instead of a revert.
  4. Reuse the decoder library of the target protocol, so your adapter parses the bytes in the same way as the target executes them.
  5. Remember what the adapter does not check. A calldata check does not limit the economic outcome. Document the invariants that must hold elsewhere, for example token approvals or slippage settings.

Test recipe

Test the adapter as the Roles Modifier calls it:

  1. Call check directly in unit tests. Use a mock that implements avatar() and prank the mock as msg.sender.
  2. Build the payload as Roles delivers it: the ABI encoding with the leading length word. Fuzz the length word — the adapter must never read it.
  3. Fuzz the struct fields, and assert exact reason codes for each rejection path.
  4. Add one integration test through a real Roles Modifier instance, so offsets and node placement are covered end to end.

Deploy

Adapters are stateless singletons. Deploy them once per chain with CREATE2 (for example through the ERC-2470 singleton factory), so they get the same address on all chains. Verify the source on the explorer. Audit them: a wrong adapter silently widens the role’s permissions.

A deployed reference adapter is AvatarIsOwnerOfERC721 (0x91B1bd7BCC5E623d5CE76b0152253499a9C819d1). It checks that the Safe owns the ERC-721 token that a parameter names.

Last updated on