@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,10 +1,12 @@
1
1
  import { bytesToHex } from "./bytes";
2
- import { Scope, TypeCode, isQuantifier, MAX_CONTEXT_PROPERTY_ID } from "./constants";
3
- import { ConstraintBuilder } from "./constraint";
2
+ import { Scope, TypeCode, lookupContextProperty } from "./constants";
3
+ import { readOperandExtremes } from "./constraint";
4
4
  import { Descriptor } from "./descriptor";
5
5
  import { DescriptorCoder } from "./descriptor-coder";
6
6
  import { CallciumError, ValidationError } from "./errors";
7
- import { PolicyCoder, parsePathSteps } from "./policy-coder";
7
+ import { isSigned } from "./operators";
8
+ import { isQuantifier, parsePathSteps } from "./path";
9
+ import { PolicyCoder } from "./policy-coder";
8
10
  import { PolicyValidator } from "./policy-validator";
9
11
  import { SignatureParser } from "./signature";
10
12
 
@@ -19,35 +21,30 @@ type PolicyDraft = {
19
21
  selector: Hex;
20
22
  descriptor: Uint8Array;
21
23
  groups: Constraint[][];
22
- pathHashes: Set<string>[];
24
+ /** Scope-qualified paths already taken in the group being built. */
25
+ groupPathKeys: Set<string>;
23
26
  };
24
27
 
25
28
  ///////////////////////////////////////////////////////////////////////////
26
29
  // Path validation
27
30
  ///////////////////////////////////////////////////////////////////////////
28
31
 
29
- /** Validate a context-scope path. */
30
- function validateContextPath(steps: number[]): void {
32
+ /** Validate a context-scope path and return the referenced property's type code. */
33
+ function validateContextPath(steps: number[]): number {
31
34
  if (steps.length !== 1) {
32
- throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope path must be exactly one step.");
33
- }
34
- const step = steps[0]!;
35
- if (step > MAX_CONTEXT_PROPERTY_ID) {
36
- throw new CallciumError(
37
- "UNKNOWN_CONTEXT_PROPERTY",
38
- `Unknown context property ID 0x${step.toString(16).padStart(4, "0")}.`,
39
- );
35
+ throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope path must be exactly one step");
40
36
  }
37
+ return lookupContextProperty(steps[0]!).typeCode;
41
38
  }
42
39
 
43
- /** Validate a calldata-scope path against the descriptor. */
44
- function validateCalldataPath(steps: number[], desc: Uint8Array): void {
40
+ /** Validate a calldata-scope path against the descriptor and return the target's type code. */
41
+ function validateCalldataPath(steps: number[], desc: Uint8Array): number {
45
42
  const argIndex = steps[0]!;
46
43
  const paramCount = Descriptor.paramCount(desc);
47
44
  if (argIndex >= paramCount) {
48
45
  throw new CallciumError(
49
46
  "PARAM_INDEX_OUT_OF_BOUNDS",
50
- `Param index ${argIndex} out of range (descriptor has ${paramCount} params).`,
47
+ `Param index ${argIndex} out of range (descriptor has ${paramCount} params)`,
51
48
  );
52
49
  }
53
50
 
@@ -61,10 +58,10 @@ function validateCalldataPath(steps: number[], desc: Uint8Array): void {
61
58
 
62
59
  if (isQuantifier(step)) {
63
60
  if (!isArray) {
64
- throw new CallciumError("QUANTIFIER_ON_NON_ARRAY", "Quantifier step is only valid on an array node.");
61
+ throw new CallciumError("QUANTIFIER_ON_NON_ARRAY", "Quantifier step is only valid on an array node");
65
62
  }
66
63
  if (hasQuantifier) {
67
- throw new CallciumError("NESTED_QUANTIFIER", "Nested quantifiers are not allowed.");
64
+ throw new CallciumError("NESTED_QUANTIFIER", "Nested quantifiers are not allowed");
68
65
  }
69
66
  hasQuantifier = true;
70
67
  }
@@ -74,7 +71,7 @@ function validateCalldataPath(steps: number[], desc: Uint8Array): void {
74
71
  if (step >= fieldCount) {
75
72
  throw new CallciumError(
76
73
  "TUPLE_FIELD_OUT_OF_BOUNDS",
77
- `Tuple field index ${step} out of range (tuple has ${fieldCount} fields).`,
74
+ `Tuple field index ${step} out of range (tuple has ${fieldCount} fields)`,
78
75
  );
79
76
  }
80
77
  offset = Descriptor.tupleFieldOffset(desc, offset, step);
@@ -84,15 +81,72 @@ function validateCalldataPath(steps: number[], desc: Uint8Array): void {
84
81
  if (step >= arrayLength) {
85
82
  throw new CallciumError(
86
83
  "STATIC_ARRAY_INDEX_OUT_OF_BOUNDS",
87
- `Array index ${step} out of range (static array has ${arrayLength} elements).`,
84
+ `Array index ${step} out of range (static array has ${arrayLength} elements)`,
88
85
  );
89
86
  }
90
87
  }
91
88
  offset = Descriptor.arrayElementOffset(offset);
92
89
  } else {
93
- throw new CallciumError("NOT_COMPOSITE", "Cannot descend into an elementary type.");
90
+ throw new CallciumError("NOT_COMPOSITE", "Cannot descend into an elementary type");
94
91
  }
95
92
  }
93
+
94
+ return Descriptor.inspect(desc, offset).typeCode;
95
+ }
96
+
97
+ ///////////////////////////////////////////////////////////////////////////
98
+ // Operand domain
99
+ ///////////////////////////////////////////////////////////////////////////
100
+
101
+ // Values a 32-byte word represents, which is the distance between an operand and its alias.
102
+ const WORD_VALUES = 1n << 256n;
103
+
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
+ /** Reject an operand outside the target's range whose word carries a value inside it. */
119
+ function reject(written: bigint, folded: bigint): never {
120
+ throw new CallciumError(
121
+ "OUT_OF_PHYSICAL_BOUNDS",
122
+ `Operand ${written} is outside the physical range of the type and encodes as ${folded}`,
123
+ );
124
+ }
125
+
126
+ /**
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.
132
+ */
133
+ function checkOperandDomain(constraint: Constraint, typeCode: number): void {
134
+ const bounds = targetBounds(typeCode);
135
+ const extremes = readOperandExtremes(constraint);
136
+ if (bounds === null || extremes === undefined) return;
137
+
138
+ // A negative operand occupies the word its unsigned alias does, which only the target holding
139
+ // the whole word can hold too.
140
+ if (extremes.leastNegative < 0n && typeCode === TypeCode.UINT_MAX) {
141
+ reject(extremes.leastNegative, extremes.leastNegative + WORD_VALUES);
142
+ }
143
+
144
+ 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) {
148
+ reject(extremes.greatest, extremes.greatest - WORD_VALUES);
149
+ }
96
150
  }
97
151
 
98
152
  ///////////////////////////////////////////////////////////////////////////
@@ -119,7 +173,7 @@ export class PolicyBuilder {
119
173
  selector: parsed.selector,
120
174
  descriptor,
121
175
  groups: [[]],
122
- pathHashes: [new Set()],
176
+ groupPathKeys: new Set(),
123
177
  });
124
178
  }
125
179
 
@@ -134,47 +188,54 @@ export class PolicyBuilder {
134
188
  selector: "0x00000000",
135
189
  descriptor,
136
190
  groups: [[]],
137
- pathHashes: [new Set()],
191
+ groupPathKeys: new Set(),
138
192
  });
139
193
  }
140
194
 
141
195
  /**
142
196
  * Add a constraint to the current group.
143
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}.
144
201
  */
145
- add(constraint: Constraint | ConstraintBuilder): this {
146
- const c: Constraint = {
202
+ add(constraint: Constraint): this {
203
+ // A builder compiles no hint; one arrives only on a constraint that came already encoded.
204
+ const added: Constraint = {
147
205
  scope: constraint.scope,
148
206
  path: constraint.path,
149
207
  operators: [...constraint.operators],
208
+ ...("hint" in constraint && constraint.hint !== undefined && { hint: constraint.hint }),
150
209
  };
151
210
 
152
- if (c.operators.length === 0) {
153
- throw new CallciumError("NO_CONSTRAINT_OPERATORS", "Constraint must have at least one operator.");
211
+ if (added.operators.length === 0) {
212
+ throw new CallciumError("NO_CONSTRAINT_OPERATORS", "Constraint must have at least one operator");
154
213
  }
155
214
 
156
215
  // Path shape is established before the scope decides how to navigate it.
157
- const steps = parsePathSteps(c.path);
216
+ const steps = parsePathSteps(added.path);
158
217
  if (steps.length === 0) {
159
- throw new CallciumError("EMPTY_PATH", "Path must have at least one step.");
218
+ throw new CallciumError("EMPTY_PATH", "Path must have at least one step");
160
219
  }
161
220
 
162
- if (c.scope === Scope.CONTEXT) {
163
- validateContextPath(steps);
164
- } else if (c.scope === Scope.CALLDATA) {
165
- validateCalldataPath(steps, this.draft.descriptor);
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);
166
226
  } else {
167
- throw new CallciumError("INVALID_SCOPE", `Unknown scope value ${c.scope}.`);
227
+ throw new CallciumError("INVALID_SCOPE", `Unknown scope value ${added.scope}`);
168
228
  }
169
229
 
170
- const key = `${c.scope}:${c.path.toLowerCase()}`;
171
- const currentHashes = this.draft.pathHashes[this.draft.pathHashes.length - 1]!;
172
- if (currentHashes.has(key)) {
173
- throw new CallciumError("DUPLICATE_PATH_IN_GROUP", `Duplicate path ${c.path} in the same group.`);
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`);
174
235
  }
175
236
 
176
- currentHashes.add(key);
177
- this.draft.groups[this.draft.groups.length - 1]!.push(c);
237
+ this.draft.groupPathKeys.add(key);
238
+ this.draft.groups[this.draft.groups.length - 1]!.push(added);
178
239
  return this;
179
240
  }
180
241
 
@@ -182,10 +243,10 @@ export class PolicyBuilder {
182
243
  or(): this {
183
244
  const lastGroup = this.draft.groups[this.draft.groups.length - 1]!;
184
245
  if (lastGroup.length === 0) {
185
- throw new CallciumError("EMPTY_GROUP", "Cannot start a new group when the current group is empty.");
246
+ throw new CallciumError("EMPTY_GROUP", "Cannot start a new group when the current group is empty");
186
247
  }
187
248
  this.draft.groups.push([]);
188
- this.draft.pathHashes.push(new Set());
249
+ this.draft.groupPathKeys.clear();
189
250
  return this;
190
251
  }
191
252
 
@@ -196,6 +257,8 @@ export class PolicyBuilder {
196
257
  * @returns The policy as a 0x-prefixed hex string.
197
258
  * @throws {CallciumError} If any group is empty.
198
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.
199
262
  */
200
263
  build(): Hex {
201
264
  this.checkGroups();
@@ -233,7 +296,7 @@ export class PolicyBuilder {
233
296
  private checkGroups(): void {
234
297
  for (let i = 0; i < this.draft.groups.length; i++) {
235
298
  if (this.draft.groups[i]!.length === 0) {
236
- throw new CallciumError("EMPTY_GROUP", `Group ${i} is empty.`);
299
+ throw new CallciumError("EMPTY_GROUP", `Group ${i} is empty`);
237
300
  }
238
301
  }
239
302
  }
@@ -3,19 +3,26 @@ import { keccak_256 } from "@noble/hashes/sha3.js";
3
3
  import { bytesToHex, hexToBytes, toHex, readU16, readU32, writeBE16, writeBE32 } from "./bytes";
4
4
  import {
5
5
  DescriptorFormat as DF,
6
+ fieldMax,
7
+ formatCode,
8
+ MAX_CONTEXT_PROPERTY_ID,
9
+ Op,
6
10
  PolicyFormat as PF,
7
11
  Scope,
8
- Limits,
9
- Op,
10
12
  TypeCode,
11
- isValidOperatorData,
12
- isAddressableTarget,
13
- MAX_CONTEXT_PROPERTY_ID,
14
13
  } from "./constants";
15
14
  import { Descriptor } from "./descriptor";
16
15
  import { decodeDescriptor } from "./descriptor-coder";
17
- import { CallciumError } from "./errors";
18
- import { isLengthOp, isLengthValidType, toBigInt } from "./operators";
16
+ import { CallciumError, type CallciumErrorCode } from "./errors";
17
+ import {
18
+ isLengthOp,
19
+ isLengthValidType,
20
+ toBigInt,
21
+ isAddressableTarget,
22
+ isValidOperatorData,
23
+ toOperatorBytes,
24
+ } from "./operators";
25
+ import { parsePathSteps } from "./path";
19
26
 
20
27
  import type {
21
28
  Constraint,
@@ -28,27 +35,6 @@ import type {
28
35
  PolicyData,
29
36
  } from "./types";
30
37
 
31
- ///////////////////////////////////////////////////////////////////////////
32
- // Path helper
33
- ///////////////////////////////////////////////////////////////////////////
34
-
35
- /**
36
- * Parse a BE16-encoded hex path into an array of step values.
37
- * @param path - 0x-prefixed hex string containing BE16-encoded path steps.
38
- * @returns Array of numeric step values.
39
- */
40
- export function parsePathSteps(path: Hex): number[] {
41
- const bytes = hexToBytes(path);
42
- if (bytes.length % PF.PATH_STEP_SIZE !== 0) {
43
- throw new CallciumError("MALFORMED_PATH", `Path byte length ${bytes.length} is not a whole number of steps`);
44
- }
45
- const steps: number[] = [];
46
- for (let offset = 0; offset < bytes.length; offset += PF.PATH_STEP_SIZE) {
47
- steps.push(readU16(bytes, offset));
48
- }
49
- return steps;
50
- }
51
-
52
38
  ///////////////////////////////////////////////////////////////////////////
53
39
  // Policy field helper
54
40
  ///////////////////////////////////////////////////////////////////////////
@@ -58,6 +44,24 @@ function field<T>(value: T, start: number, end: number): Field<T> {
58
44
  return { value, span: { start, end } };
59
45
  }
60
46
 
47
+ /**
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.
51
+ */
52
+ function hexField(data: Uint8Array, start: number, end: number): Field<Hex> {
53
+ let hex: Hex | undefined;
54
+ return {
55
+ get value(): Hex {
56
+ return (hex ??= toHex(data, start, end));
57
+ },
58
+ set value(replacement: Hex) {
59
+ hex = replacement;
60
+ },
61
+ span: { start, end },
62
+ };
63
+ }
64
+
61
65
  ///////////////////////////////////////////////////////////////////////////
62
66
  // Hint block
63
67
  ///////////////////////////////////////////////////////////////////////////
@@ -153,7 +157,7 @@ function hintTargetTypeCode(data: Uint8Array, hintStart: number, hintSize: numbe
153
157
  /** Decode a policy blob, returning the structural representation and the blob bytes it spans. */
154
158
  export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Array } {
155
159
  const data = hexToBytes(blob);
156
- if (data.length < PF.DESC_OFFSET + 1) {
160
+ if (data.length < PF.DESC_OFFSET) {
157
161
  throw new CallciumError("MALFORMED_HEADER", "Policy blob is too short");
158
162
  }
159
163
 
@@ -199,7 +203,8 @@ export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Arr
199
203
  end: descStart + param.span.end,
200
204
  },
201
205
  }));
202
- const descriptorRaw = toHex(data, descStart, descEnd);
206
+
207
+ const descriptorRaw = hexField(data, descStart, descEnd);
203
208
 
204
209
  const groupCountStart = groupCountOffset;
205
210
  const groupCountEnd = groupCountStart + PF.GROUP_COUNT_SIZE;
@@ -221,6 +226,10 @@ export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Arr
221
226
  const groupBodyStart = offset + PF.GROUP_HEADER_SIZE;
222
227
  const groupEnd = groupBodyStart + groupSizeValue;
223
228
 
229
+ if (groupEnd > data.length) {
230
+ throw new CallciumError("GROUP_OVERFLOW", "Group extends beyond policy blob", offset);
231
+ }
232
+
224
233
  const ruleCountStart = offset;
225
234
  const ruleCountValue = readU16(data, ruleCountStart);
226
235
  if (ruleCountValue === 0) {
@@ -229,9 +238,6 @@ export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Arr
229
238
  if (groupSizeValue < ruleCountValue * PF.RULE_MIN_SIZE) {
230
239
  throw new CallciumError("GROUP_TOO_SMALL", "Declared group size is too small for its rule count", offset);
231
240
  }
232
- if (groupEnd > data.length) {
233
- throw new CallciumError("GROUP_OVERFLOW", "Group extends beyond policy blob", offset);
234
- }
235
241
 
236
242
  const rules: DecodedRule[] = [];
237
243
  let ruleOffset = groupBodyStart;
@@ -249,8 +255,8 @@ export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Arr
249
255
  ruleOffset,
250
256
  );
251
257
  }
252
- if (ruleOffset + ruleSizeValue > groupEnd) {
253
- throw new CallciumError("RULE_OVERFLOW", "Rule extends beyond group boundary", ruleOffset);
258
+ if (ruleOffset + ruleSizeValue > data.length) {
259
+ throw new CallciumError("RULE_OVERFLOW", "Rule extends beyond policy blob", ruleOffset);
254
260
  }
255
261
 
256
262
  const scopeOffset = ruleOffset + PF.RULE_SCOPE_OFFSET;
@@ -312,12 +318,13 @@ export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Arr
312
318
  // IN operands must be strictly ascending (unsigned); strictness also rejects duplicates.
313
319
  if (opBase === Op.IN) {
314
320
  const wordCount = dataLengthValue / 32;
321
+ let prev = toBigInt(data, dataStart);
315
322
  for (let word = 1; word < wordCount; word++) {
316
- const prev = data.subarray(dataStart + (word - 1) * 32, dataStart + word * 32);
317
- const cur = data.subarray(dataStart + word * 32, dataStart + (word + 1) * 32);
318
- if (compareBytes(prev, cur) >= 0) {
323
+ const cur = toBigInt(data, dataStart + word * 32);
324
+ if (cur <= prev) {
319
325
  throw new CallciumError("UNSORTED_IN_SET", "IN operands must be strictly ascending", ruleOffset);
320
326
  }
327
+ prev = cur;
321
328
  }
322
329
  }
323
330
 
@@ -336,10 +343,10 @@ export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Arr
336
343
  if (depthValue === 0) {
337
344
  throw new CallciumError("EMPTY_PATH", "Rule path must have at least one step", ruleOffset);
338
345
  }
339
- if (depthValue > Limits.MAX_PATH_DEPTH) {
346
+ if (depthValue > PF.MAX_PATH_DEPTH) {
340
347
  throw new CallciumError(
341
348
  "PATH_TOO_DEEP",
342
- `Path depth ${depthValue} exceeds maximum ${Limits.MAX_PATH_DEPTH}`,
349
+ `Path depth ${depthValue} exceeds maximum ${PF.MAX_PATH_DEPTH}`,
343
350
  ruleOffset,
344
351
  );
345
352
  }
@@ -357,7 +364,7 @@ export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Arr
357
364
  if (contextPropertyId > MAX_CONTEXT_PROPERTY_ID) {
358
365
  throw new CallciumError(
359
366
  "UNKNOWN_CONTEXT_PROPERTY",
360
- `Context rule references undefined context property 0x${contextPropertyId.toString(16).padStart(4, "0")}`,
367
+ `Context rule references undefined context property ${formatCode(contextPropertyId, PF.PATH_STEP_SIZE)}`,
361
368
  ruleOffset,
362
369
  );
363
370
  }
@@ -370,14 +377,17 @@ export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Arr
370
377
  pathDepth: field(depthValue, depthOffset, depthOffset + 1),
371
378
  path: field(pathHex, pathStart, pathStart + pathLength),
372
379
  ...(hintSize > 0 && {
373
- hint: field(toHex(data, hintStart, hintStart + hintSize), hintStart, hintStart + hintSize),
380
+ hint: hexField(data, hintStart, hintStart + hintSize),
374
381
  }),
375
382
  opCode: field(opCodeValue, opCodeOffset, opCodeOffset + PF.RULE_OPCODE_SIZE),
376
383
  dataLength: field(dataLengthValue, dataLengthOffset, dataLengthOffset + PF.RULE_DATALENGTH_SIZE),
377
- data: field(toHex(data, dataStart, dataStart + dataLengthValue), dataStart, dataStart + dataLengthValue),
384
+ data: hexField(data, dataStart, dataStart + dataLengthValue),
378
385
  span: { start: ruleOffset, end: ruleOffset + ruleSizeValue },
379
386
  });
380
387
 
388
+ if (ruleOffset + ruleSizeValue > groupEnd) {
389
+ throw new CallciumError("RULE_OVERFLOW", "Rule extends beyond group boundary", ruleOffset);
390
+ }
381
391
  ruleOffset += ruleSizeValue;
382
392
  }
383
393
 
@@ -404,7 +414,12 @@ export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Arr
404
414
  selector: field(selectorHex, selectorStart, selectorEnd),
405
415
  descLength: field(descLengthValue, descLengthStart, descLengthStart + PF.DESC_LENGTH_SIZE),
406
416
  descriptor: {
407
- raw: descriptorRaw,
417
+ get raw(): Hex {
418
+ return descriptorRaw.value;
419
+ },
420
+ set raw(replacement: Hex) {
421
+ descriptorRaw.value = replacement;
422
+ },
408
423
  header: field({ version: desc.version, paramCount: desc.params.length }, descStart, descStart + DF.HEADER_SIZE),
409
424
  params,
410
425
  span: { start: descStart, end: descEnd },
@@ -425,34 +440,44 @@ export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Arr
425
440
 
426
441
  type Rule = { scope: number; path: Uint8Array; operator: Uint8Array; hint: Uint8Array };
427
442
 
428
- /** Flatten a Constraint into one Rule per operator, compiling its hint against the descriptor. */
429
- function flattenConstraint(constraint: Constraint, desc: Uint8Array): Rule[] {
430
- const operators = constraint.operators.map(hexToBytes);
431
- for (const operator of operators) {
432
- if (operator.length < 1) {
433
- throw new CallciumError("INVALID_OPERATOR_BYTES", "Operator must have at least one byte (opcode).");
434
- }
435
- }
443
+ /** The path bytes and compiled hint every rule a constraint produces carries. */
444
+ type ConstraintTarget = { path: Uint8Array; hint: Uint8Array };
436
445
 
446
+ /** Read a constraint's path and compile the hint its rules carry. */
447
+ function readConstraintTarget(constraint: Constraint, desc: Uint8Array): ConstraintTarget {
437
448
  const path = hexToBytes(constraint.path);
438
449
 
439
450
  // Path shape checks precede compilation: the compiler assumes a framed, depth-bounded path.
440
451
  if (path.length === 0) {
441
- throw new CallciumError("EMPTY_PATH", "Rule path must have at least one step.");
452
+ throw new CallciumError("EMPTY_PATH", "Rule path must have at least one step");
442
453
  }
443
454
  if ((path.length & 1) !== 0) {
444
- throw new CallciumError("MALFORMED_PATH", "Path byte length must be even.");
455
+ throw new CallciumError("MALFORMED_PATH", "Path byte length must be even");
445
456
  }
446
457
  const depth = path.length / 2;
447
- if (depth > Limits.MAX_PATH_DEPTH) {
448
- throw new CallciumError("PATH_TOO_DEEP", `Path depth ${depth} exceeds maximum ${Limits.MAX_PATH_DEPTH}.`);
458
+ if (depth > PF.MAX_PATH_DEPTH) {
459
+ throw new CallciumError("PATH_TOO_DEEP", `Path depth ${depth} exceeds maximum ${PF.MAX_PATH_DEPTH}`);
449
460
  }
450
461
 
451
462
  const hint =
452
463
  constraint.scope === Scope.CALLDATA
453
464
  ? Descriptor.compileHint(desc, parsePathSteps(constraint.path))
454
465
  : new Uint8Array(0);
455
- return operators.map((operator) => ({ scope: constraint.scope, path, operator, hint }));
466
+ return { path, hint };
467
+ }
468
+
469
+ /** Flatten a Constraint into one Rule per operator, compiling its hint against the descriptor. */
470
+ 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.
474
+ let target: ConstraintTarget | undefined;
475
+
476
+ return constraint.operators.map((operatorHex) => {
477
+ const operator = toOperatorBytes(operatorHex);
478
+ target ??= readConstraintTarget(constraint, desc);
479
+ return { scope: constraint.scope, operator, ...target };
480
+ });
456
481
  }
457
482
 
458
483
  /** Compare two byte arrays lexicographically. */
@@ -477,6 +502,12 @@ function sortRules(rules: Rule[]): void {
477
502
  });
478
503
  }
479
504
 
505
+ /** Require a value to fit the big-endian field of `byteWidth` bytes. */
506
+ function requireFieldFits(value: number, byteWidth: number, code: CallciumErrorCode, subject: string): void {
507
+ const max = fieldMax(byteWidth);
508
+ if (value > max) throw new CallciumError(code, `${subject} ${value} exceeds maximum ${max}`);
509
+ }
510
+
480
511
  /**
481
512
  * Serialize a single rule to its wire format bytes.
482
513
  * Path shape and operator framing are established by `flattenConstraint`.
@@ -484,13 +515,11 @@ function sortRules(rules: Rule[]): void {
484
515
  function encodeRule(rule: Rule): Uint8Array {
485
516
  const depth = rule.path.length / 2;
486
517
  if (rule.scope === Scope.CONTEXT && depth !== 1) {
487
- throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope rules must have exactly one path step.");
518
+ throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope rules must have exactly one path step");
488
519
  }
489
520
  const data = rule.operator.subarray(1);
490
521
  const ruleSize = PF.RULE_FIXED_OVERHEAD + rule.path.length + rule.hint.length + data.length;
491
- if (ruleSize > 0xffff) {
492
- throw new CallciumError("RULE_SIZE_OVERFLOW", `Rule size ${ruleSize} exceeds maximum 65535`);
493
- }
522
+ requireFieldFits(ruleSize, PF.RULE_SIZE_SIZE, "RULE_SIZE_OVERFLOW", "Rule size");
494
523
 
495
524
  const buf = new Uint8Array(ruleSize);
496
525
  writeBE16(buf, 0, ruleSize);
@@ -541,9 +570,7 @@ function encode(data: PolicyData): Hex {
541
570
  if (data.groups.length === 0) {
542
571
  throw new CallciumError("EMPTY_POLICY", "Policy must contain at least one group");
543
572
  }
544
- if (data.groups.length > 0xff) {
545
- throw new CallciumError("GROUP_COUNT_OVERFLOW", `Group count ${data.groups.length} exceeds maximum 255`);
546
- }
573
+ requireFieldFits(data.groups.length, PF.GROUP_COUNT_SIZE, "GROUP_COUNT_OVERFLOW", "Group count");
547
574
 
548
575
  // Flatten constraints into rules and sort within each group. Hints are compiled first because
549
576
  // they are part of the rule bytes the group hash covers.
@@ -553,20 +580,13 @@ function encode(data: PolicyData): Hex {
553
580
  return rules;
554
581
  });
555
582
 
556
- if (descBytes.length > 0xffff) {
557
- throw new CallciumError("DESC_LENGTH_OVERFLOW", `Descriptor length ${descBytes.length} exceeds maximum 65535`);
558
- }
583
+ requireFieldFits(descBytes.length, PF.DESC_LENGTH_SIZE, "DESC_LENGTH_OVERFLOW", "Descriptor length");
559
584
 
560
585
  const encodedGroups = sortedGroups.map((rules, groupIndex) => {
561
586
  if (rules.length === 0) {
562
587
  throw new CallciumError("EMPTY_GROUP", `Group ${groupIndex} is empty`);
563
588
  }
564
- if (rules.length > 0xffff) {
565
- throw new CallciumError(
566
- "RULE_COUNT_OVERFLOW",
567
- `Group ${groupIndex} rule count ${rules.length} exceeds maximum 65535`,
568
- );
569
- }
589
+ requireFieldFits(rules.length, PF.GROUP_RULECOUNT_SIZE, "RULE_COUNT_OVERFLOW", `Group ${groupIndex} rule count`);
570
590
  return { wireBytes: encodeGroupRules(rules), ruleCount: rules.length };
571
591
  });
572
592
 
@@ -577,7 +597,13 @@ function encode(data: PolicyData): Hex {
577
597
  encodedGroups.sort((a, b) => compareBytes(hashes.get(a)!, hashes.get(b)!));
578
598
  }
579
599
 
580
- const selectorBytes = data.isSelectorless ? new Uint8Array(4) : hexToBytes(data.selector);
600
+ const selectorBytes = data.isSelectorless ? new Uint8Array(PF.SELECTOR_SIZE) : hexToBytes(data.selector);
601
+ if (selectorBytes.length !== PF.SELECTOR_SIZE) {
602
+ throw new CallciumError(
603
+ "MALFORMED_SELECTOR",
604
+ `Selector must be ${PF.SELECTOR_SIZE} bytes, got ${selectorBytes.length}`,
605
+ );
606
+ }
581
607
 
582
608
  const headerByte = PF.VERSION | (data.isSelectorless ? PF.FLAG_NO_SELECTOR : 0);
583
609
 
@@ -629,8 +655,8 @@ function decode(blob: Hex): PolicyData {
629
655
  const { policy } = decodePolicy(blob);
630
656
 
631
657
  const groups: Constraint[][] = policy.groups.map((group) => {
658
+ // A Map keeps its insertion order, which is the order the rules named their constraints in.
632
659
  const constraintMap = new Map<string, Constraint>();
633
- const constraintOrder: string[] = [];
634
660
 
635
661
  for (const rule of group.rules) {
636
662
  const hintHex = rule.hint?.value;
@@ -649,11 +675,10 @@ function decode(blob: Hex): PolicyData {
649
675
  span: rule.span,
650
676
  };
651
677
  constraintMap.set(key, constraint);
652
- constraintOrder.push(key);
653
678
  }
654
679
  }
655
680
 
656
- return constraintOrder.map((k) => constraintMap.get(k)!);
681
+ return [...constraintMap.values()];
657
682
  });
658
683
 
659
684
  return {