@callcium/sdk 0.11.0 → 0.12.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,4 +1,5 @@
1
1
  import { keccak_256 } from "@noble/hashes/sha3.js";
2
+ import { concatBytes } from "@noble/hashes/utils.js";
2
3
 
3
4
  import { bytesToHex, hexToBytes, toHex, readU16, readU32, writeBE16, writeBE32 } from "./bytes";
4
5
  import {
@@ -15,6 +16,7 @@ import { Descriptor } from "./descriptor";
15
16
  import { decodeDescriptor } from "./descriptor-coder";
16
17
  import { CallciumError, type CallciumErrorCode } from "./errors";
17
18
  import {
19
+ isContextOp,
18
20
  isLengthOp,
19
21
  isLengthValidType,
20
22
  toBigInt,
@@ -45,9 +47,8 @@ function field<T>(value: T, start: number, end: number): Field<T> {
45
47
  }
46
48
 
47
49
  /**
48
- * Wrap the hex rendering of `data[start, end)` with its span, rendering on first read.
49
- * Enforcement reads the span as bytes, so the string is built only for a caller that asks.
50
- * The field reads and assigns like the plain one it replaces.
50
+ * Wrap the hex rendering of `data[start, end)` with its span, rendering on first read. Enforcement reads the span as
51
+ * bytes, so the string is built only for a caller that asks. The field reads and assigns like a plain one.
51
52
  */
52
53
  function hexField(data: Uint8Array, start: number, end: number): Field<Hex> {
53
54
  let hex: Hex | undefined;
@@ -140,8 +141,8 @@ function validateHint(data: Uint8Array, hintStart: number, hintSize: number, rul
140
141
  targetTypeCode === TypeCode.DYNAMIC_ARRAY ? (targetMeta & PF.HINT_META_RESERVED_MASK) === 0 : targetMeta === 0;
141
142
  if (!targetMetaOk) malformedHint("Target meta carries reserved or unused state", ruleOffset);
142
143
 
143
- // An operator reads either a scalar word or a declared length, so a target carrying neither —
144
- // a tuple, a static array, or an undefined code — addresses nothing.
144
+ // An operator reads either a scalar word or a declared length, so a target carrying neither — a tuple, a static
145
+ // array, or an undefined code — addresses nothing.
145
146
  if (!isAddressableTarget(targetTypeCode)) malformedHint("Target type code addresses no value", ruleOffset);
146
147
  }
147
148
 
@@ -300,8 +301,8 @@ export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Arr
300
301
  throw new CallciumError("UNKNOWN_OPERATOR", "Unrecognized or malformed operator", ruleOffset);
301
302
  }
302
303
 
303
- // A length operator reads the declared length a dynamic target resolves to, and a value
304
- // operator reads a scalar word, so the target type fixes which family the rule may carry.
304
+ // A length operator reads the declared length a dynamic target resolves to, and a value operator reads a scalar
305
+ // word, so the target type fixes which family the rule may carry.
305
306
  if (hintSize !== 0) {
306
307
  const targetTypeCode = hintTargetTypeCode(data, hintStart, hintSize);
307
308
  if (isLengthValidType(targetTypeCode) !== isLengthOp(opBase)) {
@@ -328,12 +329,12 @@ export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Arr
328
329
  }
329
330
  }
330
331
 
331
- // An EQ_CTX operand word must be a defined context property ID.
332
- if (opBase === Op.EQ_CTX) {
332
+ // A context reference operand word must be a defined context property ID.
333
+ if (isContextOp(opBase)) {
333
334
  if (toBigInt(data, dataStart) > BigInt(MAX_CONTEXT_PROPERTY_ID)) {
334
335
  throw new CallciumError(
335
336
  "UNKNOWN_CONTEXT_PROPERTY",
336
- "EQ_CTX operand references an undefined context property",
337
+ "Context reference operand references an undefined context property",
337
338
  ruleOffset,
338
339
  );
339
340
  }
@@ -468,9 +469,8 @@ function readConstraintTarget(constraint: Constraint, desc: Uint8Array): Constra
468
469
 
469
470
  /** Flatten a Constraint into one Rule per operator, compiling its hint against the descriptor. */
470
471
  function flattenConstraint(constraint: Constraint, desc: Uint8Array): Rule[] {
471
- // The rule is the unit these checks run over: each rule's operator is established before the
472
- // path and hint it shares with its siblings. A constraint carrying no operator produces no rule,
473
- // so its path is never read.
472
+ // The rule is the unit these checks run over: each rule's operator is established before the path and hint it shares
473
+ // with its siblings. A constraint carrying no operator produces no rule, so its path is never read.
474
474
  let target: ConstraintTarget | undefined;
475
475
 
476
476
  return constraint.operators.map((operatorHex) => {
@@ -509,8 +509,7 @@ function requireFieldFits(value: number, byteWidth: number, code: CallciumErrorC
509
509
  }
510
510
 
511
511
  /**
512
- * Serialize a single rule to its wire format bytes.
513
- * Path shape and operator framing are established by `flattenConstraint`.
512
+ * Serialize a single rule to its wire format bytes. `flattenConstraint` establishes path shape and operator framing.
514
513
  */
515
514
  function encodeRule(rule: Rule): Uint8Array {
516
515
  const depth = rule.path.length / 2;
@@ -537,15 +536,7 @@ function encodeRule(rule: Rule): Uint8Array {
537
536
 
538
537
  /** Serialize all rules in a group to a single byte array. */
539
538
  function encodeGroupRules(rules: Rule[]): Uint8Array {
540
- const parts = rules.map(encodeRule);
541
- const totalSize = parts.reduce((sum, p) => sum + p.length, 0);
542
- const buf = new Uint8Array(totalSize);
543
- let offset = 0;
544
- for (const part of parts) {
545
- buf.set(part, offset);
546
- offset += part.length;
547
- }
548
- return buf;
539
+ return concatBytes(...rules.map(encodeRule));
549
540
  }
550
541
 
551
542
  ///////////////////////////////////////////////////////////////////////////
@@ -572,8 +563,8 @@ function encode(data: PolicyData): Hex {
572
563
  }
573
564
  requireFieldFits(data.groups.length, PF.GROUP_COUNT_SIZE, "GROUP_COUNT_OVERFLOW", "Group count");
574
565
 
575
- // Flatten constraints into rules and sort within each group. Hints are compiled first because
576
- // they are part of the rule bytes the group hash covers.
566
+ // Flatten constraints into rules and sort within each group. Hints are compiled first because they are part of the
567
+ // rule bytes the group hash covers.
577
568
  const sortedGroups: Rule[][] = data.groups.map((group) => {
578
569
  const rules = group.flatMap((constraint) => flattenConstraint(constraint, descBytes));
579
570
  sortRules(rules);
@@ -590,8 +581,8 @@ function encode(data: PolicyData): Hex {
590
581
  return { wireBytes: encodeGroupRules(rules), ruleCount: rules.length };
591
582
  });
592
583
 
593
- // Several groups are ordered by ascending keccak256 over their rule bytes; a lone group is
594
- // already in canonical position, so it needs no hash.
584
+ // Several groups are ordered by ascending keccak256 over their rule bytes; a lone group is already in canonical
585
+ // position, so it needs no hash.
595
586
  if (encodedGroups.length > 1) {
596
587
  const hashes = new Map(encodedGroups.map((group) => [group, keccak_256(group.wireBytes)]));
597
588
  encodedGroups.sort((a, b) => compareBytes(hashes.get(a)!, hashes.get(b)!));
@@ -648,7 +639,7 @@ function encode(data: PolicyData): Hex {
648
639
  /**
649
640
  * Decode a binary policy blob into a PolicyData structure.
650
641
  * @param blob - Binary policy as 0x-prefixed hex string.
651
- * @returns The decoded policy data with constraints grouped by scope and path.
642
+ * @returns The decoded policy data with constraints grouped by scope, path, and hint.
652
643
  * @throws {CallciumError} If the blob is structurally malformed.
653
644
  */
654
645
  function decode(blob: Hex): PolicyData {
@@ -1,8 +1,8 @@
1
1
  import { hexToBytes, bytesToHex, readU16, readU32, bigintToHex, toAddress } from "./bytes";
2
2
  import { loadWord, readLength, readPointer } from "./calldata-reader";
3
- import { PolicyFormat as PF, Scope, Op, TypeCode, lookupContextProperty } from "./constants";
3
+ import { PolicyFormat as PF, Scope, TypeCode, lookupContextProperty } from "./constants";
4
4
  import { CallciumError, PolicyViolationError } from "./errors";
5
- import { applyOperator, toBigInt, isLengthValidType, canonicalize } from "./operators";
5
+ import { applyOperator, isContextOp, toBigInt, isLengthValidType, canonicalize } from "./operators";
6
6
  import { decodePolicy } from "./policy-coder";
7
7
 
8
8
  import type { ReadResult } from "./calldata-reader";
@@ -32,7 +32,7 @@ const abortViolationCodes: ReadonlySet<ViolationCode> = new Set<ViolationCode>([
32
32
 
33
33
  /**
34
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.
35
+ * @throws {CallciumError} When an address is not 20 bytes of hex or an integer does not fit a 32-byte word.
36
36
  */
37
37
  function contextValueToWord(value: bigint | Address): bigint {
38
38
  if (typeof value === "string") return BigInt(toAddress(value));
@@ -55,8 +55,8 @@ function contextValueToWord(value: bigint | Address): bigint {
55
55
  * @param callData - ABI-encoded call data as 0x-prefixed hex string.
56
56
  * @param context - Optional execution context for context-scoped rules.
57
57
  * @returns Pass with matched group index, or fail with one violation per evaluated failing group.
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.
58
+ * @throws {CallciumError} If the policy blob is structurally malformed, the call data is not valid hex, or a context
59
+ * value a rule reads is not an address or an integer a 32-byte word represents.
60
60
  */
61
61
  function check(policy: Hex, callData: Hex, context?: Context): EnforceResult {
62
62
  const { policy: decoded, data: policyBytes } = decodePolicy(policy);
@@ -123,8 +123,8 @@ function check(policy: Hex, callData: Hex, context?: Context): EnforceResult {
123
123
  * @param callData - ABI-encoded call data as 0x-prefixed hex string.
124
124
  * @param context - Optional execution context for context-scoped rules.
125
125
  * @throws {PolicyViolationError} If the policy rejects the call data.
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.
126
+ * @throws {CallciumError} If the policy blob is structurally malformed, the call data is not valid hex, or a context
127
+ * value a rule reads is not an address or an integer a 32-byte word represents.
128
128
  */
129
129
  function enforce(policy: Hex, callData: Hex, context?: Context): void {
130
130
  const result = check(policy, callData, context);
@@ -178,12 +178,12 @@ function evaluateRule(
178
178
  );
179
179
  }
180
180
 
181
- /** An `EQ_CTX` operand resolved to its word, or the missing property's type code. */
181
+ /** A context reference operand resolved to its word, or the missing property's type code. */
182
182
  type ReferenceOperand = bigint | { ctxTypeCode: number };
183
183
 
184
184
  /**
185
- * Resolve an EQ_CTX operand word to the referenced context property's value as a raw word.
186
- * Returns the missing property's type code when the context does not supply it.
185
+ * Resolve a context reference operand word to the referenced context property's value as a raw word. Returns the
186
+ * missing property's type code when the context does not supply it.
187
187
  */
188
188
  function resolveContextOperand(operandData: Uint8Array, context?: Context): ReferenceOperand {
189
189
  const propertyInfo = lookupContextProperty(Number(toBigInt(operandData, 0)));
@@ -192,9 +192,9 @@ function resolveContextOperand(operandData: Uint8Array, context?: Context): Refe
192
192
  return contextValueToWord(value);
193
193
  }
194
194
 
195
- /** Resolve the reference operand of an `EQ_CTX` rule; every other operator reads a literal. */
195
+ /** Resolve the reference operand of a context reference rule; every other operator reads a literal. */
196
196
  function referenceOperand(opCode: number, operandData: Uint8Array, context?: Context): ReferenceOperand | undefined {
197
- if ((opCode & ~Op.NOT) !== Op.EQ_CTX) return undefined;
197
+ if (!isContextOp(opCode)) return undefined;
198
198
  return resolveContextOperand(operandData, context);
199
199
  }
200
200
 
@@ -222,6 +222,9 @@ function readTargetBlock(hint: Uint8Array, targetOffset: number): TargetBlock {
222
222
  function follow(callData: Uint8Array, from: number, at: number): ReadResult<number> {
223
223
  const word = readPointer(callData, at);
224
224
  if (typeof word !== "number") return word;
225
+ if (from > callData.length || word > callData.length - from) {
226
+ return { code: "CALLDATA_OUT_OF_BOUNDS" };
227
+ }
225
228
  return from + word;
226
229
  }
227
230
 
@@ -278,9 +281,8 @@ type TargetResult =
278
281
  /**
279
282
  * Load the value at a resolved target offset and apply the operator.
280
283
  *
281
- * `value` is normalised at the decision point: length operators yield the byte/element
282
- * count; scalar operators preserve the full 32-byte ABI word, keeping left-aligned `bytesN`
283
- * decodable. A passing target allocates no string.
284
+ * `value` is normalised at the decision point: length operators yield the byte/element count; scalar operators preserve
285
+ * the full 32-byte ABI word, keeping left-aligned `bytesN` decodable. A passing target allocates no string.
284
286
  */
285
287
  function evalTarget(
286
288
  callData: Uint8Array,
@@ -316,7 +318,7 @@ function evalTarget(
316
318
 
317
319
  if (ctxOperand !== undefined) {
318
320
  if (typeof ctxOperand !== "bigint") return { error: "MISSING_CONTEXT", ctxTypeCode: ctxOperand.ctxTypeCode };
319
- const passed = (value === ctxOperand) !== ((opCode & Op.NOT) !== 0);
321
+ const passed = applyOperator(opCode, value, 32n, hexToBytes(bigintToHex(ctxOperand)), typeCode);
320
322
  return { passed, value, ctxOperand };
321
323
  }
322
324
 
@@ -536,8 +538,8 @@ function evaluateQuantified(
536
538
  let elem: number = slot;
537
539
  if (elemIsDynamic) {
538
540
  const followed = follow(callDataBytes, elems, slot);
539
- // Navigation failures abort under every quantifier: calldata the enforcer cannot read is
540
- // not an element that merely fails the operator.
541
+ // Navigation failures abort under every quantifier: calldata the enforcer cannot read is not an element that
542
+ // merely fails the operator.
541
543
  if (typeof followed !== "number") return navigationViolation(frame, followed.code, elemIndex);
542
544
  elem = followed;
543
545
  }
@@ -551,9 +553,8 @@ function evaluateQuantified(
551
553
 
552
554
  const applied = evalTarget(callDataBytes, base + block.targetDelta, block, opCode, operandData, reference);
553
555
  if ("error" in applied) {
554
- // Error results end the rule whatever the quantifier: a later element cannot rescue
555
- // calldata the enforcer cannot read (an abort effect), and a missing context
556
- // property is missing for every element alike (group-local).
556
+ // Error results end the rule whatever the quantifier: a later element cannot rescue calldata the enforcer cannot
557
+ // read (an abort effect), and a missing context property is missing for every element alike (group-local).
557
558
  return targetViolation(frame, applied, elemIndex);
558
559
  }
559
560
 
@@ -601,7 +602,7 @@ function evaluateContextRule(
601
602
 
602
603
  let result: boolean;
603
604
  let ctxOperand: bigint | undefined;
604
- if ((opCode & ~Op.NOT) === Op.EQ_CTX) {
605
+ if (isContextOp(opCode)) {
605
606
  const operand = resolveContextOperand(operandData, context);
606
607
  if (typeof operand !== "bigint") {
607
608
  return {
@@ -612,7 +613,7 @@ function evaluateContextRule(
612
613
  typeCode: operand.ctxTypeCode,
613
614
  };
614
615
  }
615
- result = (value === operand) !== ((opCode & Op.NOT) !== 0);
616
+ result = applyOperator(opCode, value, 32n, hexToBytes(bigintToHex(operand)), propertyInfo.typeCode);
616
617
  ctxOperand = operand;
617
618
  } else {
618
619
  result = applyOperator(opCode, value, 32n, operandData, propertyInfo.typeCode);
@@ -1,5 +1,14 @@
1
1
  import { bigintToHex, bytesToHex, hexToBytes } from "./bytes";
2
- import { fieldMax, findContextProperty, MAX_CONTEXT_PROPERTY_ID, Op, PolicyFormat, Scope, TypeCode } from "./constants";
2
+ import {
3
+ fieldMax,
4
+ findContextProperty,
5
+ MAX_CONTEXT_PROPERTY_ID,
6
+ Op,
7
+ operandsOf,
8
+ PolicyFormat,
9
+ Scope,
10
+ TypeCode,
11
+ } from "./constants";
3
12
  import { Descriptor, type TypeInfo } from "./descriptor";
4
13
  import { decodeDescriptor } from "./descriptor-coder";
5
14
  import { CallciumError, type CallciumErrorCode } from "./errors";
@@ -7,6 +16,7 @@ import {
7
16
  canonicalize,
8
17
  isLeftAligned,
9
18
  isSigned,
19
+ isContextOp,
10
20
  isLengthOp,
11
21
  isLengthValidType,
12
22
  isElementary,
@@ -143,7 +153,7 @@ function getDomainLimits(typeCode: number): { min: bigint; max: bigint } {
143
153
  // Operator classification
144
154
  ///////////////////////////////////////////////////////////////////////////
145
155
 
146
- /** True for EQ, GT, LT, GTE, LTE, BETWEEN, IN, EQ_CTX, BITMASK_*. */
156
+ /** True if an operator code is in the value-operator range (unassigned codes included). */
147
157
  function isValueOp(opBase: number): boolean {
148
158
  return opBase <= Op.BITMASK_NONE;
149
159
  }
@@ -163,7 +173,7 @@ function isBitmaskOp(opBase: number): boolean {
163
173
  ///////////////////////////////////////////////////////////////////////////
164
174
 
165
175
  /** Return the incompatibility reason, or null if the operator is allowed. */
166
- function getIncompat(opBase: number, typeInfo: TypeInfo): { code: IssueCode; message: string } | null {
176
+ function checkCompatibility(opBase: number, typeInfo: TypeInfo): { code: IssueCode; message: string } | null {
167
177
  const { typeCode, isDynamic, staticSize } = typeInfo;
168
178
  if (isValueOp(opBase)) {
169
179
  if (isDynamic || staticSize !== 32) {
@@ -178,15 +188,16 @@ function getIncompat(opBase: number, typeInfo: TypeInfo): { code: IssueCode; mes
178
188
  if (isBitmaskOp(opBase) && !isBitmaskCompatible(typeCode)) {
179
189
  return { code: "BITMASK_ON_INVALID", message: "Bitmask operator used on incompatible type" };
180
190
  }
181
- // IN on bool is degenerate: the two-value domain reduces every set to an equality, a tautology, or dead members.
191
+ // IN on bool is degenerate: two values reduce every set to an equality, a tautology, or dead members.
182
192
  if (opBase === Op.IN && typeCode === TypeCode.BOOL) {
183
193
  return { code: "IN_ON_BOOL", message: "IN operator used on boolean type" };
184
194
  }
185
- // Context properties are addresses or uint256, so only those target classes can match.
195
+ // Context properties are addresses or uint256, so only those target classes can match, and an address is an
196
+ // identity rather than a magnitude, so it pairs under equality only.
186
197
  if (
187
- opBase === Op.EQ_CTX &&
188
- typeCode !== TypeCode.ADDRESS &&
189
- !(typeCode >= TypeCode.UINT_MIN && typeCode <= TypeCode.UINT_MAX)
198
+ isContextOp(opBase) &&
199
+ !(typeCode >= TypeCode.UINT_MIN && typeCode <= TypeCode.UINT_MAX) &&
200
+ !(opBase === Op.EQ_CTX && typeCode === TypeCode.ADDRESS)
190
201
  ) {
191
202
  return { code: "CONTEXT_TYPE_MISMATCH", message: "Context reference operator used on incompatible type" };
192
203
  }
@@ -202,11 +213,12 @@ function getIncompat(opBase: number, typeInfo: TypeInfo): { code: IssueCode; mes
202
213
  }
203
214
 
204
215
  /**
205
- * True if operator `opCode` is allowed on a target with the given type info.
206
- * Tolerates the NOT flag on `opCode`.
216
+ * True if operator `opCode` is a defined operator allowed on a target with the given type info. Tolerates the NOT flag
217
+ * on `opCode`.
207
218
  */
208
219
  export function isOpAllowed(opCode: number, typeInfo: TypeInfo): boolean {
209
- return getIncompat(opCode & ~Op.NOT, typeInfo) === null;
220
+ const opBase = opCode & ~Op.NOT;
221
+ return operandsOf(opBase) !== undefined && checkCompatibility(opBase, typeInfo) === null;
210
222
  }
211
223
 
212
224
  ///////////////////////////////////////////////////////////////////////////
@@ -690,7 +702,7 @@ function updateSet(
690
702
  constraintIndex: number,
691
703
  issues: Issue[],
692
704
  ): void {
693
- // Physical bounds: a member outside the type's domain never occurs, so it neither matches nor excludes anything.
705
+ // Physical bounds: a member outside the type's domain never occurs, so it matches and excludes nothing.
694
706
  for (const value of values) {
695
707
  if (isOutsideDomain(ctx.numeric, value)) {
696
708
  issues.push(ValidationIssue.outOfPhysicalBounds(false, groupIndex, constraintIndex, bigintToHex(value)));
@@ -781,9 +793,9 @@ function checkDuplicates(issues: Issue[], operators: Hex[], groupIndex: number,
781
793
  ///////////////////////////////////////////////////////////////////////////
782
794
 
783
795
  /**
784
- * Report a lone unnegated gte/lte pair (value or length domain) as fusible into the
785
- * corresponding single range operator. Fires only for a satisfiable pair on a compatible
786
- * type; contradictions and dominated bounds are already reported by the domain checks.
796
+ * Report a lone unnegated gte/lte pair (value or length domain) as fusible into the corresponding single range
797
+ * operator. Fires only for a satisfiable pair on a compatible type; contradictions and dominated bounds are already
798
+ * reported by the domain checks.
787
799
  */
788
800
  function checkFusibleRange(
789
801
  ctx: ConstraintContext,
@@ -795,7 +807,7 @@ function checkFusibleRange(
795
807
  const numericPair = findLonePair(operators, Op.GTE, Op.LTE);
796
808
  if (
797
809
  numericPair &&
798
- getIncompat(Op.GTE, ctx.typeInfo) === null &&
810
+ checkCompatibility(Op.GTE, ctx.typeInfo) === null &&
799
811
  signedCompare(numericPair.low, numericPair.high, ctx.numeric.isSigned) <= 0n
800
812
  ) {
801
813
  issues.push(
@@ -810,7 +822,7 @@ function checkFusibleRange(
810
822
  }
811
823
 
812
824
  const lengthPair = findLonePair(operators, Op.LENGTH_GTE, Op.LENGTH_LTE);
813
- if (lengthPair && getIncompat(Op.LENGTH_GTE, ctx.typeInfo) === null && lengthPair.low <= lengthPair.high) {
825
+ if (lengthPair && checkCompatibility(Op.LENGTH_GTE, ctx.typeInfo) === null && lengthPair.low <= lengthPair.high) {
814
826
  issues.push(
815
827
  ValidationIssue.fusibleRange(
816
828
  true,
@@ -894,12 +906,12 @@ function validateConstraint(
894
906
  issues.push(ValidationIssue.negationUnderAny(groupIndex, constraintIndex, bigintToHex(BigInt(opCode))));
895
907
  }
896
908
 
897
- const incompat = getIncompat(base, ctx.typeInfo);
898
- if (incompat) {
909
+ const incompatibility = checkCompatibility(base, ctx.typeInfo);
910
+ if (incompatibility) {
899
911
  issues.push(
900
912
  ValidationIssue.fromOpRule(
901
- incompat.code,
902
- incompat.message,
913
+ incompatibility.code,
914
+ incompatibility.message,
903
915
  groupIndex,
904
916
  constraintIndex,
905
917
  bigintToHex(BigInt(opCode)),
@@ -908,7 +920,7 @@ function validateConstraint(
908
920
  continue;
909
921
  }
910
922
 
911
- if (base === Op.EQ_CTX) {
923
+ if (isContextOp(base)) {
912
924
  // The operand names a context property, not a value: nothing to fold into a domain.
913
925
  const ctxOperand = readValue(opHex);
914
926
  const property = findContextProperty(Number(ctxOperand));
@@ -922,8 +934,8 @@ function validateConstraint(
922
934
  ),
923
935
  );
924
936
  } else {
925
- // Compatibility has already narrowed the target to an address or unsigned type,
926
- // so the pairing reduces to whether both sides are addresses.
937
+ // Compatibility has already narrowed the target to an address or unsigned type, so the pairing reduces to
938
+ // whether both sides are addresses.
927
939
  const propertyIsAddress = property.typeCode === TypeCode.ADDRESS;
928
940
  const targetIsAddress = ctx.typeInfo.typeCode === TypeCode.ADDRESS;
929
941
  if (propertyIsAddress !== targetIsAddress) {
@@ -938,10 +950,8 @@ function validateConstraint(
938
950
  }
939
951
  }
940
952
  } else if (isValueOp(base)) {
941
- // A non-canonical operand can never equal a canonicalized runtime value (PC-1, spec
942
- // section 4.5), so the rule is unsatisfiable or vacuous; skip analyzing the garbage
943
- // word. Scoped to the left-aligned types: numeric, address, and bool operands are
944
- // already covered by the physical domain limits.
953
+ // An operand that breaks PC-1 never matches, since enforcement only admits canonical values, so the rule is
954
+ // unsatisfiable or vacuous; skip it. Numeric, address and bool operands are already covered by the domain limits.
945
955
  const typeCode = ctx.typeInfo.typeCode;
946
956
  if (isLeftAligned(typeCode)) {
947
957
  const nonCanonical = findNonCanonicalWord(opHex, typeCode);
@@ -960,10 +970,9 @@ function validateConstraint(
960
970
 
961
971
  if (base >= Op.EQ && base <= Op.BETWEEN) {
962
972
  if (base === Op.BETWEEN) {
963
- // A negated range is a disjunction (value < low OR value > high); distributing the
964
- // negation over the decomposed bounds would conjoin the complement halves into a
965
- // spurious contradiction. Leave it un-analyzed, like a neq hole. Inverted bounds
966
- // still surface: negating an empty range excludes nothing.
973
+ // A negated range is a disjunction (value < low OR value > high); distributing the negation over the
974
+ // decomposed bounds would conjoin the complement halves into a spurious contradiction. Leave it un-analyzed,
975
+ // like a neq hole. Inverted bounds still surface: negating an empty range excludes nothing.
967
976
  const { low, high } = readPair(opHex);
968
977
  if (!isNegated) {
969
978
  updateBound(ctx.numeric, Op.GTE, false, low, false, groupIndex, constraintIndex, issues);
@@ -1047,7 +1056,7 @@ function validateGroup(data: PolicyData, descBytes: Uint8Array, groupIndex: numb
1047
1056
  if (steps.length === 0) throw new CallciumError("EMPTY_PATH", "Path must have at least one step");
1048
1057
  let typeInfo: TypeInfo;
1049
1058
  if (constraint.scope === Scope.CALLDATA) {
1050
- // Compatibility warnings against the limits the spec fixes (PWF-17, PV-7).
1059
+ // Compatibility issues against the limits the spec fixes (PWF-17, PV-7).
1051
1060
  if (steps.length > PolicyFormat.MAX_PATH_DEPTH) {
1052
1061
  issues.push(
1053
1062
  ValidationIssue.pathDepthExceeded(
@@ -1089,8 +1098,8 @@ function validateGroup(data: PolicyData, descBytes: Uint8Array, groupIndex: numb
1089
1098
  const ctxId = steps[0]!;
1090
1099
  const property = findContextProperty(ctxId);
1091
1100
  if (!property) {
1092
- // An unassigned ID declares no type, so the analysis proceeds over the widest
1093
- // unsigned domain and the warning carries the ID.
1101
+ // An unassigned ID declares no type, so the analysis proceeds over the widest unsigned domain and the warning
1102
+ // carries the ID.
1094
1103
  issues.push(
1095
1104
  ValidationIssue.unknownContextProperty(
1096
1105
  groupIndex,
@@ -1106,10 +1115,9 @@ function validateGroup(data: PolicyData, descBytes: Uint8Array, groupIndex: numb
1106
1115
  contexts.push(ctx);
1107
1116
  }
1108
1117
 
1109
- // A carried hint must equal the compilation of its own path; constraints without an encoding
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.
1118
+ // A carried hint must equal the compilation of its own path; constraints without an encoding carry none, an empty
1119
+ // one carries nothing to compare, and the encoder compiles theirs from the same descriptor. Reaching here means the
1120
+ // path navigates the descriptor and quantifies at most once, so only the PWF-17 depth check remains.
1113
1121
  if (
1114
1122
  constraint.scope === Scope.CALLDATA &&
1115
1123
  constraint.hint !== undefined &&
@@ -1136,11 +1144,10 @@ function validateGroup(data: PolicyData, descBytes: Uint8Array, groupIndex: numb
1136
1144
  * Validate a policy for type mismatches, contradictions, redundancies, and vacuities.
1137
1145
  * @param data - The canonical policy data to validate.
1138
1146
  * @returns All validation issues found, ordered by group and constraint index.
1139
- * @throws {CallciumError} If the descriptor is not well-formed; semantic validation is
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.
1147
+ * @throws {CallciumError} If the descriptor is malformed; semantic validation is defined only over a well-formed one.
1148
+ * @throws {CallciumError} With `EMPTY_PATH` or `MALFORMED_PATH` for a path that is empty or not whole steps, and
1149
+ * `INVALID_HEX` or `INVALID_OPERATOR_BYTES` for an unreadable path or operator. Analysis is defined over canonical
1150
+ * data, so these reject the input rather than report an issue.
1144
1151
  */
1145
1152
  function validate(data: PolicyData): Issue[] {
1146
1153
  const descBytes = hexToBytes(data.descriptor);
package/src/signature.ts CHANGED
@@ -31,14 +31,14 @@ function isAlphanum(char: number): boolean {
31
31
  /**
32
32
  * Parse an ABI function signature into its selector and types.
33
33
  *
34
- * Strict mode: no whitespace, name must match `[A-Za-z_][A-Za-z0-9_]*`,
35
- * structure must be `name(types)` with the final `)` as the last character.
34
+ * Strict mode: no space, tab, LF or CR; name must match `[A-Za-z_][A-Za-z0-9_]*`; structure must be `name(types)` with
35
+ * the final `)` as the last character.
36
36
  *
37
37
  * @param signature - A function signature string, e.g. `"transfer(address,uint256)"`.
38
38
  * @returns The 4-byte selector and the comma-separated types string.
39
39
  * @throws {CallciumError} with code `"MALFORMED_SIGNATURE"`, `"SIGNATURE_CONTAINS_WHITESPACE"`, or
40
- * `"INVALID_FUNCTION_NAME"` if the signature is malformed. The types between the parentheses are
41
- * returned unread; whether they name types is for the type parser to say.
40
+ * `"INVALID_FUNCTION_NAME"` if the signature is malformed. The types between the parentheses are returned unread;
41
+ * whether they name types is for the type parser to say.
42
42
  */
43
43
  function parse(signature: string): ParsedSignature {
44
44
  if (signature.length < 3) {
@@ -72,8 +72,8 @@ function parse(signature: string): ParsedSignature {
72
72
  throw new CallciumError("INVALID_FUNCTION_NAME", "Function name must not be empty");
73
73
  }
74
74
 
75
- // Validate function name: [A-Za-z_][A-Za-z0-9_]*. Every character outside ASCII carries a code
76
- // unit above the alphanumeric ranges, so the same test rejects it.
75
+ // Validate function name: [A-Za-z_][A-Za-z0-9_]*. Every character outside ASCII carries a code unit above the
76
+ // alphanumeric ranges, so the same test rejects it.
77
77
  const firstChar = signature.charCodeAt(0);
78
78
  if (!isAlpha(firstChar) && firstChar !== 0x5f) {
79
79
  throw new CallciumError("INVALID_FUNCTION_NAME", "Function name must start with a letter or underscore");
package/src/type-desc.ts CHANGED
@@ -9,10 +9,8 @@ import { CallciumError } from "./errors";
9
9
  /**
10
10
  * Extract the staticWords count from a descriptor node.
11
11
  *
12
- * For elementary types (typeCode < STATIC_ARRAY), returns 1 unless the type is
13
- * BYTES or STRING, which are ABI-dynamic and return 0. For composite types,
14
- * reads the 24-bit meta field at offset 1 and shifts right by
15
- * META_STATIC_WORDS_SHIFT.
12
+ * For elementary types (typeCode < STATIC_ARRAY), returns 1 unless the type is BYTES or STRING, which are ABI-dynamic
13
+ * and return 0. For composite types, returns the 24-bit meta at offset 1 right-shifted by META_STATIC_WORDS_SHIFT.
16
14
  */
17
15
  function extractStaticWords(desc: Uint8Array): number {
18
16
  const typeCode = desc[0]!;
@@ -99,7 +97,8 @@ export function bytesN(length: number): Uint8Array {
99
97
  *
100
98
  * @param elemDesc - Descriptor bytes for the element type.
101
99
  * @param length - Fixed array length. When omitted, produces a dynamic array.
102
- * @throws {CallciumError} If length is 0 or exceeds MAX_STATIC_ARRAY_LENGTH.
100
+ * @throws {CallciumError} If length is 0 or above MAX_STATIC_ARRAY_LENGTH.
101
+ * @throws {CallciumError} If the node exceeds MAX_NODE_LENGTH or MAX_STATIC_WORDS.
103
102
  */
104
103
  export function array(elemDesc: Uint8Array, length?: number): Uint8Array {
105
104
  if (length === undefined) {
@@ -156,7 +155,8 @@ export function array(elemDesc: Uint8Array, length?: number): Uint8Array {
156
155
  * Format: `[0x90][meta:3][fieldCount:be16][field0][field1]...`
157
156
  *
158
157
  * @param fieldDescs - Descriptor bytes for each field, in order.
159
- * @throws {CallciumError} If fieldDescs is empty, exceeds MAX_TUPLE_FIELDS, or nodeLength overflows MAX_NODE_LENGTH.
158
+ * @throws {CallciumError} If fieldDescs is empty or above MAX_TUPLE_FIELDS.
159
+ * @throws {CallciumError} If the node exceeds MAX_NODE_LENGTH or MAX_STATIC_WORDS.
160
160
  */
161
161
  export function tuple(fieldDescs: Uint8Array[]): Uint8Array {
162
162
  if (fieldDescs.length === 0 || fieldDescs.length > DF.MAX_TUPLE_FIELDS) {
package/src/types.ts CHANGED
@@ -16,9 +16,8 @@ export type Span = { start: number; end: number };
16
16
  ///////////////////////////////////////////////////////////////////////////
17
17
 
18
18
  /**
19
- * Execution context for context-scoped rules.
20
- * Each property maps to a well-known EVM execution environment value.
21
- * Only the properties referenced by the policy need to be supplied.
19
+ * Execution context for context-scoped rules. Each property maps to a well-known EVM execution environment value. Only
20
+ * the properties referenced by the policy need to be supplied.
22
21
  */
23
22
  export type Context = {
24
23
  msgSender?: Address;
@@ -52,8 +51,8 @@ export type SelectorMismatchViolation = {
52
51
  };
53
52
 
54
53
  /**
55
- * A rule referenced a context property not supplied in the execution context — a context-scoped
56
- * subject, or an `EQ_CTX` operand on either scope.
54
+ * A rule referenced a context property not supplied in the execution context — a context-scoped subject, or a context
55
+ * reference operand on either scope.
57
56
  */
58
57
  export type MissingContextViolation = {
59
58
  code: "MISSING_CONTEXT";
@@ -76,10 +75,8 @@ export type MissingContextViolation = {
76
75
  * - `resolvedValue` present, `elementIndex` absent — scalar leaf or context value that failed the operator.
77
76
  * - `resolvedValue` present, `elementIndex` present — universal-quantifier per-element failure.
78
77
  * - `resolvedValue` absent, `elementIndex` absent — existential-aggregate failure (no element satisfied).
79
- * - `resolvedValue` absent, `elementIndex` present — per-element failure where the leaf could not be loaded.
80
78
  *
81
- * For length operations (`isLengthOp(opCode)`), `resolvedValue` is a hex-encoded count
82
- * rather than a 32-byte ABI word.
79
+ * For length operators, `resolvedValue` is the length as a 32-byte word.
83
80
  */
84
81
  export type ValueMismatchViolation = {
85
82
  code: "VALUE_MISMATCH";
@@ -95,8 +92,8 @@ export type ValueMismatchViolation = {
95
92
  typeCode: number;
96
93
  resolvedValue?: Hex;
97
94
  /**
98
- * Resolved runtime value of a reference operand as a 32-byte word; present when the
99
- * operator's operand names a reference resolved at enforcement, not a literal.
95
+ * Resolved runtime value of a reference operand as a 32-byte word; present when the operator's operand names a
96
+ * reference resolved at enforcement, not a literal.
100
97
  */
101
98
  resolvedOperand?: Hex;
102
99
  elementIndex?: number;
@@ -121,8 +118,8 @@ type CalldataNavigationVariant<C extends NavigationViolationCode> = {
121
118
  /**
122
119
  * Calldata structure prevented the rule from being evaluated.
123
120
  *
124
- * The operator was never applied; `opCode`, `operandData`, `typeCode`, and `elementIndex`
125
- * are diagnostic context describing the failing site, not a constraint claim.
121
+ * The operator was never applied; `opCode`, `operandData`, `typeCode`, and `elementIndex` are diagnostic context
122
+ * describing the failing site, not a constraint claim.
126
123
  *
127
124
  * A union of per-code variants, narrowable with `Extract<Violation, { code: "..." }>`.
128
125
  */
@@ -173,8 +170,7 @@ export type QuantifierEmptyArrayViolation = {
173
170
  /**
174
171
  * Structured details of a single rule failure during enforcement.
175
172
  *
176
- * Carries semantic data only; no message strings.
177
- * Discriminate on `code` to narrow to the matching variant.
173
+ * Carries semantic data only; no message strings. Discriminate on `code` to narrow to the matching variant.
178
174
  */
179
175
  export type Violation =
180
176
  | MissingSelectorViolation
@@ -277,8 +273,7 @@ export type IssueCategory = "typeMismatch" | "contradiction" | "redundancy" | "v
277
273
  /**
278
274
  * Machine-readable code for a policy validation issue.
279
275
  *
280
- * Each code names one finding of the semantic validator and matches the constant of the same
281
- * name in the Solidity `IssueCode` library.
276
+ * Each code names one finding of the semantic validator, matching the same-named Solidity `IssueCode` constant.
282
277
  */
283
278
  export type IssueCode =
284
279
  // Type mismatch.