@callcium/sdk 0.8.0 → 0.10.0

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/operators.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Op, TypeCode } from "./constants";
1
+ import { Op, TypeCode, operandsOf } from "./constants";
2
2
  import { CallciumError } from "./errors";
3
3
 
4
4
  /** Read a big-endian 256-bit unsigned integer from a 32-byte window. */
@@ -28,7 +28,7 @@ export function isLeftAligned(typeCode: number): boolean {
28
28
  }
29
29
 
30
30
  /**
31
- * Canonicalize a raw 256-bit calldata word to its ABI value for the declared type (Policy Spec §7.8).
31
+ * Canonicalize a raw 256-bit calldata word to its ABI value for the declared type.
32
32
  * A scalar loaded from untrusted calldata may carry bits outside the declared width; masking
33
33
  * unsigned/address/bool/function/bytesN to width and sign-extending signed integers makes the
34
34
  * comparison use the canonical ABI value rather than the raw bytes.
@@ -103,14 +103,14 @@ function compareTyped(a: bigint, b: bigint, typeCode: number): number {
103
103
  /** Binary search over a sorted array of 32-byte words. Comparison is always unsigned. */
104
104
  function isIn(value: bigint, data: Uint8Array): boolean {
105
105
  const count = data.length / 32;
106
- let lo = 0;
107
- let hi = count - 1;
108
- while (lo <= hi) {
109
- const mid = (lo + hi) >>> 1;
106
+ let low = 0;
107
+ let high = count - 1;
108
+ while (low <= high) {
109
+ const mid = (low + high) >>> 1;
110
110
  const elem = toBigInt(data, mid * 32);
111
111
  if (value === elem) return true;
112
- if (value < elem) hi = mid - 1;
113
- else lo = mid + 1;
112
+ if (value < elem) high = mid - 1;
113
+ else low = mid + 1;
114
114
  }
115
115
  return false;
116
116
  }
@@ -196,9 +196,114 @@ export function applyOperator(
196
196
  }
197
197
 
198
198
  default:
199
- throw new CallciumError("UNKNOWN_OPERATOR", `Unknown operator code 0x${base.toString(16).padStart(2, "0")}.`);
199
+ throw new CallciumError("UNKNOWN_OPERATOR", `Unknown operator code 0x${base.toString(16).padStart(2, "0")}`);
200
200
  }
201
201
 
202
202
  const negate = (opCode & Op.NOT) !== 0;
203
203
  return negate ? !result : result;
204
204
  }
205
+
206
+ /**
207
+ * Check whether an operator's data payload has the correct length.
208
+ * @param opBase - Base operator code with the NOT flag stripped.
209
+ * @param dataLength - Byte length of the operator's data payload.
210
+ * @returns True if the data length is valid for the given operator.
211
+ */
212
+ export function isValidOperatorData(opBase: number, dataLength: number): boolean {
213
+ const operands = operandsOf(opBase);
214
+ if (operands === "single") return dataLength === 32;
215
+ if (operands === "range") return dataLength === 64;
216
+ if (operands === "variadic") return dataLength > 0 && dataLength % 32 === 0;
217
+ return false;
218
+ }
219
+
220
+ ///////////////////////////////////////////////////////////////////////////
221
+ // Type code classification
222
+ ///////////////////////////////////////////////////////////////////////////
223
+
224
+ /** Structural category for a descriptor type code. */
225
+ export type TypeClass = "elementary" | "tuple" | "staticArray" | "dynamicArray";
226
+
227
+ /** Structural classification without label. */
228
+ export type TypeClassInfo = { typeClass: TypeClass; isDynamic: boolean };
229
+
230
+ /** Display metadata for a descriptor type code. */
231
+ export type TypeCodeInfo = TypeClassInfo & { label: string };
232
+
233
+ /** Throw an UNKNOWN_TYPE_CODE error. */
234
+ function unknownTypeCode(code: number): never {
235
+ throw new CallciumError("UNKNOWN_TYPE_CODE", `Unknown type code 0x${code.toString(16).padStart(2, "0")}`);
236
+ }
237
+
238
+ // Pre-allocated constant objects for fixed type codes (avoids per-call allocation).
239
+ const ELEMENTARY = { typeClass: "elementary", isDynamic: false } as const;
240
+ const ELEMENTARY_DYNAMIC = { typeClass: "elementary", isDynamic: true } as const;
241
+ const STATIC_ARR = { typeClass: "staticArray", isDynamic: false } as const;
242
+ const DYNAMIC_ARR = { typeClass: "dynamicArray", isDynamic: true } as const;
243
+ const TUPLE = { typeClass: "tuple", isDynamic: false } as const;
244
+
245
+ /**
246
+ * Classify a type code into its structural category and dynamism.
247
+ * Lightweight variant of `lookupTypeCode` that skips label computation.
248
+ * @param code - A single-byte descriptor type code.
249
+ * @returns Type class and whether the type is ABI-dynamic.
250
+ * @throws {CallciumError} If the code is not a recognised type code.
251
+ */
252
+ export function classifyTypeCode(code: number): TypeClassInfo {
253
+ if (code >= TypeCode.UINT_MIN && code <= TypeCode.UINT_MAX) return ELEMENTARY;
254
+ if (code >= TypeCode.INT_MIN && code <= TypeCode.INT_MAX) return ELEMENTARY;
255
+ if (code === TypeCode.ADDRESS || code === TypeCode.BOOL || code === TypeCode.FUNCTION) return ELEMENTARY;
256
+ if (code >= 0x44 && code <= 0x4f) unknownTypeCode(code);
257
+ if (code >= TypeCode.FIXED_BYTES_MIN && code <= TypeCode.FIXED_BYTES_MAX) return ELEMENTARY;
258
+ if (code === TypeCode.BYTES || code === TypeCode.STRING) return ELEMENTARY_DYNAMIC;
259
+ if (code >= 0x72 && code <= 0x7f) unknownTypeCode(code);
260
+ if (code === TypeCode.STATIC_ARRAY) return STATIC_ARR;
261
+ if (code === TypeCode.DYNAMIC_ARRAY) return DYNAMIC_ARR;
262
+ if (code >= 0x82 && code <= 0x8f) unknownTypeCode(code);
263
+ if (code === TypeCode.TUPLE) return TUPLE;
264
+ unknownTypeCode(code);
265
+ }
266
+
267
+ /**
268
+ * Determine whether a type code names a value an operator can read: a scalar word or a declared
269
+ * length. Tuples, static arrays, and undefined codes address nothing.
270
+ * @param code - A single-byte descriptor type code.
271
+ * @returns True when an operator can be applied to a target of this type.
272
+ */
273
+ export function isAddressableTarget(code: number): boolean {
274
+ let info: TypeClassInfo;
275
+ try {
276
+ // An undefined code is reported by a throw, and addresses nothing either way.
277
+ info = classifyTypeCode(code);
278
+ } catch {
279
+ return false;
280
+ }
281
+ return info.typeClass === "elementary" || info.typeClass === "dynamicArray";
282
+ }
283
+
284
+ /** Compute the ABI type label for a type code. */
285
+ function typeCodeLabel(code: number): string {
286
+ if (code >= TypeCode.UINT_MIN && code <= TypeCode.UINT_MAX) return `uint${(code - TypeCode.UINT_MIN + 1) * 8}`;
287
+ if (code >= TypeCode.INT_MIN && code <= TypeCode.INT_MAX) return `int${(code - TypeCode.INT_MIN + 1) * 8}`;
288
+ if (code === TypeCode.ADDRESS) return "address";
289
+ if (code === TypeCode.BOOL) return "bool";
290
+ if (code === TypeCode.FUNCTION) return "function";
291
+ if (code >= TypeCode.FIXED_BYTES_MIN && code <= TypeCode.FIXED_BYTES_MAX)
292
+ return `bytes${code - TypeCode.FIXED_BYTES_MIN + 1}`;
293
+ if (code === TypeCode.BYTES) return "bytes";
294
+ if (code === TypeCode.STRING) return "string";
295
+ if (code === TypeCode.STATIC_ARRAY) return "T[k]";
296
+ if (code === TypeCode.DYNAMIC_ARRAY) return "T[]";
297
+ if (code === TypeCode.TUPLE) return "tuple";
298
+ return `0x${code.toString(16).padStart(2, "0")}`;
299
+ }
300
+
301
+ /**
302
+ * Map a raw type code byte to its ABI type label, structural category, and dynamism.
303
+ * @param code - A single-byte descriptor type code.
304
+ * @returns Label, type class, and whether the type is ABI-dynamic.
305
+ * @throws {CallciumError} If the code is not a recognised type code.
306
+ */
307
+ export function lookupTypeCode(code: number): TypeCodeInfo {
308
+ return { label: typeCodeLabel(code), ...classifyTypeCode(code) };
309
+ }
package/src/path.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { bytesToHex, hexToBytes, readU16, writeBE16 } from "./bytes";
2
+ import { buildCodeMap, PolicyFormat as PF } from "./constants";
3
+ import { CallciumError } from "./errors";
4
+
5
+ import type { Hex } from "./types";
6
+
7
+ ///////////////////////////////////////////////////////////////////////////
8
+ // Quantifier steps
9
+ ///////////////////////////////////////////////////////////////////////////
10
+
11
+ const QUANTIFIER_TABLE = [
12
+ { key: "ALL", code: 0xffff, label: "all" },
13
+ { key: "ANY", code: 0xfffe, label: "any" },
14
+ ] as const;
15
+
16
+ /** Reserved path step values that trigger quantified evaluation over array elements. */
17
+ export const Quantifier = buildCodeMap(QUANTIFIER_TABLE);
18
+
19
+ /** Display metadata for a quantifier step. */
20
+ export type QuantifierInfo = { label: string };
21
+
22
+ const quantifierByCode: ReadonlyMap<number, QuantifierInfo> = new Map<number, QuantifierInfo>(
23
+ QUANTIFIER_TABLE.map((e) => [e.code, { label: e.label }]),
24
+ );
25
+
26
+ /** Check whether a path step is a quantifier (ALL or ANY). */
27
+ export function isQuantifier(step: number): boolean {
28
+ return step >= Quantifier.ANY;
29
+ }
30
+
31
+ /**
32
+ * Map a quantifier path step to its display label.
33
+ * @param code - Quantifier step value.
34
+ * @returns Display metadata for the quantifier.
35
+ * @throws {CallciumError} If the code is not a recognised quantifier.
36
+ */
37
+ export function lookupQuantifier(code: number): QuantifierInfo {
38
+ const info = quantifierByCode.get(code);
39
+ if (!info) throw new CallciumError("UNKNOWN_QUANTIFIER", `Unknown quantifier step 0x${code.toString(16)}`);
40
+ return info;
41
+ }
42
+
43
+ ///////////////////////////////////////////////////////////////////////////
44
+ // Path steps
45
+ ///////////////////////////////////////////////////////////////////////////
46
+
47
+ /** Encode a sequence of uint16 path steps as a big-endian hex string. */
48
+ export function encodePath(steps: readonly number[]): Hex {
49
+ const buffer = new Uint8Array(steps.length * 2);
50
+ for (let i = 0; i < steps.length; i++) {
51
+ writeBE16(buffer, i * 2, steps[i]!);
52
+ }
53
+ return bytesToHex(buffer);
54
+ }
55
+
56
+ /**
57
+ * Parse a BE16-encoded hex path into an array of step values.
58
+ * @param path - 0x-prefixed hex string containing BE16-encoded path steps.
59
+ * @returns Array of numeric step values.
60
+ */
61
+ export function parsePathSteps(path: Hex): number[] {
62
+ const bytes = hexToBytes(path);
63
+ if (bytes.length % PF.PATH_STEP_SIZE !== 0) {
64
+ throw new CallciumError("MALFORMED_PATH", `Path byte length ${bytes.length} is not a whole number of steps`);
65
+ }
66
+ const steps: number[] = [];
67
+ for (let offset = 0; offset < bytes.length; offset += PF.PATH_STEP_SIZE) {
68
+ steps.push(readU16(bytes, offset));
69
+ }
70
+ return steps;
71
+ }
@@ -1,10 +1,11 @@
1
1
  import { bytesToHex } from "./bytes";
2
- import { Scope, TypeCode, isQuantifier, MAX_CONTEXT_PROPERTY_ID } from "./constants";
2
+ import { Scope, TypeCode, MAX_CONTEXT_PROPERTY_ID } from "./constants";
3
3
  import { ConstraintBuilder } from "./constraint";
4
4
  import { Descriptor } from "./descriptor";
5
5
  import { DescriptorCoder } from "./descriptor-coder";
6
6
  import { CallciumError, ValidationError } from "./errors";
7
- import { PolicyCoder, parsePathSteps } from "./policy-coder";
7
+ import { isQuantifier, parsePathSteps } from "./path";
8
+ import { PolicyCoder } from "./policy-coder";
8
9
  import { PolicyValidator } from "./policy-validator";
9
10
  import { SignatureParser } from "./signature";
10
11
 
@@ -29,13 +30,13 @@ type PolicyDraft = {
29
30
  /** Validate a context-scope path. */
30
31
  function validateContextPath(steps: number[]): void {
31
32
  if (steps.length !== 1) {
32
- throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope path must be exactly one step.");
33
+ throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope path must be exactly one step");
33
34
  }
34
35
  const step = steps[0]!;
35
36
  if (step > MAX_CONTEXT_PROPERTY_ID) {
36
37
  throw new CallciumError(
37
38
  "UNKNOWN_CONTEXT_PROPERTY",
38
- `Unknown context property ID 0x${step.toString(16).padStart(4, "0")}.`,
39
+ `Unknown context property ID 0x${step.toString(16).padStart(4, "0")}`,
39
40
  );
40
41
  }
41
42
  }
@@ -47,7 +48,7 @@ function validateCalldataPath(steps: number[], desc: Uint8Array): void {
47
48
  if (argIndex >= paramCount) {
48
49
  throw new CallciumError(
49
50
  "PARAM_INDEX_OUT_OF_BOUNDS",
50
- `Param index ${argIndex} out of range (descriptor has ${paramCount} params).`,
51
+ `Param index ${argIndex} out of range (descriptor has ${paramCount} params)`,
51
52
  );
52
53
  }
53
54
 
@@ -61,10 +62,10 @@ function validateCalldataPath(steps: number[], desc: Uint8Array): void {
61
62
 
62
63
  if (isQuantifier(step)) {
63
64
  if (!isArray) {
64
- throw new CallciumError("QUANTIFIER_ON_NON_ARRAY", "Quantifier step is only valid on an array node.");
65
+ throw new CallciumError("QUANTIFIER_ON_NON_ARRAY", "Quantifier step is only valid on an array node");
65
66
  }
66
67
  if (hasQuantifier) {
67
- throw new CallciumError("NESTED_QUANTIFIER", "Nested quantifiers are not allowed.");
68
+ throw new CallciumError("NESTED_QUANTIFIER", "Nested quantifiers are not allowed");
68
69
  }
69
70
  hasQuantifier = true;
70
71
  }
@@ -74,7 +75,7 @@ function validateCalldataPath(steps: number[], desc: Uint8Array): void {
74
75
  if (step >= fieldCount) {
75
76
  throw new CallciumError(
76
77
  "TUPLE_FIELD_OUT_OF_BOUNDS",
77
- `Tuple field index ${step} out of range (tuple has ${fieldCount} fields).`,
78
+ `Tuple field index ${step} out of range (tuple has ${fieldCount} fields)`,
78
79
  );
79
80
  }
80
81
  offset = Descriptor.tupleFieldOffset(desc, offset, step);
@@ -84,13 +85,13 @@ function validateCalldataPath(steps: number[], desc: Uint8Array): void {
84
85
  if (step >= arrayLength) {
85
86
  throw new CallciumError(
86
87
  "STATIC_ARRAY_INDEX_OUT_OF_BOUNDS",
87
- `Array index ${step} out of range (static array has ${arrayLength} elements).`,
88
+ `Array index ${step} out of range (static array has ${arrayLength} elements)`,
88
89
  );
89
90
  }
90
91
  }
91
92
  offset = Descriptor.arrayElementOffset(offset);
92
93
  } else {
93
- throw new CallciumError("NOT_COMPOSITE", "Cannot descend into an elementary type.");
94
+ throw new CallciumError("NOT_COMPOSITE", "Cannot descend into an elementary type");
94
95
  }
95
96
  }
96
97
  }
@@ -150,13 +151,13 @@ export class PolicyBuilder {
150
151
  };
151
152
 
152
153
  if (c.operators.length === 0) {
153
- throw new CallciumError("NO_CONSTRAINT_OPERATORS", "Constraint must have at least one operator.");
154
+ throw new CallciumError("NO_CONSTRAINT_OPERATORS", "Constraint must have at least one operator");
154
155
  }
155
156
 
156
157
  // Path shape is established before the scope decides how to navigate it.
157
158
  const steps = parsePathSteps(c.path);
158
159
  if (steps.length === 0) {
159
- throw new CallciumError("EMPTY_PATH", "Path must have at least one step.");
160
+ throw new CallciumError("EMPTY_PATH", "Path must have at least one step");
160
161
  }
161
162
 
162
163
  if (c.scope === Scope.CONTEXT) {
@@ -164,13 +165,13 @@ export class PolicyBuilder {
164
165
  } else if (c.scope === Scope.CALLDATA) {
165
166
  validateCalldataPath(steps, this.draft.descriptor);
166
167
  } else {
167
- throw new CallciumError("INVALID_SCOPE", `Unknown scope value ${c.scope}.`);
168
+ throw new CallciumError("INVALID_SCOPE", `Unknown scope value ${c.scope}`);
168
169
  }
169
170
 
170
171
  const key = `${c.scope}:${c.path.toLowerCase()}`;
171
172
  const currentHashes = this.draft.pathHashes[this.draft.pathHashes.length - 1]!;
172
173
  if (currentHashes.has(key)) {
173
- throw new CallciumError("DUPLICATE_PATH_IN_GROUP", `Duplicate path ${c.path} in the same group.`);
174
+ throw new CallciumError("DUPLICATE_PATH_IN_GROUP", `Duplicate path ${c.path} in the same group`);
174
175
  }
175
176
 
176
177
  currentHashes.add(key);
@@ -182,7 +183,7 @@ export class PolicyBuilder {
182
183
  or(): this {
183
184
  const lastGroup = this.draft.groups[this.draft.groups.length - 1]!;
184
185
  if (lastGroup.length === 0) {
185
- throw new CallciumError("EMPTY_GROUP", "Cannot start a new group when the current group is empty.");
186
+ throw new CallciumError("EMPTY_GROUP", "Cannot start a new group when the current group is empty");
186
187
  }
187
188
  this.draft.groups.push([]);
188
189
  this.draft.pathHashes.push(new Set());
@@ -233,7 +234,7 @@ export class PolicyBuilder {
233
234
  private checkGroups(): void {
234
235
  for (let i = 0; i < this.draft.groups.length; i++) {
235
236
  if (this.draft.groups[i]!.length === 0) {
236
- throw new CallciumError("EMPTY_GROUP", `Group ${i} is empty.`);
237
+ throw new CallciumError("EMPTY_GROUP", `Group ${i} is empty`);
237
238
  }
238
239
  }
239
240
  }
@@ -1,21 +1,12 @@
1
1
  import { keccak_256 } from "@noble/hashes/sha3.js";
2
2
 
3
3
  import { bytesToHex, hexToBytes, toHex, readU16, readU32, writeBE16, writeBE32 } from "./bytes";
4
- import {
5
- DescriptorFormat as DF,
6
- PolicyFormat as PF,
7
- Scope,
8
- Limits,
9
- Op,
10
- TypeCode,
11
- isValidOperatorData,
12
- isAddressableTarget,
13
- MAX_CONTEXT_PROPERTY_ID,
14
- } from "./constants";
4
+ import { DescriptorFormat as DF, PolicyFormat as PF, Scope, Op, TypeCode, MAX_CONTEXT_PROPERTY_ID } from "./constants";
15
5
  import { Descriptor } from "./descriptor";
16
6
  import { decodeDescriptor } from "./descriptor-coder";
17
7
  import { CallciumError } from "./errors";
18
- import { isLengthOp, isLengthValidType } from "./operators";
8
+ import { isLengthOp, isLengthValidType, toBigInt, isAddressableTarget, isValidOperatorData } from "./operators";
9
+ import { parsePathSteps } from "./path";
19
10
 
20
11
  import type {
21
12
  Constraint,
@@ -28,27 +19,6 @@ import type {
28
19
  PolicyData,
29
20
  } from "./types";
30
21
 
31
- ///////////////////////////////////////////////////////////////////////////
32
- // Path helper
33
- ///////////////////////////////////////////////////////////////////////////
34
-
35
- /**
36
- * Parse a BE16-encoded hex path into an array of step values.
37
- * @param path - 0x-prefixed hex string containing BE16-encoded path steps.
38
- * @returns Array of numeric step values.
39
- */
40
- export function parsePathSteps(path: Hex): number[] {
41
- const bytes = hexToBytes(path);
42
- if (bytes.length % PF.PATH_STEP_SIZE !== 0) {
43
- throw new CallciumError("MALFORMED_PATH", `Path byte length ${bytes.length} is not a whole number of steps`);
44
- }
45
- const steps: number[] = [];
46
- for (let offset = 0; offset < bytes.length; offset += PF.PATH_STEP_SIZE) {
47
- steps.push(readU16(bytes, offset));
48
- }
49
- return steps;
50
- }
51
-
52
22
  ///////////////////////////////////////////////////////////////////////////
53
23
  // Policy field helper
54
24
  ///////////////////////////////////////////////////////////////////////////
@@ -321,14 +291,25 @@ export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Arr
321
291
  }
322
292
  }
323
293
 
294
+ // An EQ_CTX operand word must be a defined context property ID.
295
+ if (opBase === Op.EQ_CTX) {
296
+ if (toBigInt(data, dataStart) > BigInt(MAX_CONTEXT_PROPERTY_ID)) {
297
+ throw new CallciumError(
298
+ "UNKNOWN_CONTEXT_PROPERTY",
299
+ "EQ_CTX operand references an undefined context property",
300
+ ruleOffset,
301
+ );
302
+ }
303
+ }
304
+
324
305
  // The path must be non-empty and within the depth cap.
325
306
  if (depthValue === 0) {
326
307
  throw new CallciumError("EMPTY_PATH", "Rule path must have at least one step", ruleOffset);
327
308
  }
328
- if (depthValue > Limits.MAX_PATH_DEPTH) {
309
+ if (depthValue > PF.MAX_PATH_DEPTH) {
329
310
  throw new CallciumError(
330
311
  "PATH_TOO_DEEP",
331
- `Path depth ${depthValue} exceeds maximum ${Limits.MAX_PATH_DEPTH}`,
312
+ `Path depth ${depthValue} exceeds maximum ${PF.MAX_PATH_DEPTH}`,
332
313
  ruleOffset,
333
314
  );
334
315
  }
@@ -419,7 +400,7 @@ function flattenConstraint(constraint: Constraint, desc: Uint8Array): Rule[] {
419
400
  const operators = constraint.operators.map(hexToBytes);
420
401
  for (const operator of operators) {
421
402
  if (operator.length < 1) {
422
- throw new CallciumError("INVALID_OPERATOR_BYTES", "Operator must have at least one byte (opcode).");
403
+ throw new CallciumError("INVALID_OPERATOR_BYTES", "Operator must have at least one byte (opcode)");
423
404
  }
424
405
  }
425
406
 
@@ -427,14 +408,14 @@ function flattenConstraint(constraint: Constraint, desc: Uint8Array): Rule[] {
427
408
 
428
409
  // Path shape checks precede compilation: the compiler assumes a framed, depth-bounded path.
429
410
  if (path.length === 0) {
430
- throw new CallciumError("EMPTY_PATH", "Rule path must have at least one step.");
411
+ throw new CallciumError("EMPTY_PATH", "Rule path must have at least one step");
431
412
  }
432
413
  if ((path.length & 1) !== 0) {
433
- throw new CallciumError("MALFORMED_PATH", "Path byte length must be even.");
414
+ throw new CallciumError("MALFORMED_PATH", "Path byte length must be even");
434
415
  }
435
416
  const depth = path.length / 2;
436
- if (depth > Limits.MAX_PATH_DEPTH) {
437
- throw new CallciumError("PATH_TOO_DEEP", `Path depth ${depth} exceeds maximum ${Limits.MAX_PATH_DEPTH}.`);
417
+ if (depth > PF.MAX_PATH_DEPTH) {
418
+ throw new CallciumError("PATH_TOO_DEEP", `Path depth ${depth} exceeds maximum ${PF.MAX_PATH_DEPTH}`);
438
419
  }
439
420
 
440
421
  const hint =
@@ -473,7 +454,7 @@ function sortRules(rules: Rule[]): void {
473
454
  function encodeRule(rule: Rule): Uint8Array {
474
455
  const depth = rule.path.length / 2;
475
456
  if (rule.scope === Scope.CONTEXT && depth !== 1) {
476
- throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope rules must have exactly one path step.");
457
+ throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope rules must have exactly one path step");
477
458
  }
478
459
  const data = rule.operator.subarray(1);
479
460
  const ruleSize = PF.RULE_FIXED_OVERHEAD + rule.path.length + rule.hint.length + data.length;