Callcium
Building Policies

Constraints

Define constraints on function arguments and transaction context.

A policy is a set of constraint groups. All constraints in a group must pass for the group to match; the data is valid against the policy if any group matches.

Targeting arguments

Constraints target values by path. arg(n) selects the zero-indexed parameter n of the function.
arg(n, field) descends into a struct, selecting field field of the value at position n. arg() takes up to four steps; arg(Path.encode(steps)) takes a longer path, up to MAX_PATH_DEPTH steps.

import { arg } from "callcium/Constraint.sol";

arg(0)     // first parameter
arg(1)     // second parameter
arg(0, 2)  // third field of the first parameter (struct)

Array quantifiers and deeper nested access are covered in Array Constraints.

Equality and comparison

eq and neq test exact equality. Both accept the static elementary types: uint*, int*, address, bool, bytes1 to bytes32, function. For boolean parameters, eq and neq are the only operators — there is no dedicated boolean operator.

import { arg } from "callcium/Constraint.sol";
import { PolicyBuilder } from "callcium/PolicyBuilder.sol";

// approve(address spender, uint256 amount)
bytes memory policy = PolicyBuilder
    .create("approve(address,uint256)")
    .add(arg(0).eq(TREASURY))              // spender
    .build();

Range operators — gt, lt, gte, lte, and between — impose ordering constraints. Type restriction: Valid for uint* and int* parameters only. Applying a range operator to a non-numeric parameter is rejected by build().

lte caps a value at an upper bound. between defines a closed inclusive range [min, max]:

// approve(address spender, uint256 amount)
bytes memory policy = PolicyBuilder
    .create("approve(address,uint256)")
    .add(arg(0).eq(TREASURY))                                    // spender
    .add(arg(1).between(uint256(1e18), uint256(1_000e18)))       // amount
    .build();

Set membership

isIn requires the value to match one element in a set. notIn requires it to match none.

Type restriction: Accepts address[], uint256[], int256[], and bytes32[]. The builder automatically sorts and deduplicates the set. The set must have at least one element, and its operands must fit the rule's data length field (PWF-20), so a narrower operand type admits a larger set.

Allowlists (isIn) are the safer default over denylists (notIn) — an allowlist is bounded and exhaustive; a denylist can be bypassed by values not yet anticipated.

import { arg } from "callcium/Constraint.sol";
import { PolicyBuilder } from "callcium/PolicyBuilder.sol";

address[] memory spenders = new address[](2);
spenders[0] = TREASURY;
spenders[1] = MULTISIG;

// approve(address spender, uint256 amount)
bytes memory policy = PolicyBuilder
    .create("approve(address,uint256)")
    .add(arg(0).isIn(spenders))            // spender
    .add(arg(1).lte(uint256(1_000e18)))    // amount
    .build();

Narrower types

For a narrower parameter type — bytes4, uint16, int32, and similar — convert each member to its 32-byte type: bytes32(value) keeps a fixed-bytes value left-aligned, uint256(value) and int256(value) widen an integer. The builder stores each member at the parameter's width, and add() rejects a member the type cannot hold.

import { arg } from "callcium/Constraint.sol";
import { PolicyBuilder } from "callcium/PolicyBuilder.sol";

bytes32[] memory allowedSelectors = new bytes32[](2);
allowedSelectors[0] = bytes32(IERC20.transfer.selector);
allowedSelectors[1] = bytes32(IERC20.approve.selector);

// dispatch(bytes4 selector, bytes calldata payload)
bytes memory policy = PolicyBuilder
    .create("dispatch(bytes4,bytes)")
    .add(arg(0).isIn(allowedSelectors))  // selector
    .build();

Bitmask operators

bitmaskAll, bitmaskAny, and bitmaskNone check specific bits in a value.

Type restriction: Valid for uint*, int*, address, and bytes1 to bytes32 parameters.

OperatorSemantics
bitmaskAll(mask)(value & mask) == mask — all bits in mask are set
bitmaskAny(mask)(value & mask) != 0 — at least one bit in mask is set
bitmaskNone(mask)(value & mask) == 0 — no bit in mask is set

A common pattern: require that a roles parameter has all required bits set.

import { arg } from "callcium/Constraint.sol";
import { PolicyBuilder } from "callcium/PolicyBuilder.sol";

uint256 constant ROLE_OPERATOR = 1 << 0;
uint256 constant ROLE_GUARDIAN = 1 << 1;

// execute(address target, uint256 roles, bytes calldata data)
bytes memory policy = PolicyBuilder
    .create("execute(address,uint256,bytes)")
    .add(arg(1).bitmaskAll(ROLE_OPERATOR | ROLE_GUARDIAN))  // roles
    .build();

Length constraints

lengthEq, lengthGt, lengthLt, lengthGte, lengthLte, and lengthBetween constrain the length of a dynamic value: the bytes of a bytes or string value, the elements of a dynamic array.

Type restriction: Valid for bytes, string, and dynamic array parameters only. Not valid for static arrays.

import { arg } from "callcium/Constraint.sol";
import { PolicyBuilder } from "callcium/PolicyBuilder.sol";

// execute(address target, bytes calldata data)
bytes memory policy = PolicyBuilder
    .create("execute(address,bytes)")
    .add(arg(1).lengthBetween(4, 256))  // data
    .build();

lengthBetween(4, 256) ensures the payload is at least 4 bytes and imposes a hard upper bound on untrusted input size.

Transaction context

The following context properties are available as constraint targets:

TargetSolidity equivalentType
msgSender()msg.senderaddress
msgValue()msg.valueuint256
blockTimestamp()block.timestampuint256
blockNumber()block.numberuint256
chainId()block.chainiduint256
txOrigin()tx.originaddress
baseFee()block.basefeeuint256
gasPrice()tx.gaspriceuint256

When enforced via a static call, msgSender() reflects the from address of the call — set it explicitly to simulate the intended caller. msgValue() always resolves to 0; static calls cannot forward value.

msgSender() resolves in the frame that runs enforcement. A router or aggregator that reaches the enforcing contract with call is the sender; delegatecall preserves the original caller. A msgSender() constraint describes the immediate caller, not the transaction originator — txOrigin() targets the latter.

baseFee() and gasPrice() are constraints on network state: the same data is valid or invalid against the policy as gas prices move. tx.gasprice is additionally chosen by the transaction sender, bounded below by the base fee.

Context targets are imported alongside arg:

import { arg, msgSender, msgValue, blockTimestamp, chainId, txOrigin } from "callcium/Constraint.sol";

A msgSender constraint does not impose any requirement on argument values — pair it with argument constraints for complete coverage.

import { arg, msgSender } from "callcium/Constraint.sol";
import { PolicyBuilder } from "callcium/PolicyBuilder.sol";

address[] memory operators = new address[](2);
operators[0] = OPERATOR_A;
operators[1] = OPERATOR_B;

address[] memory spenders = new address[](2);
spenders[0] = TREASURY;
spenders[1] = MULTISIG;

// approve(address spender, uint256 amount)
bytes memory policy = PolicyBuilder
    .create("approve(address,uint256)")
    .add(msgSender().isIn(operators))      // caller
    .add(arg(0).isIn(spenders))            // spender
    .add(arg(1).lte(uint256(1_000e18)))    // amount
    .build();

Context references

eqCtx and neqCtx compare a value against a context property instead of a literal. The operand names the property by ID; the property's value resolves at enforcement time.

import { arg } from "callcium/Constraint.sol";
import { PolicyBuilder } from "callcium/PolicyBuilder.sol";
import { PolicyFormat } from "callcium/PolicyFormat.sol";

// transfer(address recipient, uint256 amount)
bytes memory policy = PolicyBuilder
    .create("transfer(address,uint256)")
    .add(arg(0).eqCtx(PolicyFormat.CTX_MSG_SENDER)) // recipient == caller
    .build();

Address properties (CTX_MSG_SENDER, CTX_TX_ORIGIN) pair with address arguments; uint256 properties pair with unsigned integer arguments. Context targets accept the operator too: msgSender().eqCtx(PolicyFormat.CTX_TX_ORIGIN) constrains two properties against each other.

gtCtx, ltCtx, gteCtx, and lteCtx order an unsigned integer argument against a uint256 property: arg(1).lteCtx(PolicyFormat.CTX_MSG_VALUE) caps amount at the attached value.

Array quantifiers

Path.ALL and Path.ANY apply a constraint across the elements of an array parameter:

import { arg } from "callcium/Constraint.sol";
import { Path } from "callcium/Path.sol";
import { PolicyBuilder } from "callcium/PolicyBuilder.sol";

// batchTransfer(address[] recipients, uint256[] amounts)
bytes memory policy = PolicyBuilder
    .create("batchTransfer(address[],uint256[])")
    .add(arg(0, Path.ALL).isIn(allowlist))           // recipients — all must be approved
    .add(arg(1, Path.ALL).lte(uint256(1_000e18)))    // amounts — all within limit
    .build();

Constraints with the same quantifier over the same array form one loop: under ANY, one element satisfies all of them. Full quantifier semantics, empty-array behavior, and nested path patterns are in Array Constraints.

OR groups

.or() starts a new constraint group. The data is valid against the policy when any group matches.

import { arg, msgSender } from "callcium/Constraint.sol";
import { PolicyBuilder } from "callcium/PolicyBuilder.sol";

address[] memory spenders = new address[](2);
spenders[0] = TREASURY;
spenders[1] = MULTISIG;

// approve(address spender, uint256 amount)
bytes memory policy = PolicyBuilder
    .create("approve(address,uint256)")
    // Standard operation.
    .add(arg(0).isIn(spenders))             // spender
    .add(arg(1).lte(uint256(1_000e18)))     // amount
    .or()
    // Admin bypass.
    .add(msgSender().eq(ADMIN))             // caller
    .build();

The data is valid where either (spender is in the set and amount is within the cap) or (caller is the admin).

.or() requires the current group to have at least one constraint.

Building safely

build() is the default entry point. It validates the policy's internal logic — type compatibility and structural consistency — and reverts if any issue is found. It does not check whether any specific data satisfies the policy; that is the enforcer's role.

For issue types, severity levels, and how to inspect issues without reverting, see Policy Validation.

Unsafe builds

No validation

buildUnsafe() skips all validation. Type mismatches and logical contradictions are not detected at build time — they surface at enforcement, where the policy may match data it should reject, or fail on valid data. Use only when validation has been confirmed separately or in controlled test environments.

buildUnsafe() encodes policy bytes directly without a validation pass. Its primary uses are benchmarking, testing enforcement behavior with edge-case inputs, and programmatic pipelines that validate separately.

// approve(address spender, uint256 amount)
bytes memory policy = PolicyBuilder
    .create("approve(address,uint256)")
    .add(arg(0).isIn(spenders))
    .add(arg(1).lte(uint256(1_000e18)))
    .buildUnsafe(); // validation skipped

Operator compatibility

Operator familyOperatorsValid argument types
Equalityeq, neqaddress, bool, uint*, int*, bytes1 to bytes32, function
Context referenceeqCtx, neqCtx, gtCtx, ltCtx, gteCtx, lteCtxaddress for address properties (eqCtx, neqCtx); uint* for uint256 properties
Comparisongt, lt, gte, lte, betweenuint*, int*
Set membershipisIn, notInaddress, uint*, int*, bytes1 to bytes32, function
BitmaskbitmaskAll, bitmaskAny, bitmaskNoneaddress, uint*, int*, bytes1 to bytes32
LengthlengthEq, lengthGt, lengthLt, lengthGte, lengthLte, lengthBetweenbytes, string, dynamic arrays

build() enforces these restrictions and reports violations as Issue entries in ValidationError.

On this page