@callcium/sdk 0.10.0 → 0.11.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,12 +1,13 @@
1
1
  import { hexToBytes, bytesToHex, readU16, readU32, bigintToHex, toAddress } from "./bytes";
2
- import { loadWord, readPointer } from "./calldata-reader";
3
- import { PolicyFormat as PF, Scope, MAX_CONTEXT_PROPERTY_ID, Op, TypeCode, lookupContextProperty } from "./constants";
2
+ import { loadWord, readLength, readPointer } from "./calldata-reader";
3
+ import { PolicyFormat as PF, Scope, Op, TypeCode, lookupContextProperty } from "./constants";
4
4
  import { CallciumError, PolicyViolationError } from "./errors";
5
- import { applyOperator, toBigInt, isLengthOp, isLengthValidType, canonicalize, classifyTypeCode } from "./operators";
5
+ import { applyOperator, toBigInt, isLengthValidType, canonicalize } from "./operators";
6
6
  import { decodePolicy } from "./policy-coder";
7
7
 
8
8
  import type { ReadResult } from "./calldata-reader";
9
9
  import type {
10
+ Address,
10
11
  Context,
11
12
  DecodedRule,
12
13
  EnforceResult,
@@ -29,9 +30,19 @@ const abortViolationCodes: ReadonlySet<ViolationCode> = new Set<ViolationCode>([
29
30
  "QUANTIFIER_LIMIT_EXCEEDED",
30
31
  ]);
31
32
 
32
- /** Validate a hex address and read it as a 256-bit bigint. */
33
- function addressToBigInt(hex: string): bigint {
34
- return BigInt(toAddress(hex));
33
+ /**
34
+ * Read a supplied context value as the 32-byte word that carries it.
35
+ * @throws {CallciumError} When an integer lies outside the range a 32-byte word represents.
36
+ */
37
+ function contextValueToWord(value: bigint | Address): bigint {
38
+ if (typeof value === "string") return BigInt(toAddress(value));
39
+ if (BigInt.asUintN(256, value) !== value) {
40
+ throw new CallciumError(
41
+ "CONTEXT_VALUE_OVERFLOW",
42
+ `Context value ${value} is outside the range a 32-byte word represents`,
43
+ );
44
+ }
45
+ return value;
35
46
  }
36
47
 
37
48
  ///////////////////////////////////////////////////////////////////////////
@@ -44,7 +55,8 @@ function addressToBigInt(hex: string): bigint {
44
55
  * @param callData - ABI-encoded call data as 0x-prefixed hex string.
45
56
  * @param context - Optional execution context for context-scoped rules.
46
57
  * @returns Pass with matched group index, or fail with one violation per evaluated failing group.
47
- * @throws {CallciumError} If the policy blob is structurally malformed.
58
+ * @throws {CallciumError} If the policy blob is structurally malformed, or a context value a rule
59
+ * reads is not an address or an integer a 32-byte word represents.
48
60
  */
49
61
  function check(policy: Hex, callData: Hex, context?: Context): EnforceResult {
50
62
  const { policy: decoded, data: policyBytes } = decodePolicy(policy);
@@ -111,7 +123,8 @@ function check(policy: Hex, callData: Hex, context?: Context): EnforceResult {
111
123
  * @param callData - ABI-encoded call data as 0x-prefixed hex string.
112
124
  * @param context - Optional execution context for context-scoped rules.
113
125
  * @throws {PolicyViolationError} If the policy rejects the call data.
114
- * @throws {CallciumError} If the policy blob is structurally malformed.
126
+ * @throws {CallciumError} If the policy blob is structurally malformed, or a context value a rule
127
+ * reads is not an address or an integer a 32-byte word represents.
115
128
  */
116
129
  function enforce(policy: Hex, callData: Hex, context?: Context): void {
117
130
  const result = check(policy, callData, context);
@@ -165,15 +178,24 @@ function evaluateRule(
165
178
  );
166
179
  }
167
180
 
181
+ /** An `EQ_CTX` operand resolved to its word, or the missing property's type code. */
182
+ type ReferenceOperand = bigint | { ctxTypeCode: number };
183
+
168
184
  /**
169
185
  * Resolve an EQ_CTX operand word to the referenced context property's value as a raw word.
170
186
  * Returns the missing property's type code when the context does not supply it.
171
187
  */
172
- function resolveContextOperand(operandData: Uint8Array, context?: Context): bigint | { ctxTypeCode: number } {
188
+ function resolveContextOperand(operandData: Uint8Array, context?: Context): ReferenceOperand {
173
189
  const propertyInfo = lookupContextProperty(Number(toBigInt(operandData, 0)));
174
190
  const value = context?.[propertyInfo.contextKey];
175
191
  if (value === undefined) return { ctxTypeCode: propertyInfo.typeCode };
176
- return typeof value === "string" ? addressToBigInt(value) : value;
192
+ return contextValueToWord(value);
193
+ }
194
+
195
+ /** Resolve the reference operand of an `EQ_CTX` rule; every other operator reads a literal. */
196
+ function referenceOperand(opCode: number, operandData: Uint8Array, context?: Context): ReferenceOperand | undefined {
197
+ if ((opCode & ~Op.NOT) !== Op.EQ_CTX) return undefined;
198
+ return resolveContextOperand(operandData, context);
177
199
  }
178
200
 
179
201
  ///////////////////////////////////////////////////////////////////////////
@@ -225,8 +247,8 @@ function chainResolve(
225
247
  const meta = readU16(hint, hop + PF.HINT_HOP_META_OFFSET);
226
248
  let elems = base;
227
249
  if ((meta & PF.HINT_META_DYNAMIC_ARRAY) !== 0) {
228
- const length = readPointer(callData, elems);
229
- if (typeof length !== "number") return length;
250
+ const length = readLength(callData, elems);
251
+ if (typeof length !== "bigint") return length;
230
252
  if (index >= length) return { code: "ARRAY_INDEX_OUT_OF_BOUNDS" };
231
253
  elems += 32;
232
254
  }
@@ -266,30 +288,24 @@ function evalTarget(
266
288
  block: TargetBlock,
267
289
  opCode: number,
268
290
  operandData: Uint8Array,
269
- context?: Context,
291
+ ctxOperand?: ReferenceOperand,
270
292
  ): TargetResult {
271
293
  const typeCode = block.typeCode;
272
294
 
273
295
  // A dynamic target's chain ends at its payload, so the word there is the declared length.
274
296
  if (isLengthValidType(typeCode)) {
275
- if (!isLengthOp(opCode)) {
276
- throw new CallciumError("OPERATOR_TARGET_MISMATCH", "Value operator on a target without a scalar word");
277
- }
278
- const length = readPointer(callData, target);
279
- if (typeof length !== "number") return { error: length.code };
297
+ const length = readLength(callData, target);
298
+ if (typeof length !== "bigint") return { error: length.code };
280
299
 
281
300
  // The declared payload of `length` items must lie within calldata past the length word.
282
301
  const stride = typeCode === TypeCode.DYNAMIC_ARRAY ? (block.targetMeta & PF.HINT_META_STRIDE_MASK) * 32 : 1;
283
302
  const room = callData.length - (target + 32);
284
- if (stride !== 0 && length > Math.floor(room / stride)) return { error: "CALLDATA_OUT_OF_BOUNDS" };
303
+ if (stride !== 0 && length > BigInt(Math.floor(room / stride))) return { error: "CALLDATA_OUT_OF_BOUNDS" };
285
304
 
286
305
  const passed = applyOperator(opCode, 0n, length, operandData, typeCode);
287
- return { passed, value: BigInt(length) };
306
+ return { passed, value: length };
288
307
  }
289
308
 
290
- if (classifyTypeCode(typeCode).typeClass !== "elementary") {
291
- throw new CallciumError("OPERATOR_TARGET_MISMATCH", "Operator target does not carry a scalar word");
292
- }
293
309
  const word = loadWord(callData, target);
294
310
  if (!(word instanceof Uint8Array)) return { error: word.code };
295
311
 
@@ -298,14 +314,13 @@ function evalTarget(
298
314
  return { error: "NON_CANONICAL_VALUE", value };
299
315
  }
300
316
 
301
- if ((opCode & ~Op.NOT) === Op.EQ_CTX) {
302
- const operand = resolveContextOperand(operandData, context);
303
- if (typeof operand !== "bigint") return { error: "MISSING_CONTEXT", ctxTypeCode: operand.ctxTypeCode };
304
- const passed = (value === operand) !== ((opCode & Op.NOT) !== 0);
305
- return { passed, value, ctxOperand: operand };
317
+ if (ctxOperand !== undefined) {
318
+ if (typeof ctxOperand !== "bigint") return { error: "MISSING_CONTEXT", ctxTypeCode: ctxOperand.ctxTypeCode };
319
+ const passed = (value === ctxOperand) !== ((opCode & Op.NOT) !== 0);
320
+ return { passed, value, ctxOperand };
306
321
  }
307
322
 
308
- const passed = applyOperator(opCode, value, 32, operandData, typeCode);
323
+ const passed = applyOperator(opCode, value, 32n, operandData, typeCode);
309
324
  return { passed, value };
310
325
  }
311
326
 
@@ -323,17 +338,24 @@ type RuleFrame = {
323
338
  typeCode: number;
324
339
  };
325
340
 
326
- /** Build a navigation violation for a read the enforcer cannot perform. */
327
- function navigationViolation(frame: RuleFrame, code: NavigationViolationCode, elementIndex?: number): Violation {
341
+ /** The coordinates, target and operator fields every violation of a calldata rule carries. */
342
+ function ruleFields(frame: RuleFrame) {
328
343
  return {
329
344
  group: frame.group,
330
345
  rule: frame.rule,
331
- code,
332
346
  scope: Scope.CALLDATA,
333
347
  path: frame.path,
334
348
  opCode: frame.opCode,
335
349
  operandData: bytesToHex(frame.operandData),
336
350
  typeCode: frame.typeCode,
351
+ };
352
+ }
353
+
354
+ /** Build a navigation violation for a read the enforcer cannot perform. */
355
+ function navigationViolation(frame: RuleFrame, code: NavigationViolationCode, elementIndex?: number): Violation {
356
+ return {
357
+ code,
358
+ ...ruleFields(frame),
337
359
  ...(elementIndex !== undefined && { elementIndex }),
338
360
  };
339
361
  }
@@ -342,27 +364,17 @@ function navigationViolation(frame: RuleFrame, code: NavigationViolationCode, el
342
364
  function targetViolation(frame: RuleFrame, result: TargetResult, elementIndex?: number): Violation | null {
343
365
  if ("error" in result) {
344
366
  if (result.error === "MISSING_CONTEXT") {
367
+ // The type reported is the absent property's, not the target the operator reads.
345
368
  return {
346
- group: frame.group,
347
- rule: frame.rule,
348
369
  code: result.error,
349
- scope: Scope.CALLDATA,
350
- path: frame.path,
351
- opCode: frame.opCode,
352
- operandData: bytesToHex(frame.operandData),
370
+ ...ruleFields(frame),
353
371
  typeCode: result.ctxTypeCode,
354
372
  };
355
373
  }
356
374
  if (result.error === "NON_CANONICAL_VALUE") {
357
375
  return {
358
- group: frame.group,
359
- rule: frame.rule,
360
376
  code: result.error,
361
- scope: Scope.CALLDATA,
362
- path: frame.path,
363
- opCode: frame.opCode,
364
- operandData: bytesToHex(frame.operandData),
365
- typeCode: frame.typeCode,
377
+ ...ruleFields(frame),
366
378
  resolvedValue: bigintToHex(result.value),
367
379
  ...(elementIndex !== undefined && { elementIndex }),
368
380
  };
@@ -372,14 +384,8 @@ function targetViolation(frame: RuleFrame, result: TargetResult, elementIndex?:
372
384
 
373
385
  if (result.passed) return null;
374
386
  return {
375
- group: frame.group,
376
- rule: frame.rule,
377
387
  code: "VALUE_MISMATCH",
378
- scope: Scope.CALLDATA,
379
- path: frame.path,
380
- opCode: frame.opCode,
381
- operandData: bytesToHex(frame.operandData),
382
- typeCode: frame.typeCode,
388
+ ...ruleFields(frame),
383
389
  resolvedValue: bigintToHex(result.value),
384
390
  ...(result.ctxOperand !== undefined && { resolvedOperand: bigintToHex(result.ctxOperand) }),
385
391
  ...(elementIndex !== undefined && { elementIndex }),
@@ -435,7 +441,14 @@ function evaluateCalldataRule(
435
441
 
436
442
  return targetViolation(
437
443
  frame,
438
- evalTarget(callDataBytes, base + block.targetDelta, block, opCode, operandData, context),
444
+ evalTarget(
445
+ callDataBytes,
446
+ base + block.targetDelta,
447
+ block,
448
+ opCode,
449
+ operandData,
450
+ referenceOperand(opCode, operandData, context),
451
+ ),
439
452
  );
440
453
  }
441
454
 
@@ -480,27 +493,27 @@ function evaluateQuantified(
480
493
  let elems = chained + arrayDelta;
481
494
 
482
495
  // A frame declaring no count spans a dynamic array, whose length word precedes its elements.
483
- let count = readU16(hint, frameOffset + PF.HINT_FRAME_COUNT_OFFSET);
484
- if (count === 0) {
485
- const length = readPointer(callDataBytes, elems);
486
- if (typeof length !== "number") return navigationViolation(frame, length.code);
496
+ let count = BigInt(readU16(hint, frameOffset + PF.HINT_FRAME_COUNT_OFFSET));
497
+ if (count === 0n) {
498
+ const length = readLength(callDataBytes, elems);
499
+ if (typeof length !== "bigint") return navigationViolation(frame, length.code);
487
500
  count = length;
488
501
  elems += 32;
489
502
  }
490
503
 
491
- if (count > PF.MAX_QUANTIFIED_ARRAY_LENGTH) {
504
+ if (count > BigInt(PF.MAX_QUANTIFIED_ARRAY_LENGTH)) {
492
505
  return {
493
506
  group: groupIndex,
494
507
  rule: ruleIndex,
495
508
  code: "QUANTIFIER_LIMIT_EXCEEDED",
496
509
  scope: Scope.CALLDATA,
497
510
  path: pathHex,
498
- resolvedValue: bigintToHex(BigInt(count)),
511
+ resolvedValue: bigintToHex(count),
499
512
  };
500
513
  }
501
514
 
502
515
  const isUniversal = kind === PF.HINT_KIND_ALL;
503
- if (count === 0) {
516
+ if (count === 0n) {
504
517
  if (isUniversal) return null;
505
518
  return {
506
519
  group: groupIndex,
@@ -515,8 +528,9 @@ function evaluateQuantified(
515
528
  const elemStride = (meta & PF.HINT_META_STRIDE_MASK) * 32;
516
529
  const elemIsDynamic = (meta & PF.HINT_META_ELEM_DYNAMIC) !== 0;
517
530
 
518
- // A reference operand resolves identically for every element; any element's result carries it.
519
- let ctxOperand: bigint | undefined;
531
+ // A reference operand resolves identically for every element, so it resolves before iterating.
532
+ const reference = referenceOperand(opCode, operandData, context);
533
+
520
534
  for (let elemIndex = 0; elemIndex < count; elemIndex++) {
521
535
  const slot = elems + elemIndex * elemStride;
522
536
  let elem: number = slot;
@@ -535,7 +549,7 @@ function evaluateQuantified(
535
549
  base = chainedElem;
536
550
  }
537
551
 
538
- const applied = evalTarget(callDataBytes, base + block.targetDelta, block, opCode, operandData, context);
552
+ const applied = evalTarget(callDataBytes, base + block.targetDelta, block, opCode, operandData, reference);
539
553
  if ("error" in applied) {
540
554
  // Error results end the rule whatever the quantifier: a later element cannot rescue
541
555
  // calldata the enforcer cannot read (an abort effect), and a missing context
@@ -549,21 +563,14 @@ function evaluateQuantified(
549
563
  } else if (isUniversal) {
550
564
  return targetViolation(frame, applied, elemIndex);
551
565
  }
552
- ctxOperand = applied.ctxOperand;
553
566
  }
554
567
 
555
568
  if (isUniversal) return null;
556
569
  // Existential (ANY) failure: every element rejected the constraint.
557
570
  return {
558
- group: groupIndex,
559
- rule: ruleIndex,
560
571
  code: "VALUE_MISMATCH",
561
- scope: Scope.CALLDATA,
562
- path: pathHex,
563
- opCode,
564
- operandData: bytesToHex(operandData),
565
- typeCode: block.typeCode,
566
- ...(ctxOperand !== undefined && { resolvedOperand: bigintToHex(ctxOperand) }),
572
+ ...ruleFields(frame),
573
+ ...(typeof reference === "bigint" && { resolvedOperand: bigintToHex(reference) }),
567
574
  };
568
575
  }
569
576
 
@@ -582,34 +589,15 @@ function evaluateContextRule(
582
589
  context?: Context,
583
590
  ): Violation | null {
584
591
  const propertyId = readU16(pathBytes, 0);
585
-
586
- if (propertyId > MAX_CONTEXT_PROPERTY_ID) {
587
- throw new CallciumError(
588
- "UNKNOWN_CONTEXT_PROPERTY",
589
- `Unknown context property ID 0x${propertyId.toString(16).padStart(4, "0")}`,
590
- );
591
- }
592
-
593
592
  const propertyInfo = lookupContextProperty(propertyId);
594
593
  const contextValue = context?.[propertyInfo.contextKey];
594
+ const site = { group: groupIndex, rule: ruleIndex, scope: Scope.CONTEXT, path: pathHex };
595
595
 
596
596
  if (contextValue === undefined) {
597
- return {
598
- group: groupIndex,
599
- rule: ruleIndex,
600
- code: "MISSING_CONTEXT",
601
- scope: Scope.CONTEXT,
602
- path: pathHex,
603
- typeCode: propertyInfo.typeCode,
604
- };
597
+ return { code: "MISSING_CONTEXT", ...site, typeCode: propertyInfo.typeCode };
605
598
  }
606
599
 
607
- let value: bigint;
608
- if (typeof contextValue === "string") {
609
- value = addressToBigInt(contextValue);
610
- } else {
611
- value = contextValue;
612
- }
600
+ const value = contextValueToWord(contextValue);
613
601
 
614
602
  let result: boolean;
615
603
  let ctxOperand: bigint | undefined;
@@ -617,11 +605,8 @@ function evaluateContextRule(
617
605
  const operand = resolveContextOperand(operandData, context);
618
606
  if (typeof operand !== "bigint") {
619
607
  return {
620
- group: groupIndex,
621
- rule: ruleIndex,
622
608
  code: "MISSING_CONTEXT",
623
- scope: Scope.CONTEXT,
624
- path: pathHex,
609
+ ...site,
625
610
  opCode,
626
611
  operandData: bytesToHex(operandData),
627
612
  typeCode: operand.ctxTypeCode,
@@ -630,16 +615,13 @@ function evaluateContextRule(
630
615
  result = (value === operand) !== ((opCode & Op.NOT) !== 0);
631
616
  ctxOperand = operand;
632
617
  } else {
633
- result = applyOperator(opCode, value, 32, operandData, propertyInfo.typeCode);
618
+ result = applyOperator(opCode, value, 32n, operandData, propertyInfo.typeCode);
634
619
  }
635
620
 
636
621
  if (!result) {
637
622
  return {
638
- group: groupIndex,
639
- rule: ruleIndex,
640
623
  code: "VALUE_MISMATCH",
641
- scope: Scope.CONTEXT,
642
- path: pathHex,
624
+ ...site,
643
625
  opCode,
644
626
  operandData: bytesToHex(operandData),
645
627
  typeCode: propertyInfo.typeCode,
@@ -1,5 +1,5 @@
1
1
  import { bigintToHex, bytesToHex, hexToBytes } from "./bytes";
2
- import { PolicyFormat, lookupContextProperty, MAX_CONTEXT_PROPERTY_ID, Op, Scope, TypeCode } from "./constants";
2
+ import { fieldMax, findContextProperty, MAX_CONTEXT_PROPERTY_ID, Op, PolicyFormat, Scope, TypeCode } from "./constants";
3
3
  import { Descriptor, type TypeInfo } from "./descriptor";
4
4
  import { decodeDescriptor } from "./descriptor-coder";
5
5
  import { CallciumError, type CallciumErrorCode } from "./errors";
@@ -9,8 +9,9 @@ import {
9
9
  isSigned,
10
10
  isLengthOp,
11
11
  isLengthValidType,
12
- classifyTypeCode,
12
+ isElementary,
13
13
  isValidOperatorData,
14
+ toOperatorBytes,
14
15
  } from "./operators";
15
16
  import { Quantifier, isQuantifier, parsePathSteps } from "./path";
16
17
  import * as ValidationIssue from "./validation-issue";
@@ -23,7 +24,6 @@ import type { Constraint, Hex, Issue, IssueCode, PolicyData } from "./types";
23
24
 
24
25
  /** Codes a path walk raises when the path does not navigate the descriptor (PV-1). */
25
26
  const navigabilityCodes: ReadonlySet<CallciumErrorCode> = new Set<CallciumErrorCode>([
26
- "EMPTY_PATH",
27
27
  "PARAM_INDEX_OUT_OF_BOUNDS",
28
28
  "TUPLE_FIELD_OUT_OF_BOUNDS",
29
29
  "STATIC_ARRAY_INDEX_OUT_OF_BOUNDS",
@@ -88,8 +88,15 @@ const INT256_SIGN_BIT = 1n << 255n;
88
88
  /** Signed-aware difference of two raw uint256 values: negative if a<b, zero if equal, positive if a>b. */
89
89
  function signedCompare(a: bigint, b: bigint, signed: boolean): bigint {
90
90
  if (!signed) return a - b;
91
- const toS = (v: bigint) => (v >= INT256_SIGN_BIT ? v - (1n << 256n) : v);
92
- return toS(a) - toS(b);
91
+ const toSigned = (word: bigint) => (word >= INT256_SIGN_BIT ? word - (1n << 256n) : word);
92
+ return toSigned(a) - toSigned(b);
93
+ }
94
+
95
+ /** True when a value lies outside the domain's physical bounds. */
96
+ function isOutsideDomain(domain: BoundDomain, value: bigint): boolean {
97
+ return (
98
+ signedCompare(value, domain.min, domain.isSigned) < 0n || signedCompare(value, domain.max, domain.isSigned) > 0n
99
+ );
93
100
  }
94
101
 
95
102
  ///////////////////////////////////////////////////////////////////////////
@@ -162,7 +169,7 @@ function getIncompat(opBase: number, typeInfo: TypeInfo): { code: IssueCode; mes
162
169
  if (isDynamic || staticSize !== 32) {
163
170
  return { code: "VALUE_OP_ON_DYNAMIC", message: "Value operator used on dynamic type" };
164
171
  }
165
- if (classifyTypeCode(typeCode).typeClass !== "elementary") {
172
+ if (!isElementary(typeCode)) {
166
173
  return { code: "VALUE_OP_ON_COMPOSITE", message: "Value operator used on composite type" };
167
174
  }
168
175
  if (isComparisonOp(opBase) && !isNumericType(typeCode)) {
@@ -304,7 +311,8 @@ function initContext(scope: number, path: Hex, steps: number[], typeInfo: TypeIn
304
311
  steps,
305
312
  typeInfo,
306
313
  numeric: emptyBoundDomain(isSigned(typeInfo.typeCode), min, max),
307
- bitmask: { mustBeOne: 0n, mustBeZero: 0n },
314
+ // Bits above the target's width are zero in every canonical value.
315
+ bitmask: { mustBeOne: 0n, mustBeZero: isBitmaskCompatible(typeInfo.typeCode) ? UINT256_MAX ^ max : 0n },
308
316
  length: emptyBoundDomain(false, 0n, LENGTH_MAX),
309
317
  set: {
310
318
  hasIn: false,
@@ -331,6 +339,10 @@ function updateBound(
331
339
  ): void {
332
340
  if (isNegated) {
333
341
  if (base === Op.EQ) {
342
+ // A hole outside the type's domain excludes nothing.
343
+ if (isOutsideDomain(domain, value)) {
344
+ issues.push(ValidationIssue.outOfPhysicalBounds(isLength, groupIndex, constraintIndex, bigintToHex(value)));
345
+ }
334
346
  if (domain.hasEq && domain.eq === value) {
335
347
  issues.push(ValidationIssue.eqNeqContradiction(isLength, groupIndex, constraintIndex, bigintToHex(value)));
336
348
  }
@@ -358,10 +370,7 @@ function updateBound(
358
370
  }
359
371
 
360
372
  // Physical bounds and impossibility.
361
- if (
362
- signedCompare(value, domain.min, domain.isSigned) < 0n ||
363
- signedCompare(value, domain.max, domain.isSigned) > 0n
364
- ) {
373
+ if (isOutsideDomain(domain, value)) {
365
374
  issues.push(ValidationIssue.outOfPhysicalBounds(isLength, groupIndex, constraintIndex, bigintToHex(value)));
366
375
  } else if (base === Op.GT && value === domain.max) {
367
376
  issues.push(ValidationIssue.impossibleGt(isLength, groupIndex, constraintIndex, bigintToHex(value)));
@@ -681,6 +690,13 @@ function updateSet(
681
690
  constraintIndex: number,
682
691
  issues: Issue[],
683
692
  ): void {
693
+ // Physical bounds: a member outside the type's domain never occurs, so it neither matches nor excludes anything.
694
+ for (const value of values) {
695
+ if (isOutsideDomain(ctx.numeric, value)) {
696
+ issues.push(ValidationIssue.outOfPhysicalBounds(false, groupIndex, constraintIndex, bigintToHex(value)));
697
+ }
698
+ }
699
+
684
700
  if (isNegated) {
685
701
  for (const value of values) {
686
702
  if (ctx.numeric.hasEq && ctx.numeric.eq === value) {
@@ -702,16 +718,6 @@ function updateSet(
702
718
  }
703
719
  checkSetEmpty(ctx, groupIndex, constraintIndex, issues);
704
720
  } else {
705
- // Physical bounds: a member outside the type's domain can never be matched.
706
- for (const value of values) {
707
- if (
708
- signedCompare(value, ctx.numeric.min, ctx.numeric.isSigned) < 0n ||
709
- signedCompare(value, ctx.numeric.max, ctx.numeric.isSigned) > 0n
710
- ) {
711
- issues.push(ValidationIssue.outOfPhysicalBounds(false, groupIndex, constraintIndex, bigintToHex(value)));
712
- }
713
- }
714
-
715
721
  if (ctx.numeric.hasEq) {
716
722
  let found = false;
717
723
  for (const candidate of values) {
@@ -817,6 +823,11 @@ function checkFusibleRange(
817
823
  }
818
824
  }
819
825
 
826
+ /** Check whether a payload length fits the `dataLength` field and matches the operator's arity. */
827
+ function hasValidPayload(opBase: number, dataLength: number): boolean {
828
+ return dataLength <= fieldMax(PolicyFormat.RULE_DATALENGTH_SIZE) && isValidOperatorData(opBase, dataLength);
829
+ }
830
+
820
831
  /** Return the operand pair when the operators contain exactly one of each unnegated bound opcode. */
821
832
  function findLonePair(operators: Hex[], lowerOp: number, upperOp: number): { low: bigint; high: bigint } | null {
822
833
  let lowerCount = 0;
@@ -827,7 +838,7 @@ function findLonePair(operators: Hex[], lowerOp: number, upperOp: number): { low
827
838
  for (const opHex of operators) {
828
839
  const opCode = parseInt(opHex.slice(2, 4), 16);
829
840
  const dataLength = (opHex.length - 4) / 2;
830
- if (dataLength > 0xffff || !isValidOperatorData(opCode & ~Op.NOT, dataLength)) continue;
841
+ if (!hasValidPayload(opCode & ~Op.NOT, dataLength)) continue;
831
842
  if (opCode === lowerOp) {
832
843
  lowerCount++;
833
844
  low = readValue(opHex);
@@ -856,12 +867,14 @@ function validateConstraint(
856
867
  const underAny = constraint.scope === Scope.CALLDATA && ctx.steps.includes(Quantifier.ANY);
857
868
 
858
869
  for (const opHex of operators) {
859
- const opCode = parseInt(opHex.slice(2, 4), 16);
870
+ // Every later read slices the hex, so the operator is established as bytes first.
871
+ const opBytes = toOperatorBytes(opHex);
872
+ const opCode = opBytes[0]!;
860
873
  const base = opCode & ~Op.NOT;
861
874
 
862
875
  // An unassigned opcode or mismatched payload size has no defined semantics to analyze.
863
- const dataLength = (opHex.length - 4) / 2;
864
- if (dataLength > 0xffff || !isValidOperatorData(base, dataLength)) {
876
+ const dataLength = opBytes.length - 1;
877
+ if (!hasValidPayload(base, dataLength)) {
865
878
  issues.push(
866
879
  ValidationIssue.fromOpRule(
867
880
  "UNKNOWN_OPERATOR",
@@ -898,7 +911,8 @@ function validateConstraint(
898
911
  if (base === Op.EQ_CTX) {
899
912
  // The operand names a context property, not a value: nothing to fold into a domain.
900
913
  const ctxOperand = readValue(opHex);
901
- if (ctxOperand > BigInt(MAX_CONTEXT_PROPERTY_ID)) {
914
+ const property = findContextProperty(Number(ctxOperand));
915
+ if (!property) {
902
916
  issues.push(
903
917
  ValidationIssue.unknownContextProperty(
904
918
  groupIndex,
@@ -910,7 +924,7 @@ function validateConstraint(
910
924
  } else {
911
925
  // Compatibility has already narrowed the target to an address or unsigned type,
912
926
  // so the pairing reduces to whether both sides are addresses.
913
- const propertyIsAddress = lookupContextProperty(Number(ctxOperand)).typeCode === TypeCode.ADDRESS;
927
+ const propertyIsAddress = property.typeCode === TypeCode.ADDRESS;
914
928
  const targetIsAddress = ctx.typeInfo.typeCode === TypeCode.ADDRESS;
915
929
  if (propertyIsAddress !== targetIsAddress) {
916
930
  issues.push(
@@ -1030,6 +1044,7 @@ function validateGroup(data: PolicyData, descBytes: Uint8Array, groupIndex: numb
1030
1044
 
1031
1045
  if (!ctx) {
1032
1046
  const steps = parsePathSteps(constraint.path);
1047
+ if (steps.length === 0) throw new CallciumError("EMPTY_PATH", "Path must have at least one step");
1033
1048
  let typeInfo: TypeInfo;
1034
1049
  if (constraint.scope === Scope.CALLDATA) {
1035
1050
  // Compatibility warnings against the limits the spec fixes (PWF-17, PV-7).
@@ -1072,7 +1087,8 @@ function validateGroup(data: PolicyData, descBytes: Uint8Array, groupIndex: numb
1072
1087
  typeInfo = walk.typeInfo;
1073
1088
  } else {
1074
1089
  const ctxId = steps[0]!;
1075
- if (ctxId > MAX_CONTEXT_PROPERTY_ID) {
1090
+ const property = findContextProperty(ctxId);
1091
+ if (!property) {
1076
1092
  // An unassigned ID declares no type, so the analysis proceeds over the widest
1077
1093
  // unsigned domain and the warning carries the ID.
1078
1094
  issues.push(
@@ -1083,23 +1099,21 @@ function validateGroup(data: PolicyData, descBytes: Uint8Array, groupIndex: numb
1083
1099
  bigintToHex(BigInt(MAX_CONTEXT_PROPERTY_ID)),
1084
1100
  ),
1085
1101
  );
1086
- typeInfo = { typeCode: TypeCode.UINT_MAX, isDynamic: false, staticSize: 32 };
1087
- } else {
1088
- const typeCode = lookupContextProperty(ctxId).typeCode;
1089
- typeInfo = { typeCode, isDynamic: false, staticSize: 32 };
1090
1102
  }
1103
+ typeInfo = { typeCode: property?.typeCode ?? TypeCode.UINT_MAX, isDynamic: false, staticSize: 32 };
1091
1104
  }
1092
1105
  ctx = initContext(constraint.scope, normalizedPath, steps, typeInfo);
1093
1106
  contexts.push(ctx);
1094
1107
  }
1095
1108
 
1096
1109
  // A carried hint must equal the compilation of its own path; constraints without an encoding
1097
- // carry none, and the encoder compiles theirs from the same descriptor. Reaching here means
1098
- // the path navigates the descriptor and quantifies at most once, so only the depth PWF-17
1099
- // admits remains to be established.
1110
+ // carry none, an empty one carries nothing to compare, and the encoder compiles theirs from
1111
+ // the same descriptor. Reaching here means the path navigates the descriptor and quantifies at
1112
+ // most once, so only the depth PWF-17 admits remains to be established.
1100
1113
  if (
1101
1114
  constraint.scope === Scope.CALLDATA &&
1102
1115
  constraint.hint !== undefined &&
1116
+ constraint.hint !== "0x" &&
1103
1117
  ctx.steps.length <= PolicyFormat.MAX_PATH_DEPTH
1104
1118
  ) {
1105
1119
  const compiled = bytesToHex(Descriptor.compileHint(descBytes, ctx.steps));
@@ -1124,6 +1138,9 @@ function validateGroup(data: PolicyData, descBytes: Uint8Array, groupIndex: numb
1124
1138
  * @returns All validation issues found, ordered by group and constraint index.
1125
1139
  * @throws {CallciumError} If the descriptor is not well-formed; semantic validation is
1126
1140
  * defined only over a well-formed descriptor.
1141
+ * @throws {CallciumError} With code `EMPTY_PATH` when a constraint carries no path step, and
1142
+ * `INVALID_HEX` or `INVALID_OPERATOR_BYTES` when an operator is not a readable encoding. Analysis
1143
+ * is defined over canonical data, so these reject the input rather than report an issue on it.
1127
1144
  */
1128
1145
  function validate(data: PolicyData): Issue[] {
1129
1146
  const descBytes = hexToBytes(data.descriptor);