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.senderof theexeccall (the logical caller the policy cares about).to/value/data: the proposed external call.validatorArgs: arbitrary bytes; encoding is defined by the specific validator. ForMerkleAllowlistValidatorthis is an ABI-encodedCallVerificationInput(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); // onlyOwnerEmits 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:
| Field | Type | Size (bytes) | Notes |
|---|---|---|---|
version | uint8 | 1 | Must be 0x01 |
flags | uint8 | 1 | Bitmask; see table below |
selector | bytes4 | 4 | Required when FLAG_SELECTOR set; else zero |
sender | address | 20 | Required when FLAG_SENDER set; else zero |
to | address | 20 | Required when FLAG_TO set; else zero |
value | uint256 | 32 | Required when FLAG_VALUE set; else zero |
dataLength | uint32 | 4 | Required when FLAG_DATA_LENGTH set; else zero |
constraintCount | uint16 | 2 | Number of payload constraints that follow |
| (constraints) | - | 66 × N | N = 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
| Bit | Constant | Value | Meaning when set |
|---|---|---|---|
| 0 | FLAG_SENDER | 0x01 | sender must match the rule’s sender field |
| 1 | FLAG_TO | 0x02 | to must match the rule’s to field |
| 2 | FLAG_VALUE | 0x04 | ETH value must match exactly |
| 3 | FLAG_DATA_LENGTH | 0x08 | data.length must match exactly |
| 4 | FLAG_SELECTOR | 0x10 | bytes4(data[0:4]) must match selector |
| 5-7 | - | 0xE0 | Reserved; 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):
| Field | Type | Size (bytes) |
|---|---|---|
offset | uint16 | 2 |
mask | bytes32 | 32 |
expected | bytes32 | 32 |
The check is:
(data[4 + offset : 4 + offset + 32] & mask) == expecteddata 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:
maskmust be non-zero.- Bits set in
expectedoutsidemaskare invalid (expected & ~mask != 0is rejected). - Constraints must appear in strictly increasing offset order; duplicates are invalid.
- Maximum 64 constraints per rule.
Offset reference:
| Offset | ABI position |
|---|---|
| 0 | First argument word (data[4..36]) |
| 32 | Second argument word (data[36..68]) |
| 64 | Third 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:
approvethe DeFi vault to spend LBTC (no amount restriction).deposit(uint256 amount)into the vault with any amount.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_ROLEStep 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:
| Condition | Reason |
|---|---|
allowlistRoot == bytes32(0) | Zero root; deny-all state |
ruleEncoded.length < 84 | Header too short |
version != 0x01 | Unsupported rule version |
flags & 0xE0 != 0 | Reserved flag bits set |
constraintCount > 64 | Exceeds maximum |
Wrong ruleEncoded.length | Doesn’t match 84 + 66 × constraintCount |
| Wildcard field non-zero | e.g. FLAG_SENDER clear but sender != address(0) |
Constraint mask == 0 | Zero mask is invalid |
expected & ~mask != 0 | Expected bits outside mask |
| Non-increasing constraint offsets | Duplicate or out-of-order offsets |
| Merkle proof fails | Rule 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.