@callcium/sdk 0.9.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,21 +1,13 @@
1
- import { hexToBytes, bytesToHex, readU16, readU32, bigintToHex } from "./bytes";
2
- import { loadWord, readPointer } from "./calldata-reader";
3
- import {
4
- PolicyFormat as PF,
5
- Scope,
6
- MAX_CONTEXT_PROPERTY_ID,
7
- Limits,
8
- Op,
9
- TypeCode,
10
- classifyTypeCode,
11
- lookupContextProperty,
12
- } from "./constants";
1
+ import { hexToBytes, bytesToHex, readU16, readU32, bigintToHex, toAddress } from "./bytes";
2
+ import { loadWord, readLength, readPointer } from "./calldata-reader";
3
+ import { PolicyFormat as PF, Scope, Op, TypeCode, lookupContextProperty } from "./constants";
13
4
  import { CallciumError, PolicyViolationError } from "./errors";
14
- import { applyOperator, toBigInt, isLengthOp, isLengthValidType, canonicalize } from "./operators";
5
+ import { applyOperator, toBigInt, isLengthValidType, canonicalize } from "./operators";
15
6
  import { decodePolicy } from "./policy-coder";
16
7
 
17
8
  import type { ReadResult } from "./calldata-reader";
18
9
  import type {
10
+ Address,
19
11
  Context,
20
12
  DecodedRule,
21
13
  EnforceResult,
@@ -30,20 +22,27 @@ import type {
30
22
  // Helpers
31
23
  ///////////////////////////////////////////////////////////////////////////
32
24
 
33
- /** Violations that abort evaluation instead of failing only their group (spec §9.3). */
34
- const ABORT_VIOLATION_CODES: ReadonlySet<ViolationCode> = new Set<ViolationCode>([
25
+ /** Violations that abort evaluation instead of failing only their group. */
26
+ const abortViolationCodes: ReadonlySet<ViolationCode> = new Set<ViolationCode>([
35
27
  "CALLDATA_OUT_OF_BOUNDS",
36
28
  "ARRAY_INDEX_OUT_OF_BOUNDS",
37
29
  "NON_CANONICAL_VALUE",
38
30
  "QUANTIFIER_LIMIT_EXCEEDED",
39
31
  ]);
40
32
 
41
- /** Convert a hex address to a 256-bit bigint (zero-padded to 32 bytes). */
42
- function addressToBigInt(hex: string): bigint {
43
- const bytes = hexToBytes(hex);
44
- const padded = new Uint8Array(32);
45
- padded.set(bytes, 32 - bytes.length);
46
- return toBigInt(padded, 0);
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;
47
46
  }
48
47
 
49
48
  ///////////////////////////////////////////////////////////////////////////
@@ -56,7 +55,8 @@ function addressToBigInt(hex: string): bigint {
56
55
  * @param callData - ABI-encoded call data as 0x-prefixed hex string.
57
56
  * @param context - Optional execution context for context-scoped rules.
58
57
  * @returns Pass with matched group index, or fail with one violation per evaluated failing group.
59
- * @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.
60
60
  */
61
61
  function check(policy: Hex, callData: Hex, context?: Context): EnforceResult {
62
62
  const { policy: decoded, data: policyBytes } = decodePolicy(policy);
@@ -100,8 +100,8 @@ function check(policy: Hex, callData: Hex, context?: Context): EnforceResult {
100
100
  const violation = evaluateRule(rule, policyBytes, callDataBytes, baseOffset, groupIndex, ruleIndex, context);
101
101
  if (violation !== null) {
102
102
  allViolations.push(violation);
103
- if (ABORT_VIOLATION_CODES.has(violation.code)) {
104
- // Abort violations reject the policy outright; later groups are not consulted (spec §9.3).
103
+ if (abortViolationCodes.has(violation.code)) {
104
+ // Abort violations reject the policy outright; later groups are not consulted.
105
105
  return { ok: false, violations: allViolations };
106
106
  }
107
107
  groupFailed = true;
@@ -123,7 +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.
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.
127
128
  */
128
129
  function enforce(policy: Hex, callData: Hex, context?: Context): void {
129
130
  const result = check(policy, callData, context);
@@ -177,15 +178,24 @@ function evaluateRule(
177
178
  );
178
179
  }
179
180
 
181
+ /** An `EQ_CTX` operand resolved to its word, or the missing property's type code. */
182
+ type ReferenceOperand = bigint | { ctxTypeCode: number };
183
+
180
184
  /**
181
185
  * Resolve an EQ_CTX operand word to the referenced context property's value as a raw word.
182
186
  * Returns the missing property's type code when the context does not supply it.
183
187
  */
184
- function resolveContextOperand(operandData: Uint8Array, context?: Context): bigint | { ctxTypeCode: number } {
185
- const propInfo = lookupContextProperty(Number(toBigInt(operandData, 0)));
186
- const value = context?.[propInfo.contextKey];
187
- if (value === undefined) return { ctxTypeCode: propInfo.typeCode };
188
- return typeof value === "string" ? addressToBigInt(value) : value;
188
+ function resolveContextOperand(operandData: Uint8Array, context?: Context): ReferenceOperand {
189
+ const propertyInfo = lookupContextProperty(Number(toBigInt(operandData, 0)));
190
+ const value = context?.[propertyInfo.contextKey];
191
+ if (value === undefined) return { ctxTypeCode: propertyInfo.typeCode };
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);
189
199
  }
190
200
 
191
201
  ///////////////////////////////////////////////////////////////////////////
@@ -237,8 +247,8 @@ function chainResolve(
237
247
  const meta = readU16(hint, hop + PF.HINT_HOP_META_OFFSET);
238
248
  let elems = base;
239
249
  if ((meta & PF.HINT_META_DYNAMIC_ARRAY) !== 0) {
240
- const length = readPointer(callData, elems);
241
- if (typeof length !== "number") return length;
250
+ const length = readLength(callData, elems);
251
+ if (typeof length !== "bigint") return length;
242
252
  if (index >= length) return { code: "ARRAY_INDEX_OUT_OF_BOUNDS" };
243
253
  elems += 32;
244
254
  }
@@ -278,30 +288,24 @@ function evalTarget(
278
288
  block: TargetBlock,
279
289
  opCode: number,
280
290
  operandData: Uint8Array,
281
- context?: Context,
291
+ ctxOperand?: ReferenceOperand,
282
292
  ): TargetResult {
283
293
  const typeCode = block.typeCode;
284
294
 
285
295
  // A dynamic target's chain ends at its payload, so the word there is the declared length.
286
296
  if (isLengthValidType(typeCode)) {
287
- if (!isLengthOp(opCode)) {
288
- throw new CallciumError("OPERATOR_TARGET_MISMATCH", "Value operator on a target without a scalar word.");
289
- }
290
- const length = readPointer(callData, target);
291
- if (typeof length !== "number") return { error: length.code };
297
+ const length = readLength(callData, target);
298
+ if (typeof length !== "bigint") return { error: length.code };
292
299
 
293
300
  // The declared payload of `length` items must lie within calldata past the length word.
294
301
  const stride = typeCode === TypeCode.DYNAMIC_ARRAY ? (block.targetMeta & PF.HINT_META_STRIDE_MASK) * 32 : 1;
295
302
  const room = callData.length - (target + 32);
296
- 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" };
297
304
 
298
305
  const passed = applyOperator(opCode, 0n, length, operandData, typeCode);
299
- return { passed, value: BigInt(length) };
306
+ return { passed, value: length };
300
307
  }
301
308
 
302
- if (classifyTypeCode(typeCode).typeClass !== "elementary") {
303
- throw new CallciumError("OPERATOR_TARGET_MISMATCH", "Operator target does not carry a scalar word.");
304
- }
305
309
  const word = loadWord(callData, target);
306
310
  if (!(word instanceof Uint8Array)) return { error: word.code };
307
311
 
@@ -310,14 +314,13 @@ function evalTarget(
310
314
  return { error: "NON_CANONICAL_VALUE", value };
311
315
  }
312
316
 
313
- if ((opCode & ~Op.NOT) === Op.EQ_CTX) {
314
- const operand = resolveContextOperand(operandData, context);
315
- if (typeof operand !== "bigint") return { error: "MISSING_CONTEXT", ctxTypeCode: operand.ctxTypeCode };
316
- const passed = (value === operand) !== ((opCode & Op.NOT) !== 0);
317
- 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 };
318
321
  }
319
322
 
320
- const passed = applyOperator(opCode, value, 32, operandData, typeCode);
323
+ const passed = applyOperator(opCode, value, 32n, operandData, typeCode);
321
324
  return { passed, value };
322
325
  }
323
326
 
@@ -335,17 +338,24 @@ type RuleFrame = {
335
338
  typeCode: number;
336
339
  };
337
340
 
338
- /** Build a navigation violation for a read the enforcer cannot perform. */
339
- 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) {
340
343
  return {
341
344
  group: frame.group,
342
345
  rule: frame.rule,
343
- code,
344
346
  scope: Scope.CALLDATA,
345
347
  path: frame.path,
346
348
  opCode: frame.opCode,
347
349
  operandData: bytesToHex(frame.operandData),
348
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),
349
359
  ...(elementIndex !== undefined && { elementIndex }),
350
360
  };
351
361
  }
@@ -354,27 +364,17 @@ function navigationViolation(frame: RuleFrame, code: NavigationViolationCode, el
354
364
  function targetViolation(frame: RuleFrame, result: TargetResult, elementIndex?: number): Violation | null {
355
365
  if ("error" in result) {
356
366
  if (result.error === "MISSING_CONTEXT") {
367
+ // The type reported is the absent property's, not the target the operator reads.
357
368
  return {
358
- group: frame.group,
359
- rule: frame.rule,
360
369
  code: result.error,
361
- scope: Scope.CALLDATA,
362
- path: frame.path,
363
- opCode: frame.opCode,
364
- operandData: bytesToHex(frame.operandData),
370
+ ...ruleFields(frame),
365
371
  typeCode: result.ctxTypeCode,
366
372
  };
367
373
  }
368
374
  if (result.error === "NON_CANONICAL_VALUE") {
369
375
  return {
370
- group: frame.group,
371
- rule: frame.rule,
372
376
  code: result.error,
373
- scope: Scope.CALLDATA,
374
- path: frame.path,
375
- opCode: frame.opCode,
376
- operandData: bytesToHex(frame.operandData),
377
- typeCode: frame.typeCode,
377
+ ...ruleFields(frame),
378
378
  resolvedValue: bigintToHex(result.value),
379
379
  ...(elementIndex !== undefined && { elementIndex }),
380
380
  };
@@ -384,14 +384,8 @@ function targetViolation(frame: RuleFrame, result: TargetResult, elementIndex?:
384
384
 
385
385
  if (result.passed) return null;
386
386
  return {
387
- group: frame.group,
388
- rule: frame.rule,
389
387
  code: "VALUE_MISMATCH",
390
- scope: Scope.CALLDATA,
391
- path: frame.path,
392
- opCode: frame.opCode,
393
- operandData: bytesToHex(frame.operandData),
394
- typeCode: frame.typeCode,
388
+ ...ruleFields(frame),
395
389
  resolvedValue: bigintToHex(result.value),
396
390
  ...(result.ctxOperand !== undefined && { resolvedOperand: bigintToHex(result.ctxOperand) }),
397
391
  ...(elementIndex !== undefined && { elementIndex }),
@@ -447,7 +441,14 @@ function evaluateCalldataRule(
447
441
 
448
442
  return targetViolation(
449
443
  frame,
450
- 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
+ ),
451
452
  );
452
453
  }
453
454
 
@@ -492,27 +493,27 @@ function evaluateQuantified(
492
493
  let elems = chained + arrayDelta;
493
494
 
494
495
  // A frame declaring no count spans a dynamic array, whose length word precedes its elements.
495
- let count = readU16(hint, frameOffset + PF.HINT_FRAME_COUNT_OFFSET);
496
- if (count === 0) {
497
- const length = readPointer(callDataBytes, elems);
498
- 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);
499
500
  count = length;
500
501
  elems += 32;
501
502
  }
502
503
 
503
- if (count > Limits.MAX_QUANTIFIED_ARRAY_LENGTH) {
504
+ if (count > BigInt(PF.MAX_QUANTIFIED_ARRAY_LENGTH)) {
504
505
  return {
505
506
  group: groupIndex,
506
507
  rule: ruleIndex,
507
508
  code: "QUANTIFIER_LIMIT_EXCEEDED",
508
509
  scope: Scope.CALLDATA,
509
510
  path: pathHex,
510
- resolvedValue: bigintToHex(BigInt(count)),
511
+ resolvedValue: bigintToHex(count),
511
512
  };
512
513
  }
513
514
 
514
515
  const isUniversal = kind === PF.HINT_KIND_ALL;
515
- if (count === 0) {
516
+ if (count === 0n) {
516
517
  if (isUniversal) return null;
517
518
  return {
518
519
  group: groupIndex,
@@ -527,15 +528,16 @@ function evaluateQuantified(
527
528
  const elemStride = (meta & PF.HINT_META_STRIDE_MASK) * 32;
528
529
  const elemIsDynamic = (meta & PF.HINT_META_ELEM_DYNAMIC) !== 0;
529
530
 
530
- // A reference operand resolves identically for every element; any element's result carries it.
531
- 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
+
532
534
  for (let elemIndex = 0; elemIndex < count; elemIndex++) {
533
535
  const slot = elems + elemIndex * elemStride;
534
536
  let elem: number = slot;
535
537
  if (elemIsDynamic) {
536
538
  const followed = follow(callDataBytes, elems, slot);
537
539
  // Navigation failures abort under every quantifier: calldata the enforcer cannot read is
538
- // not an element that merely fails the operator (spec §9.3).
540
+ // not an element that merely fails the operator.
539
541
  if (typeof followed !== "number") return navigationViolation(frame, followed.code, elemIndex);
540
542
  elem = followed;
541
543
  }
@@ -547,10 +549,10 @@ function evaluateQuantified(
547
549
  base = chainedElem;
548
550
  }
549
551
 
550
- const applied = evalTarget(callDataBytes, base + block.targetDelta, block, opCode, operandData, context);
552
+ const applied = evalTarget(callDataBytes, base + block.targetDelta, block, opCode, operandData, reference);
551
553
  if ("error" in applied) {
552
554
  // Error results end the rule whatever the quantifier: a later element cannot rescue
553
- // calldata the enforcer cannot read (abort effects, spec §9.3), and a missing context
555
+ // calldata the enforcer cannot read (an abort effect), and a missing context
554
556
  // property is missing for every element alike (group-local).
555
557
  return targetViolation(frame, applied, elemIndex);
556
558
  }
@@ -561,21 +563,14 @@ function evaluateQuantified(
561
563
  } else if (isUniversal) {
562
564
  return targetViolation(frame, applied, elemIndex);
563
565
  }
564
- ctxOperand = applied.ctxOperand;
565
566
  }
566
567
 
567
568
  if (isUniversal) return null;
568
569
  // Existential (ANY) failure: every element rejected the constraint.
569
570
  return {
570
- group: groupIndex,
571
- rule: ruleIndex,
572
571
  code: "VALUE_MISMATCH",
573
- scope: Scope.CALLDATA,
574
- path: pathHex,
575
- opCode,
576
- operandData: bytesToHex(operandData),
577
- typeCode: block.typeCode,
578
- ...(ctxOperand !== undefined && { resolvedOperand: bigintToHex(ctxOperand) }),
572
+ ...ruleFields(frame),
573
+ ...(typeof reference === "bigint" && { resolvedOperand: bigintToHex(reference) }),
579
574
  };
580
575
  }
581
576
 
@@ -594,34 +589,15 @@ function evaluateContextRule(
594
589
  context?: Context,
595
590
  ): Violation | null {
596
591
  const propertyId = readU16(pathBytes, 0);
597
-
598
- if (propertyId > MAX_CONTEXT_PROPERTY_ID) {
599
- throw new CallciumError(
600
- "UNKNOWN_CONTEXT_PROPERTY",
601
- `Unknown context property ID 0x${propertyId.toString(16).padStart(4, "0")}`,
602
- );
603
- }
604
-
605
- const propInfo = lookupContextProperty(propertyId);
606
- const contextValue = context?.[propInfo.contextKey];
592
+ const propertyInfo = lookupContextProperty(propertyId);
593
+ const contextValue = context?.[propertyInfo.contextKey];
594
+ const site = { group: groupIndex, rule: ruleIndex, scope: Scope.CONTEXT, path: pathHex };
607
595
 
608
596
  if (contextValue === undefined) {
609
- return {
610
- group: groupIndex,
611
- rule: ruleIndex,
612
- code: "MISSING_CONTEXT",
613
- scope: Scope.CONTEXT,
614
- path: pathHex,
615
- typeCode: propInfo.typeCode,
616
- };
597
+ return { code: "MISSING_CONTEXT", ...site, typeCode: propertyInfo.typeCode };
617
598
  }
618
599
 
619
- let value: bigint;
620
- if (typeof contextValue === "string") {
621
- value = addressToBigInt(contextValue);
622
- } else {
623
- value = contextValue;
624
- }
600
+ const value = contextValueToWord(contextValue);
625
601
 
626
602
  let result: boolean;
627
603
  let ctxOperand: bigint | undefined;
@@ -629,11 +605,8 @@ function evaluateContextRule(
629
605
  const operand = resolveContextOperand(operandData, context);
630
606
  if (typeof operand !== "bigint") {
631
607
  return {
632
- group: groupIndex,
633
- rule: ruleIndex,
634
608
  code: "MISSING_CONTEXT",
635
- scope: Scope.CONTEXT,
636
- path: pathHex,
609
+ ...site,
637
610
  opCode,
638
611
  operandData: bytesToHex(operandData),
639
612
  typeCode: operand.ctxTypeCode,
@@ -642,19 +615,16 @@ function evaluateContextRule(
642
615
  result = (value === operand) !== ((opCode & Op.NOT) !== 0);
643
616
  ctxOperand = operand;
644
617
  } else {
645
- result = applyOperator(opCode, value, 32, operandData, propInfo.typeCode);
618
+ result = applyOperator(opCode, value, 32n, operandData, propertyInfo.typeCode);
646
619
  }
647
620
 
648
621
  if (!result) {
649
622
  return {
650
- group: groupIndex,
651
- rule: ruleIndex,
652
623
  code: "VALUE_MISMATCH",
653
- scope: Scope.CONTEXT,
654
- path: pathHex,
624
+ ...site,
655
625
  opCode,
656
626
  operandData: bytesToHex(operandData),
657
- typeCode: propInfo.typeCode,
627
+ typeCode: propertyInfo.typeCode,
658
628
  resolvedValue: bigintToHex(value),
659
629
  ...(ctxOperand !== undefined && { resolvedOperand: bigintToHex(ctxOperand) }),
660
630
  };