Skip to Content

Shard Validators

Every Shard and RemoteShard instance holds a reference to exactly one IShardExecValidator contract. Before any validator-gated exec call is forwarded, the shard asks its validator: “Is this call allowed?” The validator is the sole on-chain policy enforcement point for what the shard can do.


The validator interface

interface IShardExecValidator { function isAllowed( address sender, address to, uint256 value, bytes calldata data, bytes calldata validatorArgs ) external view returns (bool); }
  • sender: msg.sender of the exec call (the logical caller the policy cares about).
  • to / value / data: the proposed external call.
  • validatorArgs: arbitrary bytes; encoding is defined by the specific validator. For MerkleAllowlistValidator this is an ABI-encoded CallVerificationInput (rule + Merkle proof).

The validator MUST be a pure read-only view. It indicates allowed/denied and never executes calls itself.

Plugging in and replacing validators

Each shard initialises with a validator at deploy time. DEFAULT_ADMIN_ROLE on the shard can replace it at any time:

shard.setValidator(IShardExecValidator newValidator); // DEFAULT_ADMIN_ROLE; newValidator != address(0)

Emits ValidatorUpdated(oldValidator, newValidator). Replacement takes effect immediately for all future exec calls. In-flight calls protected by nonReentrant are unaffected.


MerkleAllowlistValidator

MerkleAllowlistValidator is the standard validator implementation. It encodes the entire allowed call policy as a Merkle tree computed off-chain; only the root hash lives on-chain. Each exec call supplies one rule and a Merkle proof, and the validator checks both the proof and the call against the rule.

Architecture

Zero root = deny all

If _allowlistRoot == bytes32(0) (the default on deployment), isAllowed always returns false. No calls are permitted until a valid root is set.

Root management

The validator is owned via Ownable2Step. Only the current owner can update the root:

validator.setAllowlistRoot(bytes32 newRoot); // onlyOwner

Emits AllowlistRootUpdated(oldRoot, newRoot). Setting a zero root re-enables the deny-all state.

Domain separator

Every leaf in the tree is bound to a specific chain and validator contract address via a domain separator:

domainSeparator = keccak256(abi.encodePacked( bytes4(0x414c4c57), // "ALLW" magic uint8(1), // layout version block.chainid, address(this) ))

This means a proof built for validator A on chain 1 cannot be replayed against validator B or on chain 2. The tree cannot be finalised until the validator address is known: deploy the validator first, then call validator.domainSeparator() to build the tree.


Rule encoding reference

Rule header

Each encoded rule is a packed byte string structured as:

FieldTypeSize (bytes)Notes
versionuint81Must be 0x01
flagsuint81Bitmask; see table below
selectorbytes44Required when FLAG_SELECTOR set; else zero
senderaddress20Required when FLAG_SENDER set; else zero
toaddress20Required when FLAG_TO set; else zero
valueuint25632Required when FLAG_VALUE set; else zero
dataLengthuint324Required when FLAG_DATA_LENGTH set; else zero
constraintCountuint162Number of payload constraints that follow
(constraints)-66 × NN = constraintCount (max 64)

Total rule size: 84 + 66 × constraintCount bytes.

Canonicality requirement: if a flag bit is clear, its corresponding field in the encoding must be zero. The validator rejects non-canonical rules (even if the proof is valid).

Flags

BitConstantValueMeaning when set
0FLAG_SENDER0x01sender must match the rule’s sender field
1FLAG_TO0x02to must match the rule’s to field
2FLAG_VALUE0x04ETH value must match exactly
3FLAG_DATA_LENGTH0x08data.length must match exactly
4FLAG_SELECTOR0x10bytes4(data[0:4]) must match selector
5-7-0xE0Reserved; must be zero

Flags can be combined freely. A rule with flags = 0 is a wildcard for everything; it allows any call from any sender to any address.

Payload constraints

Each constraint (66 bytes) pins a 32-byte word in the call’s data[4:] (the ABI arguments, after the selector):

FieldTypeSize (bytes)
offsetuint162
maskbytes3232
expectedbytes3232

The check is:

(data[4 + offset : 4 + offset + 32] & mask) == expected

data is zero-padded if it ends before offset + 32. This means a constraint at an offset beyond data.length compares 0 & mask against expected.

Canonicality rules for constraints:

  • mask must be non-zero.
  • Bits set in expected outside mask are invalid (expected & ~mask != 0 is rejected).
  • Constraints must appear in strictly increasing offset order; duplicates are invalid.
  • Maximum 64 constraints per rule.

Offset reference:

OffsetABI position
0First argument word (data[4..36])
32Second argument word (data[36..68])
64Third argument word

Leaf format

Leaves must be built using OpenZeppelin StandardMerkleTree with value type ['uint8', 'bytes32', 'bytes']:

leaf value = [LEAF_TYPE, domainSeparator, ruleEncoded] = [0, domainSeparator, ruleEncoded]

The OZ library double-hashes: keccak256(bytes.concat(keccak256(abi.encode(0, domainSeparator, ruleEncoded)))).


Worked example: LBTC liquidity policy

Scenario: A Shard holds LBTC and needs to be able to:

  1. approve the DeFi vault to spend LBTC (no amount restriction).
  2. deposit(uint256 amount) into the vault with any amount.
  3. withdraw(uint256 amount) from the vault with any amount.

No other calls should be permitted.

Step 1: Deploy the validator and fetch the domain separator

import { ethers } from 'ethers'; import { StandardMerkleTree } from '@openzeppelin/merkle-tree'; import { packRuleSelector, packedSelectorToBytes4 } from '../../scripts/strategy/merkleUtils'; const MerkleAllowlistValidator = await ethers.getContractFactory('MerkleAllowlistValidator'); const validator = await MerkleAllowlistValidator.deploy(ownerAddress); await validator.waitForDeployment(); const domainSeparator = await validator.domainSeparator();

Step 2: Encode the rules

interface RuleConstraint { offset: number; mask: string; expected: string; } function encodeMerkleRule(p: { flags: number; selector?: string; sender: string; to: string; value: bigint; dataLength: number; constraints: RuleConstraint[]; }): string { const sel = packedSelectorToBytes4(packRuleSelector(p.selector)); const types = ['uint8', 'uint8', 'bytes4', 'address', 'address', 'uint256', 'uint32', 'uint16']; const values: (string | bigint | number)[] = [ 0x01, p.flags, sel, p.sender, p.to, p.value, p.dataLength >>> 0, p.constraints.length, ]; for (const c of p.constraints) { types.push('uint16', 'bytes32', 'bytes32'); values.push(c.offset, c.mask, c.expected); } return ethers.solidityPacked(types, values); } const LBTC_ADDRESS = '0xabc...'; // LBTC token contract const VAULT_ADDRESS = '0xdef...'; // DeFi vault contract const ZERO_ADDRESS = ethers.ZeroAddress; // Rule 1: approve(address spender, uint256 amount) on LBTC // - Exact `to` = LBTC, exact selector; spender pinned to vault; any amount const vaultWordPadded = ethers.AbiCoder.defaultAbiCoder().encode(['address'], [VAULT_ADDRESS]); const ALL_ONES = '0x' + 'ff'.repeat(32); // Ethereum addresses occupy the low 20 bytes of a 32-byte ABI word; mask the address portion. const ADDRESS_MASK = '0x' + '00'.repeat(12) + 'ff'.repeat(20); const ruleApprove = encodeMerkleRule({ flags: 0x02 | 0x10, // FLAG_TO + FLAG_SELECTOR selector: 'approve(address,uint256)', sender: ZERO_ADDRESS, // any sender to: LBTC_ADDRESS, value: 0n, dataLength: 0, // any data length constraints: [ // First arg (offset 0) = spender; must equal VAULT_ADDRESS { offset: 0, mask: ADDRESS_MASK, expected: vaultWordPadded }, ], }); // Rule 2: deposit(uint256 amount) on the vault, any amount, any sender const ruleDeposit = encodeMerkleRule({ flags: 0x02 | 0x10, // FLAG_TO + FLAG_SELECTOR selector: 'deposit(uint256)', sender: ZERO_ADDRESS, to: VAULT_ADDRESS, value: 0n, dataLength: 0, constraints: [], // no amount restriction }); // Rule 3: withdraw(uint256 amount) from the vault, any amount, any sender const ruleWithdraw = encodeMerkleRule({ flags: 0x02 | 0x10, // FLAG_TO + FLAG_SELECTOR selector: 'withdraw(uint256)', sender: ZERO_ADDRESS, to: VAULT_ADDRESS, value: 0n, dataLength: 0, constraints: [], });

Step 3: Build the Merkle tree and set the root

const leaves = [ruleApprove, ruleDeposit, ruleWithdraw].map(rule => [ 0, // LEAF_TYPE domainSeparator, ethers.getBytes(rule), ]); const tree = StandardMerkleTree.of(leaves, ['uint8', 'bytes32', 'bytes']); // Set root on-chain (owner of the validator) await validator.setAllowlistRoot(tree.root); console.log('Allowlist root set:', tree.root);

Step 4: Connect the shard to the validator

// If deploying a new Shard: const shard = await Shard.deploy(); await shard.initialize( strategyAddress, validator.target, adminAddress, shardTransfererAddress, ); // Or, if replacing the validator on an existing shard: await shard.setValidator(validator.target); // DEFAULT_ADMIN_ROLE

Step 5: Make an exec call

// Approve the vault to spend 1000 LBTC const approveCalldata = LBTC.interface.encodeFunctionData('approve', [VAULT_ADDRESS, ethers.parseUnits('1000', 8)]); // Find the rule in the tree and get its proof const ruleApproveBytes = ethers.getBytes(ruleApprove); const proof = tree.getProof([0, domainSeparator, ruleApproveBytes]); // ABI-encode validatorArgs as CallVerificationInput const validatorArgs = ethers.AbiCoder.defaultAbiCoder().encode( ['tuple(bytes ruleEncoded, bytes32[] merkleProof)'], [{ ruleEncoded: ruleApproveBytes, merkleProof: proof }], ); // Execute via the shard await shard.exec(LBTC_ADDRESS, 0n, approveCalldata, validatorArgs);

Step 6: Deposit into the vault

const depositCalldata = vault.interface.encodeFunctionData('deposit', [ethers.parseUnits('500', 8)]); const ruleDepositBytes = ethers.getBytes(ruleDeposit); const depositProof = tree.getProof([0, domainSeparator, ruleDepositBytes]); const depositValidatorArgs = ethers.AbiCoder.defaultAbiCoder().encode( ['tuple(bytes ruleEncoded, bytes32[] merkleProof)'], [{ ruleEncoded: ruleDepositBytes, merkleProof: depositProof }], ); await shard.exec(VAULT_ADDRESS, 0n, depositCalldata, depositValidatorArgs);

Step 7: Rotate the policy

When you need to add, remove, or change rules (e.g. add a new vault or remove a deprecated one), rebuild the tree off-chain with the new rule set and push a new root:

// Add a new rule for a second vault const ruleDepositVault2 = encodeMerkleRule({ flags: 0x02 | 0x10, selector: 'deposit(uint256)', sender: ZERO_ADDRESS, to: VAULT_ADDRESS_2, value: 0n, dataLength: 0, constraints: [], }); const newLeaves = [ruleApprove, ruleDeposit, ruleWithdraw, ruleDepositVault2].map(rule => [ 0, domainSeparator, ethers.getBytes(rule), ]); const newTree = StandardMerkleTree.of(newLeaves, ['uint8', 'bytes32', 'bytes']); // Atomic swap, old proofs become invalid as soon as this lands await validator.setAllowlistRoot(newTree.root);

Note: Root rotation is atomic but not atomic with exec calls in the same block. In-flight transactions using proofs from the old tree will fail if their transaction lands after the root update. Coordinate off-chain to avoid disruption.


Canonicality errors

The validator silently returns false (does not revert) for any of the following. From the shard’s perspective these appear as Shard_CallNotAllowed reverts:

ConditionReason
allowlistRoot == bytes32(0)Zero root; deny-all state
ruleEncoded.length < 84Header too short
version != 0x01Unsupported rule version
flags & 0xE0 != 0Reserved flag bits set
constraintCount > 64Exceeds maximum
Wrong ruleEncoded.lengthDoesn’t match 84 + 66 × constraintCount
Wildcard field non-zeroe.g. FLAG_SENDER clear but sender != address(0)
Constraint mask == 0Zero mask is invalid
expected & ~mask != 0Expected bits outside mask
Non-increasing constraint offsetsDuplicate or out-of-order offsets
Merkle proof failsRule not in the published tree

Security considerations

Zero root as a circuit-breaker. Setting the root to bytes32(0) immediately denies all validator-gated exec calls without replacing the validator contract. Useful for emergency freezes of shard external activity while preserving the PRIVILEGED_EXECUTOR_ROLE path for recovery.

Domain separator prevents replay. A proof generated for one validator or chain cannot be reused on another. Migrating a shard to a new validator deployment requires rebuilding the tree against the new domainSeparator.

Self-calls are not permitted. Both exec overloads revert with Shard_ExecToSelfNotAllowed if to == address(this). Shard administration (e.g. granting roles) must go through dedicated access-controlled functions, not through exec.

Wildcard rules carry risk. A rule with flags = 0x02 (exact to, all else wildcard) allows any selector and any arguments to one contract. Prefer pinning selectors (FLAG_SELECTOR) at minimum. Prefer pinning argument words for high-impact calls (e.g. pin the recipient on token transfers).

Privileged executor is an escape hatch. PRIVILEGED_EXECUTOR_ROLE bypasses the validator entirely. It should be granted only to accounts with strong operational security, and its use should be auditable via the Executed event.

Root rotation races. Replacing the root in one transaction invalidates all old proofs. Off-chain systems relying on pre-signed exec transactions should drain in-flight operations before rotating, or coordinate via a timelock.

Last updated on