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
- Keep the adapter
viewand free of state. The contract requires it, and state breaks re-orgs and simulations. - Check
sizebefore you decode. Remember that an empty dynamic field still adds 64 bytes (offset word plus length word) to a struct encoding. - Decode inside
try this.decode(...)(an external self-call), and catch decode reverts. Return(false, INVALID_ENCODING)instead of a revert. - Reuse the decoder library of the target protocol, so your adapter parses the bytes in the same way as the target executes them.
- 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:
- Call
checkdirectly in unit tests. Use a mock that implementsavatar()and prank the mock asmsg.sender. - 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.
- Fuzz the struct fields, and assert exact reason codes for each rejection path.
- 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.