@callcium/sdk 0.11.1 → 0.13.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.
@@ -1,16 +1,28 @@
1
- import { bytesToHex } from "./bytes";
2
- import { Scope, TypeCode, lookupContextProperty } from "./constants";
1
+ import { bigintToHex, bytesToHex, hexToBytes } from "./bytes";
2
+ import {
3
+ comparisonOf,
4
+ formatCode,
5
+ lookupContextProperty,
6
+ LoopQuantifier,
7
+ Op,
8
+ PolicyFormat as PF,
9
+ Scope,
10
+ Source,
11
+ sourceOf,
12
+ TypeCode,
13
+ } from "./constants";
3
14
  import { readOperandExtremes } from "./constraint";
4
- import { Descriptor } from "./descriptor";
5
15
  import { DescriptorCoder } from "./descriptor-coder";
6
16
  import { CallciumError, ValidationError } from "./errors";
7
- import { isSigned } from "./operators";
8
- import { isQuantifier, parsePathSteps } from "./path";
17
+ import { Hint } from "./hint";
18
+ import { canonicalize, isSigned, loadOperand, operandTypeOf, operandWidth, toBigInt } from "./operators";
19
+ import { encodePath, parsePathSteps, Quantifier } from "./path";
9
20
  import { PolicyCoder } from "./policy-coder";
10
21
  import { PolicyValidator } from "./policy-validator";
11
22
  import { SignatureParser } from "./signature";
12
23
 
13
- import type { Constraint, Hex, Issue, PolicyData } from "./types";
24
+ import type { Hex } from "./types";
25
+ import type { BodyRule, Constraint, Issue, PolicyData, Rule } from "./types";
14
26
 
15
27
  ///////////////////////////////////////////////////////////////////////////
16
28
  // Internal types
@@ -20,80 +32,9 @@ type PolicyDraft = {
20
32
  isSelectorless: boolean;
21
33
  selector: Hex;
22
34
  descriptor: Uint8Array;
23
- groups: Constraint[][];
24
- /** Scope-qualified paths already taken in the group being built. */
25
- groupPathKeys: Set<string>;
35
+ groups: Rule[][];
26
36
  };
27
37
 
28
- ///////////////////////////////////////////////////////////////////////////
29
- // Path validation
30
- ///////////////////////////////////////////////////////////////////////////
31
-
32
- /** Validate a context-scope path and return the referenced property's type code. */
33
- function validateContextPath(steps: number[]): number {
34
- if (steps.length !== 1) {
35
- throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope path must be exactly one step");
36
- }
37
- return lookupContextProperty(steps[0]!).typeCode;
38
- }
39
-
40
- /** Validate a calldata-scope path against the descriptor and return the target's type code. */
41
- function validateCalldataPath(steps: number[], desc: Uint8Array): number {
42
- const argIndex = steps[0]!;
43
- const paramCount = Descriptor.paramCount(desc);
44
- if (argIndex >= paramCount) {
45
- throw new CallciumError(
46
- "PARAM_INDEX_OUT_OF_BOUNDS",
47
- `Param index ${argIndex} out of range (descriptor has ${paramCount} params)`,
48
- );
49
- }
50
-
51
- let offset = Descriptor.paramOffset(desc, argIndex);
52
- let hasQuantifier = false;
53
-
54
- for (let i = 1; i < steps.length; i++) {
55
- const step = steps[i]!;
56
- const info = Descriptor.inspect(desc, offset);
57
- const isArray = info.typeCode === TypeCode.STATIC_ARRAY || info.typeCode === TypeCode.DYNAMIC_ARRAY;
58
-
59
- if (isQuantifier(step)) {
60
- if (!isArray) {
61
- throw new CallciumError("QUANTIFIER_ON_NON_ARRAY", "Quantifier step is only valid on an array node");
62
- }
63
- if (hasQuantifier) {
64
- throw new CallciumError("NESTED_QUANTIFIER", "Nested quantifiers are not allowed");
65
- }
66
- hasQuantifier = true;
67
- }
68
-
69
- if (info.typeCode === TypeCode.TUPLE) {
70
- const fieldCount = Descriptor.tupleFieldCount(desc, offset);
71
- if (step >= fieldCount) {
72
- throw new CallciumError(
73
- "TUPLE_FIELD_OUT_OF_BOUNDS",
74
- `Tuple field index ${step} out of range (tuple has ${fieldCount} fields)`,
75
- );
76
- }
77
- offset = Descriptor.tupleFieldOffset(desc, offset, step);
78
- } else if (isArray) {
79
- if (!isQuantifier(step) && info.typeCode === TypeCode.STATIC_ARRAY) {
80
- const arrayLength = Descriptor.staticArrayLength(desc, offset);
81
- if (step >= arrayLength) {
82
- throw new CallciumError(
83
- "STATIC_ARRAY_INDEX_OUT_OF_BOUNDS",
84
- `Array index ${step} out of range (static array has ${arrayLength} elements)`,
85
- );
86
- }
87
- }
88
- offset = Descriptor.arrayElementOffset(offset);
89
- } else {
90
- throw new CallciumError("NOT_COMPOSITE", "Cannot descend into an elementary type");
91
- }
92
- }
93
-
94
- return Descriptor.inspect(desc, offset).typeCode;
95
- }
96
-
97
38
  ///////////////////////////////////////////////////////////////////////////
98
39
  // Operand domain
99
40
  ///////////////////////////////////////////////////////////////////////////
@@ -101,20 +42,6 @@ function validateCalldataPath(steps: number[], desc: Uint8Array): number {
101
42
  // Values a 32-byte word represents, which is the distance between an operand and its alias.
102
43
  const WORD_VALUES = 1n << 256n;
103
44
 
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
45
  /** Reject an operand outside the target's range whose word carries a value inside it. */
119
46
  function reject(written: bigint, folded: bigint): never {
120
47
  throw new CallciumError(
@@ -124,36 +51,85 @@ function reject(written: bigint, folded: bigint): never {
124
51
  }
125
52
 
126
53
  /**
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.
54
+ * Reject an operand that two's complement folds onto a value the target admits. Such an operand and the value it folds
55
+ * onto encode to one word. The operands as written are the only place the two stay distinct. An operand spans no
56
+ * more than one word, which leaves two ways across. A negative's alias clears every target narrower than the whole
57
+ * word. A positive reaches a signed range only at that type's own minimum word.
132
58
  */
133
59
  function checkOperandDomain(constraint: Constraint, typeCode: number): void {
134
- const bounds = targetBounds(typeCode);
135
60
  const extremes = readOperandExtremes(constraint);
136
- if (bounds === null || extremes === undefined) return;
61
+ if (extremes === undefined) return;
137
62
 
138
- // A negative operand occupies the word its unsigned alias does, which only the target holding
139
- // the whole word can hold too.
63
+ // A negative operand encodes as its unsigned alias's word, which only a whole-word target can hold too.
140
64
  if (extremes.leastNegative < 0n && typeCode === TypeCode.UINT_MAX) {
141
65
  reject(extremes.leastNegative, extremes.leastNegative + WORD_VALUES);
142
66
  }
143
67
 
144
68
  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) {
69
+ // An operand at or above the signed minimum's own word denotes that negative, and every such operand already exceeds
70
+ // the signed maximum.
71
+ const signedMinimumWord = WORD_VALUES - (1n << BigInt(8 * operandWidth(typeCode)! - 1));
72
+ if (extremes.greatest >= signedMinimumWord) {
148
73
  reject(extremes.greatest, extremes.greatest - WORD_VALUES);
149
74
  }
150
75
  }
151
76
 
77
+ /** Reject an operand word its operand type cannot represent. */
78
+ function outOfRange(word: bigint): never {
79
+ throw new CallciumError(
80
+ "OUT_OF_PHYSICAL_BOUNDS",
81
+ `Operand ${bigintToHex(word)} is outside the range of its operand type`,
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Require every comparison of `constraint` to apply to a target of `typeCode` and every constant to be a value its
87
+ * operand type can represent. Return the operators with each mask as its operand type's word.
88
+ */
89
+ function checkOperands(constraint: Constraint, typeCode: number): Hex[] {
90
+ checkOperandDomain(constraint, typeCode);
91
+
92
+ return constraint.operators.map((operatorHex) => {
93
+ const operator = hexToBytes(operatorHex);
94
+ // Only an opcode followed by whole words carries constants to check.
95
+ if (operator.length <= 1 || (operator.length - 1) % 32 !== 0) return operatorHex;
96
+ const opCode = operator[0]!;
97
+ const comparison = comparisonOf(opCode);
98
+ const operandType = operandTypeOf(comparison, typeCode);
99
+ if (operandType === undefined) {
100
+ throw new CallciumError("OPERATOR_TARGET_MISMATCH", "Comparison does not apply to the target type");
101
+ }
102
+ if (sourceOf(opCode) !== Source.CONSTANT) return operatorHex;
103
+
104
+ // The members of a set fill at most what the data length field holds.
105
+ const width = operandWidth(operandType)!;
106
+ const limit = Math.floor((2 ** (8 * PF.RULE_DATALENGTH_SIZE) - 1) / width);
107
+ if ((operator.length - 1) / 32 > limit) {
108
+ throw new CallciumError("SET_TOO_LARGE", `Set must contain at most ${limit} values`);
109
+ }
110
+
111
+ const isMask = comparison >= Op.BITMASK_ALL && comparison <= Op.BITMASK_NONE;
112
+ const checked = operator.slice();
113
+ for (let offset = 1; offset < operator.length; offset += 32) {
114
+ const word = toBigInt(operator, offset);
115
+ if (!isMask) {
116
+ if (canonicalize(word, operandType) !== word) outOfRange(word);
117
+ continue;
118
+ }
119
+ // A mask is a bit pattern of the type's width, or -1 for every bit.
120
+ if (word !== WORD_VALUES - 1n && word >> BigInt(8 * width) !== 0n) outOfRange(word);
121
+ const pattern = hexToBytes(bigintToHex(word)).subarray(32 - width);
122
+ checked.set(hexToBytes(bigintToHex(loadOperand(pattern, 0, operandType))), offset);
123
+ }
124
+ return bytesToHex(checked);
125
+ });
126
+ }
127
+
152
128
  ///////////////////////////////////////////////////////////////////////////
153
129
  // PolicyBuilder
154
130
  ///////////////////////////////////////////////////////////////////////////
155
131
 
156
- /** Fluent builder for constructing Callcium policies. */
132
+ /** Fluent API for drafting policies from constraints. */
157
133
  export class PolicyBuilder {
158
134
  private draft: PolicyDraft;
159
135
 
@@ -163,145 +139,160 @@ export class PolicyBuilder {
163
139
 
164
140
  /**
165
141
  * Create a builder from a function signature.
166
- * @param signature - ABI function signature, e.g. `"transfer(address,uint256)"`.
142
+ * @param signature - The function signature (for example "transfer(address,uint256)").
143
+ * @returns A new builder.
144
+ * @throws {CallciumError} If the signature is malformed.
167
145
  */
168
146
  static create(signature: string): PolicyBuilder {
169
147
  const parsed = SignatureParser.parse(signature);
170
148
  const descriptor = DescriptorCoder.fromTypes(parsed.types);
171
- return new PolicyBuilder({
172
- isSelectorless: false,
173
- selector: parsed.selector,
174
- descriptor,
175
- groups: [[]],
176
- groupPathKeys: new Set(),
177
- });
149
+ return new PolicyBuilder({ isSelectorless: false, selector: parsed.selector, descriptor, groups: [[]] });
178
150
  }
179
151
 
180
152
  /**
181
- * Create a selectorless builder from a raw type string.
182
- * @param typesCsv - Comma-separated ABI type strings, e.g. `"address,uint256"`.
153
+ * Create a selectorless builder from a comma-separated type list.
154
+ * @param typesCsv - The comma-separated ABI types (for example "uint256,address").
155
+ * @returns A new builder.
156
+ * @throws {CallciumError} If a type is malformed.
183
157
  */
184
158
  static createRaw(typesCsv: string): PolicyBuilder {
185
159
  const descriptor = DescriptorCoder.fromTypes(typesCsv);
186
- return new PolicyBuilder({
187
- isSelectorless: true,
188
- selector: "0x00000000",
189
- descriptor,
190
- groups: [[]],
191
- groupPathKeys: new Set(),
192
- });
160
+ return new PolicyBuilder({ isSelectorless: true, selector: "0x00000000", descriptor, groups: [[]] });
193
161
  }
194
162
 
195
163
  /**
196
- * Add a constraint to the current group.
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}.
164
+ * Add a constraint to the active group. A path through `Quantifier.ALL` or `Quantifier.ANY` adds its operators to the
165
+ * body of the group's loop over that array with that quantifier.
166
+ * @param constraint - The constraint to add.
167
+ * @returns This builder.
168
+ * @throws {CallciumError} If the constraint has no operator, or its path does not reach a target in the signature.
169
+ * If a comparison does not apply to its target, or an operand is a value its target's type cannot represent.
201
170
  */
202
171
  add(constraint: Constraint): this {
203
- // A builder compiles no hint; one arrives only on a constraint that came already encoded.
204
- const added: Constraint = {
205
- scope: constraint.scope,
206
- path: constraint.path,
207
- operators: [...constraint.operators],
208
- ...("hint" in constraint && constraint.hint !== undefined && { hint: constraint.hint }),
209
- };
210
-
211
- if (added.operators.length === 0) {
172
+ if (constraint.operators.length === 0) {
212
173
  throw new CallciumError("NO_CONSTRAINT_OPERATORS", "Constraint must have at least one operator");
213
174
  }
214
175
 
215
176
  // Path shape is established before the scope decides how to navigate it.
216
- const steps = parsePathSteps(added.path);
177
+ const steps = parsePathSteps(constraint.path);
217
178
  if (steps.length === 0) {
218
179
  throw new CallciumError("EMPTY_PATH", "Path must have at least one step");
219
180
  }
220
181
 
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);
182
+ // A caller from JavaScript can pass any scope, so the value is read as a number.
183
+ const scope: number = constraint.scope;
184
+ if (scope === Scope.CONTEXT) {
185
+ this.addContextTests(constraint, steps);
186
+ } else if (scope === Scope.CALLDATA) {
187
+ this.addCalldataTests(constraint, steps);
226
188
  } else {
227
- throw new CallciumError("INVALID_SCOPE", `Unknown scope value ${added.scope}`);
189
+ throw new CallciumError("UNKNOWN_SCOPE", `Unknown scope value ${scope}`);
228
190
  }
229
-
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`);
235
- }
236
-
237
- this.draft.groupPathKeys.add(key);
238
- this.draft.groups[this.draft.groups.length - 1]!.push(added);
239
191
  return this;
240
192
  }
241
193
 
242
- /** Start a new constraint group (OR branch). */
194
+ /**
195
+ * Start a new constraint group (OR semantics between groups).
196
+ * @returns This builder.
197
+ * @throws {CallciumError} If the current group is empty.
198
+ */
243
199
  or(): this {
244
200
  const lastGroup = this.draft.groups[this.draft.groups.length - 1]!;
245
201
  if (lastGroup.length === 0) {
246
202
  throw new CallciumError("EMPTY_GROUP", "Cannot start a new group when the current group is empty");
247
203
  }
248
204
  this.draft.groups.push([]);
249
- this.draft.groupPathKeys.clear();
250
205
  return this;
251
206
  }
252
207
 
253
208
  /**
254
- * Build the policy into an encoded binary blob with strict validation.
255
- * Throws on any issue, regardless of severity. Use {@link validate} to
256
- * inspect issues, or {@link buildUnsafe} to encode without validation.
257
- * @returns The policy as a 0x-prefixed hex string.
258
- * @throws {CallciumError} If any group is empty.
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.
209
+ * Build the policy blob and validate it.
210
+ * @returns The encoded policy.
211
+ * @throws {ValidationError} If validation reports any issue.
212
+ * @throws {CallciumError} If the encoder refuses the policy, or the blob fails storage validation.
262
213
  */
263
214
  build(): Hex {
264
- this.checkGroups();
265
- const policyData = this.toPolicyData();
266
- const issues = PolicyValidator.validate(policyData);
267
- if (issues.length > 0) {
268
- throw new ValidationError(issues);
269
- }
270
- return PolicyCoder.encode(policyData);
215
+ const blob = this.buildUnsafe();
216
+ const issues = PolicyValidator.validate(blob);
217
+ if (issues.length > 0) throw new ValidationError(issues);
218
+ return blob;
271
219
  }
272
220
 
273
221
  /**
274
- * Build the policy into an encoded binary blob without validation.
275
- * The resulting policy may be invalid. Prefer {@link build}; use this
276
- * only to knowingly bypass a reported issue.
277
- * @returns The policy as a 0x-prefixed hex string.
278
- * @throws {CallciumError} If any group is empty.
222
+ * Validate the policy the builder contains.
223
+ * @returns Every issue validation reports on the encoded policy.
224
+ * @throws {CallciumError} If the encoder refuses the policy, or the blob fails storage validation.
279
225
  */
280
- buildUnsafe(): Hex {
281
- this.checkGroups();
282
- return PolicyCoder.encode(this.toPolicyData());
226
+ validate(): Issue[] {
227
+ return PolicyValidator.validate(this.buildUnsafe());
283
228
  }
284
229
 
285
230
  /**
286
- * Validate the policy without encoding.
287
- * @returns All validation issues found.
231
+ * Build the policy blob without validation. The resulting policy can be invalid.
232
+ * @returns The encoded policy.
233
+ * @throws {CallciumError} If the encoder refuses the policy.
288
234
  */
289
- validate(): Issue[] {
290
- this.checkGroups();
291
- const policyData = this.toPolicyData();
292
- return PolicyValidator.validate(policyData);
235
+ buildUnsafe(): Hex {
236
+ return PolicyCoder.encode(this.toPolicyData());
293
237
  }
294
238
 
295
- /** Throw if any group is empty. */
296
- private checkGroups(): void {
297
- for (let i = 0; i < this.draft.groups.length; i++) {
298
- if (this.draft.groups[i]!.length === 0) {
299
- throw new CallciumError("EMPTY_GROUP", `Group ${i} is empty`);
239
+ /** Append a context test per operator of `constraint`, whose path names a context property. */
240
+ private addContextTests(constraint: Constraint, steps: number[]): void {
241
+ if (steps.length !== 1) {
242
+ throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope path must be exactly one step");
243
+ }
244
+ const { typeCode } = lookupContextProperty(steps[0]!);
245
+ for (const operator of checkOperands(constraint, typeCode)) {
246
+ this.currentGroup().push({ scope: Scope.CONTEXT, path: constraint.path, operator });
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Append a calldata test per operator of `constraint`. If the path of `constraint` passes a quantifier step, append a
252
+ * body test per operator to the group's loop over that array instead.
253
+ */
254
+ private addCalldataTests(constraint: Constraint, steps: number[]): void {
255
+ const desc = this.draft.descriptor;
256
+ const quantifierIndex = findQuantifierIndex(steps);
257
+
258
+ if (quantifierIndex === undefined) {
259
+ const target = Hint.requireCompiled(Hint.compileTest(desc, steps), steps, 0);
260
+ for (const operator of checkOperands(constraint, desc[target.node]!)) {
261
+ this.currentGroup().push({ scope: Scope.CALLDATA, path: constraint.path, operator });
300
262
  }
263
+ return;
264
+ }
265
+
266
+ // The quantifier splits the path into the array's path and the body test's path from the element.
267
+ const arraySteps = steps.slice(0, quantifierIndex);
268
+ const bodySteps = steps.slice(quantifierIndex + 1);
269
+ const loop = Hint.requireCompiled(Hint.compileLoop(desc, arraySteps), steps, 0);
270
+ const target = Hint.requireCompiled(Hint.compileTest(desc, bodySteps, loop.node), steps, quantifierIndex + 1);
271
+
272
+ const bodyPath = encodePath(bodySteps);
273
+ const tests: BodyRule[] = checkOperands(constraint, desc[target.node]!).map((operator) => ({
274
+ scope: Scope.CALLDATA,
275
+ path: bodyPath,
276
+ operator,
277
+ }));
278
+ const quantifier = steps[quantifierIndex] === Quantifier.ALL ? LoopQuantifier.ALL : LoopQuantifier.ANY;
279
+ const arrayPath = encodePath(arraySteps);
280
+ const existing = this.currentGroup().find(
281
+ (rule) => rule.scope === Scope.LOOP && rule.quantifier === quantifier && rule.path === arrayPath,
282
+ );
283
+ if (existing !== undefined && existing.scope === Scope.LOOP) {
284
+ existing.body.push(...tests);
285
+ } else {
286
+ this.currentGroup().push({ scope: Scope.LOOP, quantifier, path: arrayPath, body: tests });
301
287
  }
302
288
  }
303
289
 
304
- /** Convert the draft to a PolicyData structure. */
290
+ /** The active group. */
291
+ private currentGroup(): Rule[] {
292
+ return this.draft.groups[this.draft.groups.length - 1]!;
293
+ }
294
+
295
+ /** The draft as the encoder takes it. */
305
296
  private toPolicyData(): PolicyData {
306
297
  return {
307
298
  isSelectorless: this.draft.isSelectorless,
@@ -311,3 +302,27 @@ export class PolicyBuilder {
311
302
  };
312
303
  }
313
304
  }
305
+
306
+ /**
307
+ * Return the index of the path's quantifier step, or undefined when the path has none.
308
+ * The first step is a parameter index.
309
+ * @throws {CallciumError} On a second quantifier step and on any other step in the reserved marker range.
310
+ */
311
+ function findQuantifierIndex(steps: readonly number[]): number | undefined {
312
+ let quantifierIndex: number | undefined;
313
+ for (let stepIndex = 1; stepIndex < steps.length; stepIndex++) {
314
+ const step = steps[stepIndex]!;
315
+ if (step < PF.PATH_MARKER_BASE) continue;
316
+ if (step !== Quantifier.ALL && step !== Quantifier.ANY) {
317
+ throw new CallciumError(
318
+ "UNKNOWN_PATH_STEP",
319
+ `Path step ${formatCode(step, PF.PATH_STEP_SIZE)} is in the reserved marker range`,
320
+ );
321
+ }
322
+ if (quantifierIndex !== undefined) {
323
+ throw new CallciumError("INVALID_NESTED_LOOP", "Loop body holds a loop");
324
+ }
325
+ quantifierIndex = stepIndex;
326
+ }
327
+ return quantifierIndex;
328
+ }