IPermission & Context
Every Sail permission implements a single interface. It is small on purpose.
interface IPermission { /// Decide whether a manager-submitted transaction is permitted. function evaluate(bytes calldata txData, Context calldata ctx) external view returns (bool);
/// Optional stable identifier for off-chain indexing/deduplication. function discriminator() external view returns (bytes32);}evaluateisviewand is called by the kernel viastaticcallunderPERMISSION_GAS_CAP(150,000 gas). A revert or gas exhaustion is treated asfalse. It may read arbitrary on-chain state within the gas budget but can never mutate state.discriminatorreturns a stable identifier — by conventionkeccak256("<ContractName>")for fixed-shape permissions, orbytes32(0)for generic/multi-purpose ones. It is metadata for off-chain tooling; the kernel does not use it for authorization.
The Context struct
Section titled “The Context struct”The kernel passes a read-only snapshot of the dispatch environment, captured at the moment of evaluation. Reproduced verbatim from contracts/interfaces/IPermission.sol:
struct Context { address account; // the Safe account whose assets are being moved address manager; // the delegated signer who submitted the dispatch address submitter; // msg.sender of the dispatch; may differ from manager (relayer) address target; // the call target address bytes4 selector; // leading 4 bytes of calldata; bytes4(0) if calldata < 4 bytes uint256 value; // native ETH forwarded with the call (wei) uint256 blockTimestamp; // block.timestamp at dispatch — for time-based gates uint256 blockNumber; // block.number at dispatch}Field-by-field:
| Field | Use it to… |
|---|---|
account | scope per-account config (mapping(account => …)); confirm a swap’s recipient == account. |
manager | enforce identity-based rules (e.g. require a specific agent wallet). |
submitter | gate on the relayer/submitter if your threat model needs it. |
target | allowlist the contract being called. |
selector | route by function; reject unknown selectors. |
value | reject unexpected ETH on ERC-20 calls. |
blockTimestamp / blockNumber | time- or block-based windows and rate limits. |
The raw calldata of the call being dispatched arrives as txData (so txData[:4] == ctx.selector when length ≥ 4).
Implementation checklist
Section titled “Implementation checklist”From the integration guidance and the shipped templates:
- Check calldata length before decoding.
abi.decodeon short calldata reverts (treated asfalse); guard with explicit length checks first. - Return
falsefor unknown selectors — don’t revert; just deny. - Validate the target against an allowlist unless the permission is intentionally selector-only.
- Check
ctx.value— token calls should carry no ETH; a non-zerovalueis suspicious. - Return
falsefor malformed or out-of-bounds input — never assume well-formed calldata. - Stay well under 150k gas — leave margin for decoding, memory, and any on-chain reads. As a rough guide: staticcall overhead ~3k, a 2-arg decode ~1–2k, a cold
SLOAD2,100, an oracle call 5–20k+. - Implement
discriminator()—keccak256("YourPermissionName"), orbytes32(0)if generic.
A minimal permission:
contract AllowTargetPermission is IPermission { address public immutable allowedTarget; constructor(address t) { allowedTarget = t; }
function evaluate(bytes calldata, Context calldata ctx) external view returns (bool) { return ctx.target == allowedTarget; } function discriminator() external pure returns (bytes32) { return keccak256("AllowTargetPermission"); }}Walk through a real one in Write your first permission.