@callcium/sdk 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/descriptor.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readU16, readU24, writeBE16, writeBE32 } from "./bytes";
2
2
  import { DescriptorFormat as DF, PolicyFormat as PF, TypeCode } from "./constants";
3
3
  import { CallciumError } from "./errors";
4
+ import { classifyTypeCode } from "./operators";
4
5
  import { Quantifier, isQuantifier } from "./path";
5
6
 
6
7
  ///////////////////////////////////////////////////////////////////////////
@@ -41,15 +42,51 @@ type HintWalk = {
41
42
  // Internal helpers
42
43
  ///////////////////////////////////////////////////////////////////////////
43
44
 
45
+ /** A descriptor node's type code, static word count, and total byte length. */
46
+ type Node = { typeCode: number; staticWords: number; nodeLength: number };
47
+
48
+ /**
49
+ * Read the node at `offset`.
50
+ * Elementary nodes occupy exactly one byte; composite nodes encode their static
51
+ * word count and length in the 24-bit meta field that follows the type code.
52
+ * @throws {CallciumError} When the node starts past the descriptor, carries an undefined type code,
53
+ * or declares a length below its own header or beyond the descriptor.
54
+ */
55
+ export function readNode(desc: Uint8Array, offset: number): Node {
56
+ if (offset >= desc.length) {
57
+ throw new CallciumError("UNEXPECTED_END", "Unexpected end of descriptor", offset);
58
+ }
59
+ const typeCode = desc[offset]!;
60
+ const info = classifyTypeCode(typeCode);
61
+ if (info.typeClass === "elementary") {
62
+ return { typeCode, staticWords: info.isDynamic ? 0 : 1, nodeLength: DF.TYPECODE_SIZE };
63
+ }
64
+
65
+ if (offset + DF.TYPECODE_SIZE + DF.COMPOSITE_META_SIZE > desc.length) {
66
+ throw new CallciumError("UNEXPECTED_END", "Incomplete composite metadata", offset);
67
+ }
68
+ const meta = readU24(desc, offset + DF.TYPECODE_SIZE);
69
+ const length = meta & DF.META_NODE_LENGTH_MASK;
70
+ const minHeader = info.typeClass === "tuple" ? DF.TUPLE_HEADER_SIZE : DF.ARRAY_HEADER_SIZE;
71
+ if (length < minHeader) {
72
+ throw new CallciumError(
73
+ "NODE_LENGTH_TOO_SMALL",
74
+ `Composite node length ${length} is smaller than minimum header ${minHeader}`,
75
+ offset,
76
+ );
77
+ }
78
+ if (offset + length > desc.length) {
79
+ throw new CallciumError("NODE_OVERFLOW", "Composite node extends beyond descriptor", offset);
80
+ }
81
+ return { typeCode, staticWords: meta >> DF.META_STATIC_WORDS_SHIFT, nodeLength: length };
82
+ }
83
+
44
84
  /**
45
85
  * Return the byte length of the node at `offset`.
46
- * Elementary nodes occupy exactly one byte; composite nodes encode their
47
- * length in the lower 12 bits of the 24-bit meta field.
86
+ * @throws {CallciumError} When the descriptor holds no readable node at `offset`.
48
87
  */
49
88
  function nodeLength(desc: Uint8Array, offset: number): number {
50
- const typeCode = desc[offset]!;
51
- if (typeCode < TypeCode.STATIC_ARRAY) return DF.TYPECODE_SIZE;
52
- return readU24(desc, offset + 1) & DF.META_NODE_LENGTH_MASK;
89
+ return readNode(desc, offset).nodeLength;
53
90
  }
54
91
 
55
92
  /**
@@ -68,14 +105,20 @@ function tupleFieldOffset(desc: Uint8Array, tupleOffset: number, fieldIndex: num
68
105
  // Public interface
69
106
  ///////////////////////////////////////////////////////////////////////////
70
107
 
71
- /** Return paramCount from header byte 1. */
108
+ /**
109
+ * Return the declared top-level parameter count.
110
+ * @throws {CallciumError} When the descriptor is too short to hold a header.
111
+ */
72
112
  function paramCount(desc: Uint8Array): number {
113
+ if (desc.length < DF.HEADER_SIZE) {
114
+ throw new CallciumError("MALFORMED_HEADER", "Descriptor too short for header");
115
+ }
73
116
  return desc[1]!;
74
117
  }
75
118
 
76
119
  /** Return byte offset of the N-th top-level param (0-indexed). */
77
120
  function paramOffset(desc: Uint8Array, index: number): number {
78
- const count = desc[1]!;
121
+ const count = paramCount(desc);
79
122
  if (index >= count) {
80
123
  throw new CallciumError("PARAM_INDEX_OUT_OF_BOUNDS", `Param index ${index} out of range (paramCount=${count})`);
81
124
  }
@@ -91,17 +134,11 @@ function paramOffset(desc: Uint8Array, index: number): number {
91
134
  * @param desc - Raw descriptor bytes.
92
135
  * @param offset - Byte position of the node.
93
136
  * @returns typeCode, isDynamic, and staticSize in bytes.
137
+ * @throws {CallciumError} When the descriptor holds no readable node at `offset`.
94
138
  */
95
139
  function inspect(desc: Uint8Array, offset: number): TypeInfo {
96
- const typeCode = desc[offset]!;
97
- let staticWords: number;
98
- if (typeCode < TypeCode.STATIC_ARRAY) {
99
- staticWords = typeCode === TypeCode.BYTES || typeCode === TypeCode.STRING ? 0 : 1;
100
- } else {
101
- staticWords = readU24(desc, offset + 1) >> DF.META_STATIC_WORDS_SHIFT;
102
- }
103
- const isDynamic = staticWords === 0;
104
- return { typeCode, isDynamic, staticSize: staticWords * 32 };
140
+ const { typeCode, staticWords } = readNode(desc, offset);
141
+ return { typeCode, isDynamic: staticWords === 0, staticSize: staticWords * 32 };
105
142
  }
106
143
 
107
144
  /**
@@ -134,16 +171,7 @@ function walkPath(desc: Uint8Array, steps: number[]): { typeInfo: TypeInfo; quan
134
171
  throw new CallciumError("EMPTY_PATH", "Path must have at least one step");
135
172
  }
136
173
 
137
- const paramIndex = steps[0]!;
138
- const count = desc[1]!;
139
- if (paramIndex >= count) {
140
- throw new CallciumError(
141
- "PARAM_INDEX_OUT_OF_BOUNDS",
142
- `Param index ${paramIndex} out of range (paramCount=${count})`,
143
- );
144
- }
145
-
146
- let cursor = paramOffset(desc, paramIndex);
174
+ let cursor = paramOffset(desc, steps[0]!);
147
175
  let quantifiedStaticLength = 0;
148
176
 
149
177
  for (let stepIndex = 1; stepIndex < steps.length; stepIndex++) {
package/src/errors.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Machine-readable error code for structural decoding and validation failures.
3
3
  *
4
- * Each code names one invariant and matches the Solidity error raised for the same
5
- * invariant, transliterated from `PascalCase` to `SCREAMING_SNAKE_CASE`.
4
+ * Each code names one invariant. Where Solidity detects the same invariant, the code is its error
5
+ * name transliterated from `PascalCase` to `SCREAMING_SNAKE_CASE`. The codes under "Inputs and
6
+ * validation" have no counterpart: they name input a Solidity signature cannot express.
7
+ * `OUT_OF_PHYSICAL_BOUNDS` names an invariant Solidity carries twice, as the error
8
+ * `PolicyBuilder.OutOfPhysicalBounds` and as an issue code, so the name is shared with `IssueCode`.
6
9
  */
7
10
  export type CallciumErrorCode =
8
11
  // Policy and descriptor framing.
@@ -47,6 +50,7 @@ export type CallciumErrorCode =
47
50
  | "EMPTY_SET"
48
51
  | "SET_TOO_LARGE"
49
52
  | "INVALID_RANGE"
53
+ | "OUT_OF_PHYSICAL_BOUNDS"
50
54
  // Descriptor nodes.
51
55
  | "UNKNOWN_TYPE_CODE"
52
56
  | "NODE_LENGTH_TOO_SMALL"
@@ -72,6 +76,10 @@ export type CallciumErrorCode =
72
76
  // Inputs and validation.
73
77
  | "UNKNOWN_QUANTIFIER"
74
78
  | "INVALID_HEX"
79
+ | "MALFORMED_SELECTOR"
80
+ | "PATH_STEP_OVERFLOW"
81
+ | "OPERAND_OVERFLOW"
82
+ | "CONTEXT_VALUE_OVERFLOW"
75
83
  | "VALIDATION_ERROR";
76
84
 
77
85
  /**
@@ -88,6 +96,8 @@ export class CallciumError extends Error {
88
96
  super(`${prefix}${message}`);
89
97
  this.name = "CallciumError";
90
98
  this.code = code;
99
+ // The property is declared optional, so it stays absent rather than present and undefined.
100
+ if (offset !== undefined) this.offset = offset;
91
101
  }
92
102
  }
93
103
 
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ export {
46
46
  Scope,
47
47
  ContextProperty,
48
48
  MAX_CONTEXT_PROPERTY_ID,
49
+ findContextProperty,
49
50
  lookupOp,
50
51
  lookupScope,
51
52
  lookupContextProperty,
package/src/operators.ts CHANGED
@@ -1,6 +1,9 @@
1
- import { Op, TypeCode, operandsOf } from "./constants";
1
+ import { hexToBytes } from "./bytes";
2
+ import { DescriptorFormat as DF, formatCode, Op, PolicyFormat as PF, TypeCode, operandsOf } from "./constants";
2
3
  import { CallciumError } from "./errors";
3
4
 
5
+ import type { Hex } from "./types";
6
+
4
7
  /** Read a big-endian 256-bit unsigned integer from a 32-byte window. */
5
8
  export function toBigInt(bytes: Uint8Array, offset = 0): bigint {
6
9
  const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 32);
@@ -128,7 +131,7 @@ function isIn(value: bigint, data: Uint8Array): boolean {
128
131
  export function applyOperator(
129
132
  opCode: number,
130
133
  value: bigint,
131
- valueLength: number,
134
+ valueLength: bigint,
132
135
  operandData: Uint8Array,
133
136
  typeCode: number,
134
137
  ): boolean {
@@ -138,7 +141,7 @@ export function applyOperator(
138
141
  // value and the opcode maps onto its EQ/GT/LT/GTE/LTE/BETWEEN twin. Counts are non-negative,
139
142
  // so the comparison is always unsigned — force an unsigned type code regardless of the target.
140
143
  if (isLengthOp(base)) {
141
- value = BigInt(valueLength);
144
+ value = valueLength;
142
145
  typeCode = TypeCode.UINT_MAX;
143
146
  base = lengthToValueOp(base);
144
147
  }
@@ -196,7 +199,7 @@ export function applyOperator(
196
199
  }
197
200
 
198
201
  default:
199
- throw new CallciumError("UNKNOWN_OPERATOR", `Unknown operator code 0x${base.toString(16).padStart(2, "0")}`);
202
+ throw new CallciumError("UNKNOWN_OPERATOR", `Unknown operator code ${formatCode(base, PF.RULE_OPCODE_SIZE)}`);
200
203
  }
201
204
 
202
205
  const negate = (opCode & Op.NOT) !== 0;
@@ -217,6 +220,20 @@ export function isValidOperatorData(opBase: number, dataLength: number): boolean
217
220
  return false;
218
221
  }
219
222
 
223
+ /**
224
+ * Read an operator's encoding as bytes: one opcode byte followed by its data payload.
225
+ * @param operator - 0x-prefixed hex string holding a single operator.
226
+ * @returns The operator's bytes, opcode first.
227
+ * @throws {CallciumError} If the hex is malformed or carries no opcode byte.
228
+ */
229
+ export function toOperatorBytes(operator: Hex): Uint8Array {
230
+ const bytes = hexToBytes(operator);
231
+ if (bytes.length < 1) {
232
+ throw new CallciumError("INVALID_OPERATOR_BYTES", "Operator must have at least one byte (opcode)");
233
+ }
234
+ return bytes;
235
+ }
236
+
220
237
  ///////////////////////////////////////////////////////////////////////////
221
238
  // Type code classification
222
239
  ///////////////////////////////////////////////////////////////////////////
@@ -232,7 +249,7 @@ export type TypeCodeInfo = TypeClassInfo & { label: string };
232
249
 
233
250
  /** Throw an UNKNOWN_TYPE_CODE error. */
234
251
  function unknownTypeCode(code: number): never {
235
- throw new CallciumError("UNKNOWN_TYPE_CODE", `Unknown type code 0x${code.toString(16).padStart(2, "0")}`);
252
+ throw new CallciumError("UNKNOWN_TYPE_CODE", `Unknown type code ${formatCode(code, DF.TYPECODE_SIZE)}`);
236
253
  }
237
254
 
238
255
  // Pre-allocated constant objects for fixed type codes (avoids per-call allocation).
@@ -264,6 +281,20 @@ export function classifyTypeCode(code: number): TypeClassInfo {
264
281
  unknownTypeCode(code);
265
282
  }
266
283
 
284
+ /**
285
+ * Determine whether a type code names an elementary type.
286
+ * @param code - A single-byte descriptor type code.
287
+ * @returns True for an elementary code; false for a composite or unassigned one.
288
+ */
289
+ export function isElementary(code: number): boolean {
290
+ try {
291
+ // An undefined code is reported by a throw, and names no elementary type either way.
292
+ return classifyTypeCode(code).typeClass === "elementary";
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+
267
298
  /**
268
299
  * Determine whether a type code names a value an operator can read: a scalar word or a declared
269
300
  * length. Tuples, static arrays, and undefined codes address nothing.
package/src/path.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { bytesToHex, hexToBytes, readU16, writeBE16 } from "./bytes";
2
- import { buildCodeMap, PolicyFormat as PF } from "./constants";
2
+ import { buildCodeMap, formatCode, PolicyFormat as PF } from "./constants";
3
3
  import { CallciumError } from "./errors";
4
4
 
5
5
  import type { Hex } from "./types";
@@ -20,7 +20,7 @@ export const Quantifier = buildCodeMap(QUANTIFIER_TABLE);
20
20
  export type QuantifierInfo = { label: string };
21
21
 
22
22
  const quantifierByCode: ReadonlyMap<number, QuantifierInfo> = new Map<number, QuantifierInfo>(
23
- QUANTIFIER_TABLE.map((e) => [e.code, { label: e.label }]),
23
+ QUANTIFIER_TABLE.map((entry) => [entry.code, { label: entry.label }]),
24
24
  );
25
25
 
26
26
  /** Check whether a path step is a quantifier (ALL or ANY). */
@@ -36,7 +36,8 @@ export function isQuantifier(step: number): boolean {
36
36
  */
37
37
  export function lookupQuantifier(code: number): QuantifierInfo {
38
38
  const info = quantifierByCode.get(code);
39
- if (!info) throw new CallciumError("UNKNOWN_QUANTIFIER", `Unknown quantifier step 0x${code.toString(16)}`);
39
+ if (!info)
40
+ throw new CallciumError("UNKNOWN_QUANTIFIER", `Unknown quantifier step ${formatCode(code, PF.PATH_STEP_SIZE)}`);
40
41
  return info;
41
42
  }
42
43
 
@@ -44,11 +45,19 @@ export function lookupQuantifier(code: number): QuantifierInfo {
44
45
  // Path steps
45
46
  ///////////////////////////////////////////////////////////////////////////
46
47
 
47
- /** Encode a sequence of uint16 path steps as a big-endian hex string. */
48
+ /**
49
+ * Encode a sequence of uint16 path steps as a big-endian hex string.
50
+ * @throws {CallciumError} When a step is not an integer the field holds. A wrapped step would
51
+ * address a different argument.
52
+ */
48
53
  export function encodePath(steps: readonly number[]): Hex {
49
- const buffer = new Uint8Array(steps.length * 2);
54
+ const buffer = new Uint8Array(steps.length * PF.PATH_STEP_SIZE);
50
55
  for (let i = 0; i < steps.length; i++) {
51
- writeBE16(buffer, i * 2, steps[i]!);
56
+ const step = steps[i]!;
57
+ if (!Number.isInteger(step) || step < 0 || step > PF.PATH_STEP_MAX) {
58
+ throw new CallciumError("PATH_STEP_OVERFLOW", `Path step ${step} is outside the path step field`);
59
+ }
60
+ writeBE16(buffer, i * PF.PATH_STEP_SIZE, step);
52
61
  }
53
62
  return bytesToHex(buffer);
54
63
  }
@@ -1,9 +1,10 @@
1
1
  import { bytesToHex } from "./bytes";
2
- import { Scope, TypeCode, MAX_CONTEXT_PROPERTY_ID } from "./constants";
3
- import { ConstraintBuilder } from "./constraint";
2
+ import { Scope, TypeCode, lookupContextProperty } from "./constants";
3
+ import { readOperandExtremes } from "./constraint";
4
4
  import { Descriptor } from "./descriptor";
5
5
  import { DescriptorCoder } from "./descriptor-coder";
6
6
  import { CallciumError, ValidationError } from "./errors";
7
+ import { isSigned } from "./operators";
7
8
  import { isQuantifier, parsePathSteps } from "./path";
8
9
  import { PolicyCoder } from "./policy-coder";
9
10
  import { PolicyValidator } from "./policy-validator";
@@ -20,29 +21,24 @@ type PolicyDraft = {
20
21
  selector: Hex;
21
22
  descriptor: Uint8Array;
22
23
  groups: Constraint[][];
23
- pathHashes: Set<string>[];
24
+ /** Scope-qualified paths already taken in the group being built. */
25
+ groupPathKeys: Set<string>;
24
26
  };
25
27
 
26
28
  ///////////////////////////////////////////////////////////////////////////
27
29
  // Path validation
28
30
  ///////////////////////////////////////////////////////////////////////////
29
31
 
30
- /** Validate a context-scope path. */
31
- function validateContextPath(steps: number[]): void {
32
+ /** Validate a context-scope path and return the referenced property's type code. */
33
+ function validateContextPath(steps: number[]): number {
32
34
  if (steps.length !== 1) {
33
35
  throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope path must be exactly one step");
34
36
  }
35
- const step = steps[0]!;
36
- if (step > MAX_CONTEXT_PROPERTY_ID) {
37
- throw new CallciumError(
38
- "UNKNOWN_CONTEXT_PROPERTY",
39
- `Unknown context property ID 0x${step.toString(16).padStart(4, "0")}`,
40
- );
41
- }
37
+ return lookupContextProperty(steps[0]!).typeCode;
42
38
  }
43
39
 
44
- /** Validate a calldata-scope path against the descriptor. */
45
- function validateCalldataPath(steps: number[], desc: Uint8Array): void {
40
+ /** Validate a calldata-scope path against the descriptor and return the target's type code. */
41
+ function validateCalldataPath(steps: number[], desc: Uint8Array): number {
46
42
  const argIndex = steps[0]!;
47
43
  const paramCount = Descriptor.paramCount(desc);
48
44
  if (argIndex >= paramCount) {
@@ -94,6 +90,63 @@ function validateCalldataPath(steps: number[], desc: Uint8Array): void {
94
90
  throw new CallciumError("NOT_COMPOSITE", "Cannot descend into an elementary type");
95
91
  }
96
92
  }
93
+
94
+ return Descriptor.inspect(desc, offset).typeCode;
95
+ }
96
+
97
+ ///////////////////////////////////////////////////////////////////////////
98
+ // Operand domain
99
+ ///////////////////////////////////////////////////////////////////////////
100
+
101
+ // Values a 32-byte word represents, which is the distance between an operand and its alias.
102
+ const WORD_VALUES = 1n << 256n;
103
+
104
+ /** Integers an integer target admits, or null for any other type. */
105
+ function targetBounds(typeCode: number): { min: bigint; max: bigint } | null {
106
+ if (typeCode >= TypeCode.UINT_MIN && typeCode <= TypeCode.UINT_MAX) {
107
+ const bits = BigInt(typeCode - TypeCode.UINT_MIN + 1) * 8n;
108
+ return { min: 0n, max: (1n << bits) - 1n };
109
+ }
110
+ if (typeCode >= TypeCode.INT_MIN && typeCode <= TypeCode.INT_MAX) {
111
+ const bits = BigInt(typeCode - TypeCode.INT_MIN + 1) * 8n;
112
+ const half = 1n << (bits - 1n);
113
+ return { min: -half, max: half - 1n };
114
+ }
115
+ return null;
116
+ }
117
+
118
+ /** Reject an operand outside the target's range whose word carries a value inside it. */
119
+ function reject(written: bigint, folded: bigint): never {
120
+ throw new CallciumError(
121
+ "OUT_OF_PHYSICAL_BOUNDS",
122
+ `Operand ${written} is outside the physical range of the type and encodes as ${folded}`,
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Reject an operand that two's complement folds onto a value the target admits.
128
+ * Such an operand and the value it folds onto encode to one word, so the operands as written are
129
+ * the only place the two stay distinct. An operand spans no more than one word, which leaves two
130
+ * ways across: a negative's alias clears every target narrower than the whole word, and a positive
131
+ * reaches a signed range only at that type's own minimum word.
132
+ */
133
+ function checkOperandDomain(constraint: Constraint, typeCode: number): void {
134
+ const bounds = targetBounds(typeCode);
135
+ const extremes = readOperandExtremes(constraint);
136
+ if (bounds === null || extremes === undefined) return;
137
+
138
+ // A negative operand occupies the word its unsigned alias does, which only the target holding
139
+ // the whole word can hold too.
140
+ if (extremes.leastNegative < 0n && typeCode === TypeCode.UINT_MAX) {
141
+ reject(extremes.leastNegative, extremes.leastNegative + WORD_VALUES);
142
+ }
143
+
144
+ if (!isSigned(typeCode)) return;
145
+ // An operand at or above the signed minimum's own word denotes that negative, and every such
146
+ // operand already exceeds the signed maximum.
147
+ if (extremes.greatest >= WORD_VALUES + bounds.min) {
148
+ reject(extremes.greatest, extremes.greatest - WORD_VALUES);
149
+ }
97
150
  }
98
151
 
99
152
  ///////////////////////////////////////////////////////////////////////////
@@ -120,7 +173,7 @@ export class PolicyBuilder {
120
173
  selector: parsed.selector,
121
174
  descriptor,
122
175
  groups: [[]],
123
- pathHashes: [new Set()],
176
+ groupPathKeys: new Set(),
124
177
  });
125
178
  }
126
179
 
@@ -135,47 +188,54 @@ export class PolicyBuilder {
135
188
  selector: "0x00000000",
136
189
  descriptor,
137
190
  groups: [[]],
138
- pathHashes: [new Set()],
191
+ groupPathKeys: new Set(),
139
192
  });
140
193
  }
141
194
 
142
195
  /**
143
196
  * Add a constraint to the current group.
144
197
  * @param constraint - A `Constraint` object or a `ConstraintBuilder` instance.
198
+ * @throws {CallciumError} With code `OUT_OF_PHYSICAL_BOUNDS` when a numeric operand lies outside
199
+ * the target type's range and encodes as a value inside it. Operands are read as written, so a
200
+ * `Constraint` carrying encoded operator bytes is left to {@link validate}.
145
201
  */
146
- add(constraint: Constraint | ConstraintBuilder): this {
147
- const c: Constraint = {
202
+ add(constraint: Constraint): this {
203
+ // A builder compiles no hint; one arrives only on a constraint that came already encoded.
204
+ const added: Constraint = {
148
205
  scope: constraint.scope,
149
206
  path: constraint.path,
150
207
  operators: [...constraint.operators],
208
+ ...("hint" in constraint && constraint.hint !== undefined && { hint: constraint.hint }),
151
209
  };
152
210
 
153
- if (c.operators.length === 0) {
211
+ if (added.operators.length === 0) {
154
212
  throw new CallciumError("NO_CONSTRAINT_OPERATORS", "Constraint must have at least one operator");
155
213
  }
156
214
 
157
215
  // Path shape is established before the scope decides how to navigate it.
158
- const steps = parsePathSteps(c.path);
216
+ const steps = parsePathSteps(added.path);
159
217
  if (steps.length === 0) {
160
218
  throw new CallciumError("EMPTY_PATH", "Path must have at least one step");
161
219
  }
162
220
 
163
- if (c.scope === Scope.CONTEXT) {
164
- validateContextPath(steps);
165
- } else if (c.scope === Scope.CALLDATA) {
166
- validateCalldataPath(steps, this.draft.descriptor);
221
+ let targetTypeCode: number;
222
+ if (added.scope === Scope.CONTEXT) {
223
+ targetTypeCode = validateContextPath(steps);
224
+ } else if (added.scope === Scope.CALLDATA) {
225
+ targetTypeCode = validateCalldataPath(steps, this.draft.descriptor);
167
226
  } else {
168
- throw new CallciumError("INVALID_SCOPE", `Unknown scope value ${c.scope}`);
227
+ throw new CallciumError("INVALID_SCOPE", `Unknown scope value ${added.scope}`);
169
228
  }
170
229
 
171
- const key = `${c.scope}:${c.path.toLowerCase()}`;
172
- const currentHashes = this.draft.pathHashes[this.draft.pathHashes.length - 1]!;
173
- if (currentHashes.has(key)) {
174
- throw new CallciumError("DUPLICATE_PATH_IN_GROUP", `Duplicate path ${c.path} in the same group`);
230
+ checkOperandDomain(constraint, targetTypeCode);
231
+
232
+ const key = `${added.scope}:${added.path.toLowerCase()}`;
233
+ if (this.draft.groupPathKeys.has(key)) {
234
+ throw new CallciumError("DUPLICATE_PATH_IN_GROUP", `Duplicate path ${added.path} in the same group`);
175
235
  }
176
236
 
177
- currentHashes.add(key);
178
- this.draft.groups[this.draft.groups.length - 1]!.push(c);
237
+ this.draft.groupPathKeys.add(key);
238
+ this.draft.groups[this.draft.groups.length - 1]!.push(added);
179
239
  return this;
180
240
  }
181
241
 
@@ -186,7 +246,7 @@ export class PolicyBuilder {
186
246
  throw new CallciumError("EMPTY_GROUP", "Cannot start a new group when the current group is empty");
187
247
  }
188
248
  this.draft.groups.push([]);
189
- this.draft.pathHashes.push(new Set());
249
+ this.draft.groupPathKeys.clear();
190
250
  return this;
191
251
  }
192
252
 
@@ -197,6 +257,8 @@ export class PolicyBuilder {
197
257
  * @returns The policy as a 0x-prefixed hex string.
198
258
  * @throws {CallciumError} If any group is empty.
199
259
  * @throws {ValidationError} If validation finds any issue.
260
+ * @throws {CallciumError} With code `RULE_SIZE_OVERFLOW` when a rule's encoded bytes exceed the
261
+ * width of the rule size field.
200
262
  */
201
263
  build(): Hex {
202
264
  this.checkGroups();