@callcium/sdk 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,704 +1,1328 @@
1
- import { keccak_256 } from "@noble/hashes/sha3.js";
1
+ import { concatBytes } from "@noble/hashes/utils.js";
2
2
 
3
- import { bytesToHex, hexToBytes, toHex, readU16, readU32, writeBE16, writeBE32 } from "./bytes";
3
+ import { bigintToHex, bytesToHex, hexToBytes, readU16, readU32, toHex, writeBE16, writeBE32 } from "./bytes";
4
4
  import {
5
- DescriptorFormat as DF,
5
+ comparisonOf,
6
6
  fieldMax,
7
7
  formatCode,
8
+ isComparisonCode,
9
+ isQuantifierCode,
10
+ isScopeCode,
11
+ isSourceCode,
12
+ LoopQuantifier,
8
13
  MAX_CONTEXT_PROPERTY_ID,
9
14
  Op,
10
15
  PolicyFormat as PF,
11
16
  Scope,
17
+ Source,
18
+ sourceOf,
12
19
  TypeCode,
20
+ type QuantifierCode,
21
+ type ScopeCode,
13
22
  } from "./constants";
14
23
  import { Descriptor } from "./descriptor";
15
- import { decodeDescriptor } from "./descriptor-coder";
16
- import { CallciumError, type CallciumErrorCode } from "./errors";
24
+ import { decodeDescriptor, inspectDescriptor } from "./descriptor-coder";
25
+ import { CallciumError } from "./errors";
26
+ import { Hint } from "./hint";
17
27
  import {
28
+ acceptsContextSource,
29
+ canonicalize,
30
+ contextPropertyType,
31
+ hasCalldataLength,
32
+ isLeftAligned,
18
33
  isLengthOp,
19
- isLengthValidType,
34
+ isValidPayloadSize,
35
+ LENGTH_TYPE,
36
+ operandTypeOf,
37
+ operandWidth,
38
+ readMember,
39
+ readOperator,
20
40
  toBigInt,
21
- isAddressableTarget,
22
- isValidOperatorData,
23
- toOperatorBytes,
24
41
  } from "./operators";
25
- import { parsePathSteps } from "./path";
42
+ import { encodePath, parsePathSteps } from "./path";
26
43
 
44
+ import type { CallciumErrorCode } from "./errors";
45
+ import type { Hex } from "./types";
27
46
  import type {
28
- Constraint,
29
- Field,
30
- Hex,
47
+ ArrayMeta,
48
+ BodyRule,
49
+ DecodedFrameHint,
31
50
  DecodedGroup,
32
- DecodedParam,
51
+ DecodedHint,
52
+ DecodedHop,
53
+ DecodedOperator,
33
54
  DecodedPolicy,
34
55
  DecodedRule,
56
+ DecodedTest,
57
+ Field,
58
+ LoopRule,
35
59
  PolicyData,
60
+ Rule,
36
61
  } from "./types";
37
62
 
38
63
  ///////////////////////////////////////////////////////////////////////////
39
- // Policy field helper
64
+ // Policy validation
40
65
  ///////////////////////////////////////////////////////////////////////////
41
66
 
42
- /** Wrap a value with its byte span for positional tracking. */
43
- function field<T>(value: T, start: number, end: number): Field<T> {
44
- return { value, span: { start, end } };
45
- }
46
-
47
67
  /**
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.
68
+ * Validate a policy blob against every storage-time invariant.
69
+ * @param blob - The policy blob.
70
+ * @throws {CallciumError} On the first invariant the blob breaks, at the offset of the group or rule containing it.
51
71
  */
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
-
65
- ///////////////////////////////////////////////////////////////////////////
66
- // Hint block
67
- ///////////////////////////////////////////////////////////////////////////
68
-
69
- /** Resolve the hint block size at `hintStart` from its header and suffix header bytes. */
70
- function resolveHintSize(data: Uint8Array, hintStart: number, ruleEnd: number, ruleOffset: number): number {
71
- const overrun = (): never => {
72
- throw new CallciumError("RULE_FIELD_OUT_OF_BOUNDS", "Rule is too small to hold a hint block", ruleOffset);
73
- };
74
-
75
- if (hintStart + PF.HINT_HEADER_SIZE > ruleEnd) overrun();
76
- const header = data[hintStart]!;
77
- const hopsEnd = hintStart + PF.HINT_HEADER_SIZE + (header & PF.HINT_HOP_COUNT_MASK) * PF.HINT_HOP_SIZE;
78
- let hintSize = hopsEnd - hintStart + PF.HINT_TARGET_SIZE;
79
-
80
- // A quantifier frame sits between the main chain and the target, sized by its own header byte.
81
- if (header >> PF.HINT_KIND_SHIFT !== PF.HINT_KIND_NONE) {
82
- const suffixHeaderOffset = hopsEnd + PF.HINT_FRAME_PREFIX_SIZE;
83
- if (suffixHeaderOffset >= ruleEnd) overrun();
84
- const suffixHeader = data[suffixHeaderOffset]!;
85
- hintSize +=
86
- PF.HINT_FRAME_PREFIX_SIZE + PF.HINT_HEADER_SIZE + (suffixHeader & PF.HINT_HOP_COUNT_MASK) * PF.HINT_HOP_SIZE;
87
- }
88
-
89
- if (hintStart + hintSize > ruleEnd) overrun();
90
- return hintSize;
91
- }
92
-
93
- /** Throw a MALFORMED_HINT error with the given detail. */
94
- function malformedHint(detail: string, ruleOffset: number): never {
95
- throw new CallciumError("MALFORMED_HINT", detail, ruleOffset);
96
- }
97
-
98
- /** Require every hop entry in `[start, end)` to carry no reserved or unused state. */
99
- function validateHops(data: Uint8Array, start: number, end: number, ruleOffset: number): void {
100
- for (let offset = start; offset < end; offset += PF.HINT_HOP_SIZE) {
101
- const index = readU16(data, offset + PF.HINT_HOP_INDEX_OFFSET);
102
- if (index === PF.HINT_INDEX_RESERVED) malformedHint("Hop index is a reserved value", ruleOffset);
103
-
104
- const meta = readU16(data, offset + PF.HINT_HOP_META_OFFSET);
105
- // A plain hop carries no element meta; an element hop addresses no offset of its own.
106
- if (index === PF.HINT_NO_INDEX) {
107
- if (meta !== 0) malformedHint("Plain hop carries a meta word", ruleOffset);
108
- } else {
109
- if (readU32(data, offset) !== 0) malformedHint("Element hop carries a delta", ruleOffset);
110
- if ((meta & PF.HINT_META_RESERVED_MASK) !== 0) malformedHint("Hop meta carries reserved bits", ruleOffset);
111
- }
112
- }
113
- }
114
-
115
- /** Require the hint block at `hintStart` to carry no reserved or unused state. */
116
- function validateHint(data: Uint8Array, hintStart: number, hintSize: number, ruleOffset: number): void {
117
- const header = data[hintStart]!;
118
- const kind = header >> PF.HINT_KIND_SHIFT;
119
- if (kind > PF.HINT_KIND_MAX) malformedHint("Header kind is a reserved value", ruleOffset);
120
-
121
- const hopsStart = hintStart + PF.HINT_HEADER_SIZE;
122
- const hopsEnd = hopsStart + (header & PF.HINT_HOP_COUNT_MASK) * PF.HINT_HOP_SIZE;
123
- validateHops(data, hopsStart, hopsEnd, ruleOffset);
124
-
125
- const targetOffset = hintStart + hintSize - PF.HINT_TARGET_SIZE;
126
- if (kind !== PF.HINT_KIND_NONE) {
127
- const frameMeta = readU16(data, hopsEnd + PF.HINT_FRAME_META_OFFSET);
128
- if ((frameMeta & PF.HINT_META_RESERVED_MASK) !== 0) malformedHint("Frame meta carries reserved bits", ruleOffset);
129
- const suffixHeader = data[hopsEnd + PF.HINT_FRAME_PREFIX_SIZE]!;
130
- if ((suffixHeader & PF.HINT_SUFFIX_RESERVED_MASK) !== 0) {
131
- malformedHint("Suffix header carries reserved bits", ruleOffset);
132
- }
133
- validateHops(data, hopsEnd + PF.HINT_FRAME_PREFIX_SIZE + PF.HINT_HEADER_SIZE, targetOffset, ruleOffset);
134
- }
135
-
136
- // A target meta word describes a dynamic array and is absent for every other type.
137
- const targetMeta = readU16(data, targetOffset + PF.HINT_TARGET_META_OFFSET);
138
- const targetTypeCode = data[targetOffset + PF.HINT_TARGET_TYPECODE_OFFSET]!;
139
- const targetMetaOk =
140
- targetTypeCode === TypeCode.DYNAMIC_ARRAY ? (targetMeta & PF.HINT_META_RESERVED_MASK) === 0 : targetMeta === 0;
141
- if (!targetMetaOk) malformedHint("Target meta carries reserved or unused state", ruleOffset);
142
-
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.
145
- if (!isAddressableTarget(targetTypeCode)) malformedHint("Target type code addresses no value", ruleOffset);
146
- }
147
-
148
- /** Read the type code the hint block at `hintStart` declares for its target. */
149
- function hintTargetTypeCode(data: Uint8Array, hintStart: number, hintSize: number): number {
150
- return data[hintStart + hintSize - PF.HINT_TARGET_SIZE + PF.HINT_TARGET_TYPECODE_OFFSET]!;
151
- }
152
-
153
- ///////////////////////////////////////////////////////////////////////////
154
- // Policy decoder
155
- ///////////////////////////////////////////////////////////////////////////
156
-
157
- /** Decode a policy blob, returning the structural representation and the blob bytes it spans. */
158
- export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Array } {
72
+ export function validatePolicy(blob: Hex): void {
159
73
  const data = hexToBytes(blob);
160
74
  if (data.length < PF.DESC_OFFSET) {
161
75
  throw new CallciumError("MALFORMED_HEADER", "Policy blob is too short");
162
76
  }
163
77
 
164
78
  const headerByte = data[0]!;
165
- const version = headerByte & PF.VERSION_MASK;
166
- if (version !== PF.VERSION) {
167
- throw new CallciumError("UNSUPPORTED_VERSION", `Version ${version} is not supported (expected ${PF.VERSION})`);
79
+ const major = headerByte >> PF.MAJOR_SHIFT;
80
+ if (major !== PF.MAJOR) {
81
+ throw new CallciumError("UNSUPPORTED_VERSION", `Major version ${major} is not supported (expected ${PF.MAJOR})`);
168
82
  }
169
- if ((headerByte & PF.RESERVED_MASK) !== 0) {
170
- throw new CallciumError("MALFORMED_HEADER", "Reserved header bits must be zero");
83
+ // A blob that uses no extension declares MINOR, the lowest minor covering its features.
84
+ const minor = (headerByte >> PF.MINOR_SHIFT) & PF.MINOR_MASK;
85
+ if (minor !== PF.MINOR) {
86
+ throw new CallciumError(
87
+ "MINOR_MISMATCH",
88
+ `Minor version ${minor} is not the lowest covering the policy's features (expected ${PF.MINOR})`,
89
+ );
171
90
  }
172
91
 
173
- const selectorStart = PF.SELECTOR_OFFSET;
174
- const selectorEnd = selectorStart + PF.SELECTOR_SIZE;
175
- const selectorHex = toHex(data, selectorStart, selectorEnd);
176
-
177
92
  const isSelectorless = (headerByte & PF.FLAG_NO_SELECTOR) !== 0;
178
- if (isSelectorless && selectorHex !== "0x00000000") {
93
+ if (isSelectorless && readU32(data, PF.SELECTOR_OFFSET) !== 0) {
179
94
  throw new CallciumError("MALFORMED_HEADER", "Selectorless policy must have a zeroed selector slot");
180
95
  }
181
96
 
182
- const descLengthStart = PF.DESC_LENGTH_OFFSET;
183
- const descLengthValue = readU16(data, descLengthStart);
184
- if (descLengthValue < 2) {
185
- throw new CallciumError("MALFORMED_HEADER", "Descriptor length must be at least 2");
186
- }
187
-
188
- const descStart = PF.DESC_OFFSET;
189
- const descEnd = descStart + descLengthValue;
190
- const groupCountOffset = descEnd;
191
-
97
+ const descLengthValue = readU16(data, PF.DESC_LENGTH_OFFSET);
98
+ const groupCountOffset = PF.DESC_OFFSET + descLengthValue;
192
99
  if (data.length < groupCountOffset + PF.GROUP_COUNT_SIZE) {
193
100
  throw new CallciumError("MALFORMED_HEADER", "Policy blob is too short for descriptor and group count");
194
101
  }
102
+ decodeDescriptor(data.subarray(PF.DESC_OFFSET, groupCountOffset));
195
103
 
196
- // Decode the embedded descriptor, offsetting spans to be relative to the policy blob.
197
- const descSlice = data.subarray(descStart, descEnd);
198
- const { descriptor: desc } = decodeDescriptor(descSlice);
199
- const params: DecodedParam[] = desc.params.map((param) => ({
200
- ...param,
201
- span: {
202
- start: descStart + param.span.start,
203
- end: descStart + param.span.end,
204
- },
205
- }));
206
-
207
- const descriptorRaw = hexField(data, descStart, descEnd);
208
-
209
- const groupCountStart = groupCountOffset;
210
- const groupCountEnd = groupCountStart + PF.GROUP_COUNT_SIZE;
211
104
  const groupCountValue = data[groupCountOffset]!;
212
105
  if (groupCountValue === 0) {
213
106
  throw new CallciumError("EMPTY_POLICY", "Policy must contain at least one group");
214
107
  }
215
108
 
216
- let offset = groupCountEnd;
217
- const groups: DecodedGroup[] = [];
218
-
109
+ let offset = groupCountOffset + PF.GROUP_COUNT_SIZE;
219
110
  for (let groupIndex = 0; groupIndex < groupCountValue; groupIndex++) {
220
111
  if (offset + PF.GROUP_HEADER_SIZE > data.length) {
221
112
  throw new CallciumError("UNEXPECTED_END", "Unexpected end while reading group header", offset);
222
113
  }
223
114
 
224
- const groupSizeStart = offset + PF.GROUP_RULECOUNT_SIZE;
225
- const groupSizeValue = readU32(data, groupSizeStart);
226
- const groupBodyStart = offset + PF.GROUP_HEADER_SIZE;
227
- const groupEnd = groupBodyStart + groupSizeValue;
228
-
115
+ const groupSizeValue = readU32(data, offset + PF.GROUP_RULECOUNT_SIZE);
116
+ const groupEnd = offset + PF.GROUP_HEADER_SIZE + groupSizeValue;
229
117
  if (groupEnd > data.length) {
230
118
  throw new CallciumError("GROUP_OVERFLOW", "Group extends beyond policy blob", offset);
231
119
  }
232
120
 
233
- const ruleCountStart = offset;
234
- const ruleCountValue = readU16(data, ruleCountStart);
121
+ const ruleCountValue = readU16(data, offset);
235
122
  if (ruleCountValue === 0) {
236
123
  throw new CallciumError("EMPTY_GROUP", "Group must contain at least one rule", offset);
237
124
  }
238
- if (groupSizeValue < ruleCountValue * PF.RULE_MIN_SIZE) {
239
- throw new CallciumError("GROUP_TOO_SMALL", "Declared group size is too small for its rule count", offset);
125
+
126
+ let ruleOffset = offset + PF.GROUP_HEADER_SIZE;
127
+ for (let ruleIndex = 0; ruleIndex < ruleCountValue; ruleIndex++) {
128
+ if (ruleOffset + PF.RULE_SIZE_SIZE > groupEnd) groupSizeMismatch(offset);
129
+ ruleOffset = validateRule(data, ruleOffset, groupEnd);
240
130
  }
241
131
 
242
- const rules: DecodedRule[] = [];
243
- let ruleOffset = groupBodyStart;
132
+ if (ruleOffset !== groupEnd) groupSizeMismatch(offset);
133
+ offset = groupEnd;
134
+ }
244
135
 
245
- for (let ruleIndex = 0; ruleIndex < ruleCountValue; ruleIndex++) {
246
- if (ruleOffset + PF.RULE_SIZE_SIZE > data.length) {
247
- throw new CallciumError("UNEXPECTED_END", "Unexpected end while reading rule size", ruleOffset);
248
- }
136
+ // Reject trailing bytes after the last group.
137
+ if (offset !== data.length) {
138
+ throw new CallciumError("TRAILING_BYTES", "Trailing bytes after last group", offset);
139
+ }
140
+ }
249
141
 
250
- const ruleSizeValue = readU16(data, ruleOffset);
251
- if (ruleSizeValue < PF.RULE_MIN_SIZE) {
142
+ /** Validate a single rule and return the offset of the next rule. */
143
+ function validateRule(data: Uint8Array, ruleOffset: number, groupEnd: number): number {
144
+ const ruleEnd = ruleOffset + ruleSize(data, ruleOffset, groupEnd);
145
+ const scopeValue = scope(data, ruleOffset, ruleEnd);
146
+ switch (scopeValue) {
147
+ case Scope.LOOP:
148
+ validateLoop(data, ruleOffset, ruleEnd);
149
+ break;
150
+ case Scope.CONTEXT:
151
+ case Scope.CALLDATA:
152
+ validateTest(data, ruleOffset, ruleEnd, scopeValue, 0);
153
+ break;
154
+ default:
155
+ return scopeValue satisfies never;
156
+ }
157
+ return ruleEnd;
158
+ }
159
+
160
+ /** Return the size of the rule at `ruleOffset`, which must lie within `end`. */
161
+ function ruleSize(data: Uint8Array, ruleOffset: number, end: number): number {
162
+ const ruleSizeValue = readU16(data, ruleOffset);
163
+ if (ruleSizeValue < PF.RULE_SIZE_SIZE) {
164
+ throw new CallciumError(
165
+ "RULE_TOO_SMALL",
166
+ `Rule size ${ruleSizeValue} is below minimum ${PF.RULE_SIZE_SIZE}`,
167
+ ruleOffset,
168
+ );
169
+ }
170
+ if (ruleOffset + ruleSizeValue > end) {
171
+ throw new CallciumError("RULE_OVERFLOW", "Rule extends beyond group boundary", ruleOffset);
172
+ }
173
+ return ruleSizeValue;
174
+ }
175
+
176
+ /** Return the scope of the rule at `ruleOffset`, which must be a defined value. */
177
+ function scope(data: Uint8Array, ruleOffset: number, ruleEnd: number): ScopeCode {
178
+ if (ruleOffset + PF.RULE_SCOPE_OFFSET + PF.RULE_SCOPE_SIZE > ruleEnd) ruleSizeMismatch(ruleOffset);
179
+ const scopeValue = data[ruleOffset + PF.RULE_SCOPE_OFFSET]!;
180
+ if (!isScopeCode(scopeValue)) {
181
+ throw new CallciumError("UNKNOWN_SCOPE", `Unknown scope value ${scopeValue}`, ruleOffset);
182
+ }
183
+ return scopeValue;
184
+ }
185
+
186
+ /** Validate the loop rule at `ruleOffset` and the tests of its body. */
187
+ function validateLoop(data: Uint8Array, ruleOffset: number, ruleEnd: number): void {
188
+ if (ruleOffset + PF.RULE_QUANTIFIER_OFFSET + PF.RULE_QUANTIFIER_SIZE > ruleEnd) ruleSizeMismatch(ruleOffset);
189
+ const quantifier = data[ruleOffset + PF.RULE_QUANTIFIER_OFFSET]!;
190
+ if (!isQuantifierCode(quantifier)) {
191
+ throw new CallciumError("UNKNOWN_QUANTIFIER", `Unknown quantifier value ${quantifier}`, ruleOffset);
192
+ }
193
+
194
+ const { pathEnd: hintOffset, depth } = validatePath(data, ruleOffset, ruleOffset + PF.LOOP_DEPTH_OFFSET, ruleEnd);
195
+ if (depth === 0) {
196
+ throw new CallciumError("EMPTY_PATH", "Rule path must have at least one step", ruleOffset);
197
+ }
198
+ if (depth > PF.MAX_PATH_DEPTH) pathTooDeep(depth, ruleOffset);
199
+
200
+ const frameOffset = validateHint(data, ruleOffset, hintOffset, ruleEnd);
201
+ if (frameOffset + PF.HINT_FRAME_PREFIX_SIZE + PF.GROUP_RULECOUNT_SIZE > ruleEnd) ruleSizeMismatch(ruleOffset);
202
+ const frameMeta = readU16(data, frameOffset + PF.HINT_FRAME_META_OFFSET);
203
+ if ((frameMeta & PF.HINT_META_RESERVED_MASK) !== 0) malformedHint("Frame meta carries reserved bits", ruleOffset);
204
+
205
+ const bodyOffset = frameOffset + PF.HINT_FRAME_PREFIX_SIZE;
206
+ const ruleCountValue = readU16(data, bodyOffset);
207
+ if (ruleCountValue === 0) {
208
+ throw new CallciumError("EMPTY_GROUP", "Loop body must contain at least one rule", ruleOffset);
209
+ }
210
+
211
+ let testOffset = bodyOffset + PF.GROUP_RULECOUNT_SIZE;
212
+ for (let ruleIndex = 0; ruleIndex < ruleCountValue; ruleIndex++) {
213
+ if (testOffset + PF.RULE_SIZE_SIZE > ruleEnd) ruleSizeMismatch(ruleOffset);
214
+ const testEnd = testOffset + ruleSize(data, testOffset, ruleEnd);
215
+ const scopeValue = scope(data, testOffset, testEnd);
216
+ switch (scopeValue) {
217
+ case Scope.LOOP:
218
+ throw new CallciumError("INVALID_NESTED_LOOP", "Loop body holds a loop", testOffset);
219
+ case Scope.CONTEXT:
220
+ case Scope.CALLDATA:
221
+ validateTest(data, testOffset, testEnd, scopeValue, depth);
222
+ break;
223
+ default:
224
+ return scopeValue satisfies never;
225
+ }
226
+ testOffset = testEnd;
227
+ }
228
+
229
+ if (testOffset !== ruleEnd) ruleSizeMismatch(ruleOffset);
230
+ }
231
+
232
+ /** Validate the context or calldata test at `ruleOffset`, `outerDepth` path steps below the arguments. */
233
+ function validateTest(
234
+ data: Uint8Array,
235
+ ruleOffset: number,
236
+ ruleEnd: number,
237
+ scopeValue: typeof Scope.CONTEXT | typeof Scope.CALLDATA,
238
+ outerDepth: number,
239
+ ): void {
240
+ let operatorOffset: number;
241
+ let typeCode: number;
242
+ switch (scopeValue) {
243
+ case Scope.CONTEXT: {
244
+ // Context-scope rules must have exactly one path step naming a defined property.
245
+ if (ruleOffset + PF.RULE_PATH_OFFSET > ruleEnd) ruleSizeMismatch(ruleOffset);
246
+ if (data[ruleOffset + PF.RULE_DEPTH_OFFSET] !== 1) {
252
247
  throw new CallciumError(
253
- "RULE_TOO_SMALL",
254
- `Rule size ${ruleSizeValue} is below minimum ${PF.RULE_MIN_SIZE}`,
248
+ "INVALID_CONTEXT_PATH",
249
+ "Context-scope rules must have exactly one path step",
255
250
  ruleOffset,
256
251
  );
257
252
  }
258
- if (ruleOffset + ruleSizeValue > data.length) {
259
- throw new CallciumError("RULE_OVERFLOW", "Rule extends beyond policy blob", ruleOffset);
253
+ operatorOffset = ruleOffset + PF.RULE_PATH_OFFSET + PF.PROPERTY_ID_SIZE;
254
+ if (operatorOffset > ruleEnd) ruleSizeMismatch(ruleOffset);
255
+ const contextPropertyId = readU16(data, ruleOffset + PF.RULE_PATH_OFFSET);
256
+ if (contextPropertyId > MAX_CONTEXT_PROPERTY_ID) {
257
+ throw new CallciumError(
258
+ "UNKNOWN_CONTEXT_PROPERTY",
259
+ `Unknown context property ID ${formatCode(contextPropertyId, PF.PROPERTY_ID_SIZE)}`,
260
+ ruleOffset,
261
+ );
260
262
  }
261
-
262
- const scopeOffset = ruleOffset + PF.RULE_SCOPE_OFFSET;
263
- const scopeValue = data[scopeOffset]!;
264
- if (scopeValue !== Scope.CONTEXT && scopeValue !== Scope.CALLDATA) {
265
- throw new CallciumError("INVALID_SCOPE", `Unknown scope value ${scopeValue}`, ruleOffset);
263
+ typeCode = contextPropertyType(contextPropertyId);
264
+ break;
265
+ }
266
+ case Scope.CALLDATA: {
267
+ const { pathEnd: hintOffset, depth } = validatePath(data, ruleOffset, ruleOffset + PF.RULE_DEPTH_OFFSET, ruleEnd);
268
+ // A root test lies outside every loop, and only a root path must be non-empty.
269
+ if (outerDepth === 0 && depth === 0) {
270
+ throw new CallciumError("EMPTY_PATH", "Rule path must have at least one step", ruleOffset);
266
271
  }
267
-
268
- const depthOffset = ruleOffset + PF.RULE_DEPTH_OFFSET;
269
- const depthValue = data[depthOffset]!;
270
- const pathStart = ruleOffset + PF.RULE_PATH_OFFSET;
271
- const pathLength = depthValue * PF.PATH_STEP_SIZE;
272
-
273
- // Resolve the hint block; context rules carry none.
274
- const ruleEnd = ruleOffset + ruleSizeValue;
275
- const hintStart = pathStart + pathLength;
276
- let hintSize = 0;
277
- if (scopeValue === Scope.CALLDATA) {
278
- hintSize = resolveHintSize(data, hintStart, ruleEnd, ruleOffset);
279
- validateHint(data, hintStart, hintSize, ruleOffset);
272
+ if (outerDepth + depth > PF.MAX_PATH_DEPTH) pathTooDeep(outerDepth + depth, ruleOffset);
273
+
274
+ const targetOffset = validateHint(data, ruleOffset, hintOffset, ruleEnd);
275
+ operatorOffset = targetOffset + PF.HINT_TARGET_SIZE;
276
+ if (operatorOffset > ruleEnd) ruleSizeMismatch(ruleOffset);
277
+ const targetMeta = readU16(data, targetOffset + PF.HINT_TARGET_META_OFFSET);
278
+ typeCode = data[targetOffset + PF.HINT_TARGET_TYPECODE_OFFSET]!;
279
+ // A target meta word describes a dynamic array and is absent for every other type.
280
+ const targetMetaOk =
281
+ typeCode === TypeCode.DYNAMIC_ARRAY ? (targetMeta & PF.HINT_META_RESERVED_MASK) === 0 : targetMeta === 0;
282
+ if (!targetMetaOk) malformedHint("Target meta carries reserved or unused state", ruleOffset);
283
+
284
+ // An operator reads either a scalar word or a declared length, so a target carrying neither — a tuple, a static
285
+ // array, or an undefined code — addresses nothing.
286
+ if (operandWidth(typeCode) === undefined && !hasCalldataLength(typeCode)) {
287
+ malformedHint("Target type code addresses no value", ruleOffset);
280
288
  }
289
+ break;
290
+ }
291
+ default:
292
+ return scopeValue satisfies never;
293
+ }
281
294
 
282
- const opCodeOffset = hintStart + hintSize;
283
- const dataLengthOffset = opCodeOffset + PF.RULE_OPCODE_SIZE;
284
- const dataLengthValue = readU16(data, dataLengthOffset);
295
+ const operatorEnd = validateOperator(data, ruleOffset, operatorOffset, ruleEnd, typeCode);
296
+ if (operatorEnd !== ruleEnd) ruleSizeMismatch(ruleOffset);
297
+ }
285
298
 
286
- // Validate ruleSize matches the computed layout.
287
- const expectedRuleSize = PF.RULE_FIXED_OVERHEAD + pathLength + hintSize + dataLengthValue;
288
- if (ruleSizeValue !== expectedRuleSize) {
289
- throw new CallciumError(
290
- "RULE_SIZE_MISMATCH",
291
- `Declared rule size ${ruleSizeValue} does not match computed size ${expectedRuleSize}`,
292
- ruleOffset,
293
- );
294
- }
299
+ /**
300
+ * Require the path whose depth field sits at `depthOffset` to step below the reserved marker range. Return the
301
+ * offset past the path and its depth.
302
+ */
303
+ function validatePath(
304
+ data: Uint8Array,
305
+ ruleOffset: number,
306
+ depthOffset: number,
307
+ ruleEnd: number,
308
+ ): { pathEnd: number; depth: number } {
309
+ if (depthOffset + PF.RULE_DEPTH_SIZE > ruleEnd) ruleSizeMismatch(ruleOffset);
310
+ const depth = data[depthOffset]!;
311
+ const pathStart = depthOffset + PF.RULE_DEPTH_SIZE;
312
+ const pathEnd = pathStart + depth * PF.PATH_STEP_SIZE;
313
+ if (pathEnd > ruleEnd) ruleSizeMismatch(ruleOffset);
314
+
315
+ for (let offset = pathStart; offset < pathEnd; offset += PF.PATH_STEP_SIZE) {
316
+ const step = readU16(data, offset);
317
+ if (step >= PF.PATH_MARKER_BASE) {
318
+ throw new CallciumError(
319
+ "UNKNOWN_PATH_STEP",
320
+ `Path step ${formatCode(step, PF.PATH_STEP_SIZE)} is in the reserved marker range`,
321
+ ruleOffset,
322
+ );
323
+ }
324
+ }
325
+ return { pathEnd, depth };
326
+ }
295
327
 
296
- // Validate operator and data length.
297
- const opCodeValue = data[opCodeOffset]!;
298
- const opBase = opCodeValue & ~Op.NOT;
299
- if (opBase === 0 || !isValidOperatorData(opBase, dataLengthValue)) {
300
- throw new CallciumError("UNKNOWN_OPERATOR", "Unrecognized or malformed operator", ruleOffset);
301
- }
328
+ /**
329
+ * Require the hint header and hops at `hintOffset` to carry no reserved or unused state, and return the offset past
330
+ * the hops.
331
+ */
332
+ function validateHint(data: Uint8Array, ruleOffset: number, hintOffset: number, ruleEnd: number): number {
333
+ if (hintOffset + PF.HINT_HEADER_SIZE > ruleEnd) ruleSizeMismatch(ruleOffset);
334
+ const header = data[hintOffset]!;
335
+ if ((header & PF.HINT_HEADER_RESERVED_MASK) !== 0) malformedHint("Header carries reserved bits", ruleOffset);
302
336
 
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.
305
- if (hintSize !== 0) {
306
- const targetTypeCode = hintTargetTypeCode(data, hintStart, hintSize);
307
- if (isLengthValidType(targetTypeCode) !== isLengthOp(opBase)) {
308
- throw new CallciumError(
309
- "OPERATOR_TARGET_MISMATCH",
310
- "Operator family does not match the type the target declares",
311
- ruleOffset,
312
- );
313
- }
314
- }
337
+ const hopsStart = hintOffset + PF.HINT_HEADER_SIZE;
338
+ const hopsEnd = hopsStart + (header & PF.HINT_HOP_COUNT_MASK) * PF.HINT_HOP_SIZE;
339
+ if (hopsEnd > ruleEnd) ruleSizeMismatch(ruleOffset);
340
+ validateHops(data, hopsStart, hopsEnd, ruleOffset);
341
+ return hopsEnd;
342
+ }
315
343
 
316
- const dataStart = dataLengthOffset + PF.RULE_DATALENGTH_SIZE;
344
+ /** Require every hop entry in `[start, end)` to carry no reserved or unused state. */
345
+ function validateHops(data: Uint8Array, start: number, end: number, ruleOffset: number): void {
346
+ for (let offset = start; offset < end; offset += PF.HINT_HOP_SIZE) {
347
+ const index = readU16(data, offset + PF.HINT_HOP_INDEX_OFFSET);
348
+ if (index !== PF.HINT_NO_INDEX && index >= PF.PATH_MARKER_BASE) {
349
+ malformedHint("Hop index is a reserved value", ruleOffset);
350
+ }
317
351
 
318
- // IN operands must be strictly ascending (unsigned); strictness also rejects duplicates.
319
- if (opBase === Op.IN) {
320
- const wordCount = dataLengthValue / 32;
321
- let prev = toBigInt(data, dataStart);
322
- for (let word = 1; word < wordCount; word++) {
323
- const cur = toBigInt(data, dataStart + word * 32);
324
- if (cur <= prev) {
325
- throw new CallciumError("UNSORTED_IN_SET", "IN operands must be strictly ascending", ruleOffset);
326
- }
327
- prev = cur;
328
- }
329
- }
352
+ const meta = readU16(data, offset + PF.HINT_HOP_META_OFFSET);
353
+ // A plain hop carries no element meta; an element hop addresses no offset of its own.
354
+ if (index === PF.HINT_NO_INDEX) {
355
+ if (meta !== 0) malformedHint("Plain hop carries a meta word", ruleOffset);
356
+ } else {
357
+ if (readU32(data, offset) !== 0) malformedHint("Element hop carries a delta", ruleOffset);
358
+ if ((meta & PF.HINT_META_RESERVED_MASK) !== 0) malformedHint("Hop meta carries reserved bits", ruleOffset);
359
+ }
360
+ }
361
+ }
330
362
 
331
- // An EQ_CTX operand word must be a defined context property ID.
332
- if (opBase === Op.EQ_CTX) {
333
- if (toBigInt(data, dataStart) > BigInt(MAX_CONTEXT_PROPERTY_ID)) {
334
- throw new CallciumError(
335
- "UNKNOWN_CONTEXT_PROPERTY",
336
- "EQ_CTX operand references an undefined context property",
337
- ruleOffset,
338
- );
339
- }
340
- }
363
+ /** Validate the operator at `offset` against a target of `typeCode` and return the offset past it. */
364
+ function validateOperator(
365
+ data: Uint8Array,
366
+ ruleOffset: number,
367
+ offset: number,
368
+ ruleEnd: number,
369
+ typeCode: number,
370
+ ): number {
371
+ if (offset + PF.RULE_OPCODE_SIZE + PF.RULE_DATALENGTH_SIZE > ruleEnd) ruleSizeMismatch(ruleOffset);
372
+ const opCodeValue = data[offset]!;
373
+ const opBase = comparisonOf(opCodeValue);
374
+ const source = sourceOf(opCodeValue);
375
+ if (!isComparisonCode(opBase) || !isSourceCode(source)) {
376
+ throw new CallciumError(
377
+ "UNKNOWN_OPERATOR",
378
+ `Unknown operator code ${formatCode(opCodeValue & ~Op.NOT, PF.RULE_OPCODE_SIZE)}`,
379
+ ruleOffset,
380
+ );
381
+ }
341
382
 
342
- // The path must be non-empty and within the depth cap.
343
- if (depthValue === 0) {
344
- throw new CallciumError("EMPTY_PATH", "Rule path must have at least one step", ruleOffset);
345
- }
346
- if (depthValue > PF.MAX_PATH_DEPTH) {
383
+ // A length operator reads the declared length a dynamic target resolves to, and a value operator reads a scalar
384
+ // word, so the target type fixes which family the rule may carry.
385
+ const isLength = isLengthOp(opBase);
386
+ if (hasCalldataLength(typeCode) !== isLength) {
387
+ throw new CallciumError(
388
+ "OPERATOR_TARGET_MISMATCH",
389
+ "Operator family does not match the type the target declares",
390
+ ruleOffset,
391
+ );
392
+ }
393
+
394
+ const dataLengthValue = readU16(data, offset + PF.RULE_OPCODE_SIZE);
395
+ const dataStart = offset + PF.RULE_OPCODE_SIZE + PF.RULE_DATALENGTH_SIZE;
396
+ const operatorEnd = dataStart + dataLengthValue;
397
+
398
+ switch (source) {
399
+ case Source.CONTEXT: {
400
+ if (!acceptsContextSource(opBase)) {
347
401
  throw new CallciumError(
348
- "PATH_TOO_DEEP",
349
- `Path depth ${depthValue} exceeds maximum ${PF.MAX_PATH_DEPTH}`,
402
+ "INVALID_CONTEXT_OPERAND",
403
+ "Context operand applies only to EQ, GT, LT, GTE and LTE",
350
404
  ruleOffset,
351
405
  );
352
406
  }
353
-
354
- // Context-scope rules must have exactly one path step naming a defined property.
355
- if (scopeValue === Scope.CONTEXT) {
356
- if (depthValue !== 1) {
357
- throw new CallciumError(
358
- "INVALID_CONTEXT_PATH",
359
- "Context-scope rules must have exactly one path step",
360
- ruleOffset,
361
- );
362
- }
363
- const contextPropertyId = readU16(data, pathStart);
364
- if (contextPropertyId > MAX_CONTEXT_PROPERTY_ID) {
365
- throw new CallciumError(
366
- "UNKNOWN_CONTEXT_PROPERTY",
367
- `Context rule references undefined context property ${formatCode(contextPropertyId, PF.PATH_STEP_SIZE)}`,
368
- ruleOffset,
369
- );
370
- }
371
- }
372
-
373
- const pathHex = toHex(data, pathStart, pathStart + pathLength);
374
- rules.push({
375
- ruleSize: field(ruleSizeValue, ruleOffset, ruleOffset + PF.RULE_SIZE_SIZE),
376
- scope: field(scopeValue, scopeOffset, scopeOffset + 1),
377
- pathDepth: field(depthValue, depthOffset, depthOffset + 1),
378
- path: field(pathHex, pathStart, pathStart + pathLength),
379
- ...(hintSize > 0 && {
380
- hint: hexField(data, hintStart, hintStart + hintSize),
381
- }),
382
- opCode: field(opCodeValue, opCodeOffset, opCodeOffset + PF.RULE_OPCODE_SIZE),
383
- dataLength: field(dataLengthValue, dataLengthOffset, dataLengthOffset + PF.RULE_DATALENGTH_SIZE),
384
- data: hexField(data, dataStart, dataStart + dataLengthValue),
385
- span: { start: ruleOffset, end: ruleOffset + ruleSizeValue },
386
- });
387
-
388
- if (ruleOffset + ruleSizeValue > groupEnd) {
389
- throw new CallciumError("RULE_OVERFLOW", "Rule extends beyond group boundary", ruleOffset);
407
+ if (dataLengthValue !== PF.PROPERTY_ID_SIZE) dataLengthMismatch(dataLengthValue, PF.PROPERTY_ID_SIZE, ruleOffset);
408
+ if (operatorEnd > ruleEnd) ruleSizeMismatch(ruleOffset);
409
+ // A context operand must name a defined context property.
410
+ const contextPropertyId = readU16(data, dataStart);
411
+ if (contextPropertyId > MAX_CONTEXT_PROPERTY_ID) {
412
+ throw new CallciumError(
413
+ "UNKNOWN_CONTEXT_PROPERTY",
414
+ `Unknown context property ID ${formatCode(contextPropertyId, PF.PROPERTY_ID_SIZE)}`,
415
+ ruleOffset,
416
+ );
390
417
  }
391
- ruleOffset += ruleSizeValue;
418
+ break;
392
419
  }
393
-
394
- if (ruleOffset !== groupEnd) {
395
- throw new CallciumError("GROUP_SIZE_MISMATCH", "Rules do not exactly fill the declared group size", offset);
420
+ case Source.CONSTANT: {
421
+ // The target is a static elementary type or carries a calldata length, so the operand has a width.
422
+ const width = operandWidth(isLength ? LENGTH_TYPE : typeCode)!;
423
+ if (!isValidPayloadSize(opBase, dataLengthValue, width)) dataLengthMismatch(dataLengthValue, width, ruleOffset);
424
+ if (operatorEnd > ruleEnd) ruleSizeMismatch(ruleOffset);
425
+ // IN operands must be strictly ascending (unsigned); strictness also rejects duplicates.
426
+ if (opBase === Op.IN) validateInAscending(data, dataStart, dataLengthValue, width, ruleOffset);
427
+ break;
396
428
  }
397
-
398
- groups.push({
399
- ruleCount: field(ruleCountValue, ruleCountStart, ruleCountStart + PF.GROUP_RULECOUNT_SIZE),
400
- groupSize: field(groupSizeValue, groupSizeStart, groupSizeStart + PF.GROUP_SIZE_SIZE),
401
- rules,
402
- span: { start: offset, end: groupEnd },
403
- });
404
- offset = groupEnd;
429
+ default:
430
+ return source satisfies never;
405
431
  }
432
+ return operatorEnd;
433
+ }
406
434
 
407
- // Reject trailing bytes after the last group.
408
- if (offset !== data.length) {
409
- throw new CallciumError("TRAILING_BYTES", "Trailing bytes after last group", offset);
435
+ /**
436
+ * Require the IN operand members to be strictly ascending by unsigned value. Sign extension and left alignment keep the
437
+ * unsigned order of a member's bytes, so the bytes order the members as their loaded words.
438
+ */
439
+ function validateInAscending(
440
+ data: Uint8Array,
441
+ dataStart: number,
442
+ dataLength: number,
443
+ width: number,
444
+ ruleOffset: number,
445
+ ): void {
446
+ const memberCount = dataLength / width;
447
+ let prev = readMember(data, dataStart, width);
448
+ for (let member = 1; member < memberCount; member++) {
449
+ const cur = readMember(data, dataStart + member * width, width);
450
+ if (cur <= prev) {
451
+ throw new CallciumError("UNSORTED_IN_SET", "IN operands must be strictly ascending", ruleOffset);
452
+ }
453
+ prev = cur;
410
454
  }
455
+ }
411
456
 
412
- const policy: DecodedPolicy = {
413
- header: field(headerByte, 0, PF.HEADER_SIZE),
414
- selector: field(selectorHex, selectorStart, selectorEnd),
415
- descLength: field(descLengthValue, descLengthStart, descLengthStart + PF.DESC_LENGTH_SIZE),
416
- descriptor: {
417
- get raw(): Hex {
418
- return descriptorRaw.value;
419
- },
420
- set raw(replacement: Hex) {
421
- descriptorRaw.value = replacement;
422
- },
423
- header: field({ version: desc.version, paramCount: desc.params.length }, descStart, descStart + DF.HEADER_SIZE),
424
- params,
425
- span: { start: descStart, end: descEnd },
426
- },
427
- groupCount: field(groupCountValue, groupCountStart, groupCountEnd),
428
- groups,
429
- span: { start: 0, end: data.length },
430
- version,
431
- isSelectorless,
432
- };
457
+ /** Throw a GROUP_SIZE_MISMATCH error for the group at `groupOffset`. */
458
+ function groupSizeMismatch(groupOffset: number): never {
459
+ throw new CallciumError("GROUP_SIZE_MISMATCH", "Rules do not exactly fill the declared group size", groupOffset);
460
+ }
433
461
 
434
- return { policy, data };
462
+ /** Throw a RULE_SIZE_MISMATCH error for the rule at `ruleOffset`. */
463
+ function ruleSizeMismatch(ruleOffset: number): never {
464
+ throw new CallciumError(
465
+ "RULE_SIZE_MISMATCH",
466
+ "Rule content does not exactly fill the declared rule size",
467
+ ruleOffset,
468
+ );
469
+ }
470
+
471
+ /** Throw a MALFORMED_HINT error with the given detail. */
472
+ function malformedHint(detail: string, ruleOffset: number): never {
473
+ throw new CallciumError("MALFORMED_HINT", detail, ruleOffset);
474
+ }
475
+
476
+ /** Throw a PATH_TOO_DEEP error for a path of `depth` steps counted across the enclosing loop. */
477
+ function pathTooDeep(depth: number, ruleOffset: number): never {
478
+ throw new CallciumError("PATH_TOO_DEEP", `Path depth ${depth} exceeds maximum ${PF.MAX_PATH_DEPTH}`, ruleOffset);
479
+ }
480
+
481
+ /** Throw a DATA_LENGTH_MISMATCH error for `dataLength` against operands of `width` bytes. */
482
+ function dataLengthMismatch(dataLength: number, width: number, ruleOffset: number): never {
483
+ throw new CallciumError(
484
+ "DATA_LENGTH_MISMATCH",
485
+ `Operator data length ${dataLength} does not hold ${width}-byte operands`,
486
+ ruleOffset,
487
+ );
435
488
  }
436
489
 
437
490
  ///////////////////////////////////////////////////////////////////////////
438
- // Encoder internals
491
+ // Encoding
439
492
  ///////////////////////////////////////////////////////////////////////////
440
493
 
441
- type Rule = { scope: number; path: Uint8Array; operator: Uint8Array; hint: Uint8Array };
494
+ /**
495
+ * A rule with its size-free bytes, its key in the canonical order, and its bytes after its `ruleSize`, each body rule
496
+ * framed.
497
+ */
498
+ type Keyed = { rule: Rule; key: Uint8Array; content: Uint8Array };
442
499
 
443
- /** The path bytes and compiled hint every rule a constraint produces carries. */
444
- type ConstraintTarget = { path: Uint8Array; hint: Uint8Array };
500
+ /**
501
+ * Encode the canonical form of a policy: each group normalized, its rules and the groups sorted by their
502
+ * size-free bytes, repeats dropped.
503
+ * @param data - The policy.
504
+ * @returns The policy blob as a 0x-prefixed hex string.
505
+ * @throws {CallciumError} When a field does not have the shape the encoder reads, or a path does not compile.
506
+ * When an operator does not apply to its target, or a constant is not a value of its operand type.
507
+ * When a count or size overflows its field.
508
+ */
509
+ function encode(data: PolicyData): Hex {
510
+ if (data.groups.length === 0) {
511
+ throw new CallciumError("EMPTY_POLICY", "Policy must contain at least one group");
512
+ }
513
+ const desc = hexToBytes(data.descriptor);
514
+ requireFieldFits(desc.length, PF.DESC_LENGTH_SIZE, "DESC_LENGTH_OVERFLOW", "Descriptor length");
515
+
516
+ const groups = sortDedupe(
517
+ data.groups.map((rules, groupIndex) => encodeGroup(desc, rules, groupIndex)),
518
+ (group) => group.key,
519
+ );
520
+ requireFieldFits(groups.length, PF.GROUP_COUNT_SIZE, "GROUP_COUNT_OVERFLOW", "Group count");
521
+
522
+ // Selector slot is defined as zero for selectorless policies regardless of what the caller provides.
523
+ const selector = data.isSelectorless ? new Uint8Array(PF.SELECTOR_SIZE) : hexToBytes(data.selector);
524
+ if (selector.length !== PF.SELECTOR_SIZE) {
525
+ throw new CallciumError("MALFORMED_SELECTOR", `Selector must be ${PF.SELECTOR_SIZE} bytes, got ${selector.length}`);
526
+ }
527
+ const header = (PF.MAJOR << PF.MAJOR_SHIFT) | (PF.MINOR << PF.MINOR_SHIFT);
528
+ return bytesToHex(
529
+ concatBytes(
530
+ Uint8Array.of(data.isSelectorless ? header | PF.FLAG_NO_SELECTOR : header),
531
+ selector,
532
+ be16(desc.length),
533
+ desc,
534
+ Uint8Array.of(groups.length),
535
+ ...groups.map((group) => group.encoded),
536
+ ),
537
+ );
538
+ }
445
539
 
446
- /** Read a constraint's path and compile the hint its rules carry. */
447
- function readConstraintTarget(constraint: Constraint, desc: Uint8Array): ConstraintTarget {
448
- const path = hexToBytes(constraint.path);
540
+ /** Encode a group in canonical form: its size-free bytes, and its bytes with the group header. */
541
+ function encodeGroup(desc: Uint8Array, rules: Rule[], groupIndex: number): { key: Uint8Array; encoded: Uint8Array } {
542
+ if (rules.length === 0) {
543
+ throw new CallciumError("EMPTY_GROUP", `Group ${groupIndex} is empty`);
544
+ }
545
+
546
+ // Every rule in canonical form, bodies first; then loops joined, context tests hoisted, and the rules sorted.
547
+ const normalized = rules.map((rule, ruleIndex) => {
548
+ checkRule(rule, groupIndex, ruleIndex);
549
+ return normalizeRule(desc, rule);
550
+ });
551
+ const finished = finish(desc, hoistContext(joinLoops(desc, normalized)));
552
+
553
+ requireFieldFits(finished.length, PF.GROUP_RULECOUNT_SIZE, "RULE_COUNT_OVERFLOW", `Group ${groupIndex} rule count`);
554
+ const ruleBytes = concatBytes(
555
+ ...finished.map(({ content }) => {
556
+ const size = content.length + PF.RULE_SIZE_SIZE;
557
+ requireFieldFits(size, PF.RULE_SIZE_SIZE, "RULE_SIZE_OVERFLOW", "Rule size");
558
+ return concatBytes(be16(size), content);
559
+ }),
560
+ );
561
+ return {
562
+ key: concatBytes(...finished.map(({ key }) => key)),
563
+ encoded: concatBytes(be16(finished.length), be32(ruleBytes.length), ruleBytes),
564
+ };
565
+ }
566
+
567
+ /** Require the rule's fields to have the shapes the encoder reads. */
568
+ function checkRule(rule: Rule, groupIndex: number, ruleIndex: number): void {
569
+ if (rule.scope !== Scope.LOOP) {
570
+ checkTest(rule.scope, rule.path, rule.operator, 0, groupIndex, ruleIndex);
571
+ return;
572
+ }
573
+ const quantifier: number = rule.quantifier;
574
+ if (!isQuantifierCode(quantifier)) {
575
+ throw new CallciumError("UNKNOWN_QUANTIFIER", `Unknown quantifier value ${quantifier}`);
576
+ }
577
+ const arrayDepth = checkPath(hexToBytes(rule.path), true, 0);
578
+ for (const test of rule.body) checkTest(test.scope, test.path, test.operator, arrayDepth, groupIndex, ruleIndex);
579
+ }
580
+
581
+ /**
582
+ * Require a test's operator to be an opcode and one or more 32-byte words, and its path to have the shape its scope
583
+ * reads. For a calldata test, the path comes after `depthBefore` steps of an enclosing loop.
584
+ */
585
+ function checkTest(
586
+ testScope: number,
587
+ path: Hex,
588
+ operator: Hex,
589
+ depthBefore: number,
590
+ groupIndex: number,
591
+ ruleIndex: number,
592
+ ): void {
593
+ const operatorBytes = hexToBytes(operator);
594
+ const isWords = operatorBytes.length > 1 && (operatorBytes.length - 1) % 32 === 0;
595
+ // A context property operand is one ID within its field.
596
+ const isContextId =
597
+ sourceOf(operatorBytes[0] ?? 0) !== Source.CONTEXT ||
598
+ (operatorBytes.length === 33 && toBigInt(operatorBytes, 1) <= BigInt(fieldMax(PF.PROPERTY_ID_SIZE)));
599
+ if (!isWords || !isContextId) {
600
+ throw new CallciumError(
601
+ "INVALID_OPERATOR_BYTES",
602
+ `Group ${groupIndex} rule ${ruleIndex} operator must be an opcode followed by 32-byte operand words`,
603
+ );
604
+ }
605
+ const pathBytes = hexToBytes(path);
606
+ if (testScope === Scope.CONTEXT) {
607
+ if (pathBytes.length !== PF.PROPERTY_ID_SIZE) {
608
+ throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope rules must have exactly one path step");
609
+ }
610
+ } else if (testScope === Scope.CALLDATA) {
611
+ checkPath(pathBytes, depthBefore === 0, depthBefore);
612
+ } else {
613
+ throw new CallciumError("UNKNOWN_SCOPE", `Unknown scope value ${testScope}`);
614
+ }
615
+ }
449
616
 
450
- // Path shape checks precede compilation: the compiler assumes a framed, depth-bounded path.
451
- if (path.length === 0) {
617
+ /**
618
+ * Require a calldata path to be whole steps, nonempty where `required`, and within `MAX_PATH_DEPTH` with the
619
+ * `depthBefore` steps of an enclosing loop. Return the depth reached.
620
+ */
621
+ function checkPath(path: Uint8Array, required: boolean, depthBefore: number): number {
622
+ if (required && path.length === 0) {
452
623
  throw new CallciumError("EMPTY_PATH", "Rule path must have at least one step");
453
624
  }
454
- if ((path.length & 1) !== 0) {
625
+ if (path.length % PF.PATH_STEP_SIZE !== 0) {
455
626
  throw new CallciumError("MALFORMED_PATH", "Path byte length must be even");
456
627
  }
457
- const depth = path.length / 2;
628
+ const depth = depthBefore + path.length / PF.PATH_STEP_SIZE;
458
629
  if (depth > PF.MAX_PATH_DEPTH) {
459
630
  throw new CallciumError("PATH_TOO_DEEP", `Path depth ${depth} exceeds maximum ${PF.MAX_PATH_DEPTH}`);
460
631
  }
632
+ return depth;
633
+ }
634
+
635
+ ///////////////////////////////////////////////////////////////////////////
636
+ // Normalization
637
+ ///////////////////////////////////////////////////////////////////////////
461
638
 
462
- const hint =
463
- constraint.scope === Scope.CALLDATA
464
- ? Descriptor.compileHint(desc, parsePathSteps(constraint.path))
465
- : new Uint8Array(0);
466
- return { path, hint };
639
+ /**
640
+ * Return the rule in canonical form. Its operator is positive and no one-member set, a context pair has the lower ID
641
+ * as subject, and a loop's body is finished.
642
+ */
643
+ function normalizeRule(desc: Uint8Array, rule: Rule): Rule {
644
+ if (rule.scope !== Scope.LOOP) return normalizeTest(rule);
645
+ return loop(desc, rule.quantifier, rule.path, rule.body.map(normalizeTest));
467
646
  }
468
647
 
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;
648
+ /**
649
+ * Return the test with its operator in canonical form and, for a context test against another context property, the
650
+ * lower ID as subject.
651
+ */
652
+ function normalizeTest(test: BodyRule): BodyRule {
653
+ const operator = normalizeOperator(hexToBytes(test.operator));
654
+ const opCode = operator[0]!;
655
+ const normalized: BodyRule = { scope: test.scope, path: test.path, operator: bytesToHex(operator) };
656
+ if (test.scope !== Scope.CONTEXT || sourceOf(opCode) !== Source.CONTEXT) return normalized;
657
+
658
+ const swapped = swapOf(comparisonOf(opCode));
659
+ const other = toBigInt(operator, 1);
660
+ const property = readU16(hexToBytes(test.path), 0);
661
+ if (swapped === undefined || other >= BigInt(property)) return normalized;
662
+
663
+ const word = new Uint8Array(32);
664
+ writeBE16(word, 30, property);
665
+ return {
666
+ scope: test.scope,
667
+ path: encodePath([Number(other)]),
668
+ operator: bytesToHex(concatBytes(Uint8Array.of((opCode & ~Op.COMPARISON_MASK) | swapped), word)),
669
+ };
670
+ }
671
+
672
+ /**
673
+ * Return the operator without NOT where a positive comparison expresses the negation, and a one-member constant set as
674
+ * an equality.
675
+ */
676
+ function normalizeOperator(operator: Uint8Array): Uint8Array {
677
+ let opCode = operator[0]!;
678
+ const comparison = comparisonOf(opCode);
679
+ if ((opCode & Op.NOT) !== 0) {
680
+ const positive = negationOf(comparison);
681
+ if (positive !== undefined) opCode = (opCode & ~(Op.NOT | Op.COMPARISON_MASK)) | positive;
682
+ }
683
+ if (comparison === Op.IN && sourceOf(opCode) === Source.CONSTANT && operator.length === 33) {
684
+ opCode = (opCode & ~Op.COMPARISON_MASK) | Op.EQ;
685
+ }
686
+ const normalized = operator.slice();
687
+ normalized[0] = opCode;
688
+ return normalized;
689
+ }
690
+
691
+ /** Return the loop over the array at `arrayPath` with its body finished. */
692
+ function loop(desc: Uint8Array, quantifier: QuantifierCode, arrayPath: Hex, tests: BodyRule[]): LoopRule {
693
+ const steps = parsePathSteps(arrayPath);
694
+ const compiled = Hint.requireCompiled(Hint.compileLoop(desc, steps), steps, 0);
695
+ const body = finish(desc, tests, compiled.node)
696
+ .map(({ rule }) => rule)
697
+ .filter((rule): rule is BodyRule => rule.scope !== Scope.LOOP);
698
+ return { scope: Scope.LOOP, quantifier, path: arrayPath, body };
699
+ }
475
700
 
476
- return constraint.operators.map((operatorHex) => {
477
- const operator = toOperatorBytes(operatorHex);
478
- target ??= readConstraintTarget(constraint, desc);
479
- return { scope: constraint.scope, operator, ...target };
701
+ /**
702
+ * Join every `ALL` loop into the first `ALL` loop over the same array. `ANY` loops stay apart: they are independent
703
+ * existentials.
704
+ */
705
+ function joinLoops(desc: Uint8Array, rules: Rule[]): Rule[] {
706
+ const consumed = new Set<number>();
707
+ const joined: Rule[] = [];
708
+ rules.forEach((rule, ruleIndex) => {
709
+ if (consumed.has(ruleIndex)) return;
710
+ if (!isAllLoop(rule)) {
711
+ joined.push(rule);
712
+ return;
713
+ }
714
+ const tests = [...rule.body];
715
+ for (let laterIndex = ruleIndex + 1; laterIndex < rules.length; laterIndex++) {
716
+ const later = rules[laterIndex]!;
717
+ if (!isAllLoop(later) || later.path.toLowerCase() !== rule.path.toLowerCase()) continue;
718
+ tests.push(...later.body);
719
+ consumed.add(laterIndex);
720
+ }
721
+ joined.push(loop(desc, LoopQuantifier.ALL, rule.path, tests));
480
722
  });
723
+ return joined;
481
724
  }
482
725
 
483
- /** Compare two byte arrays lexicographically. */
484
- function compareBytes(a: Uint8Array, b: Uint8Array): number {
485
- const length = Math.min(a.length, b.length);
486
- for (let i = 0; i < length; i++) {
487
- if (a[i]! !== b[i]!) return a[i]! - b[i]!;
488
- }
489
- return a.length - b.length;
726
+ /**
727
+ * Move the context tests of every `ANY` loop whose body contains a calldata test into the group:
728
+ * `∃i.(P ∧ Q(i)) ≡ P ∧ ∃i.Q(i)`.
729
+ */
730
+ function hoistContext(rules: Rule[]): Rule[] {
731
+ const hoisted: Rule[] = [];
732
+ const kept = rules.map((rule) => {
733
+ if (!hoists(rule)) return rule;
734
+ hoisted.push(...rule.body.filter((test) => test.scope === Scope.CONTEXT));
735
+ return { ...rule, body: rule.body.filter((test) => test.scope !== Scope.CONTEXT) };
736
+ });
737
+ return [...kept, ...hoisted];
490
738
  }
491
739
 
492
- /** Sort rules by (scope, pathDepth, pathBytes, operatorBytes). */
493
- function sortRules(rules: Rule[]): void {
494
- rules.sort((a, b) => {
495
- if (a.scope !== b.scope) return a.scope - b.scope;
496
- const depthA = a.path.length / 2;
497
- const depthB = b.path.length / 2;
498
- if (depthA !== depthB) return depthA - depthB;
499
- const pathCmp = compareBytes(a.path, b.path);
500
- if (pathCmp !== 0) return pathCmp;
501
- return compareBytes(a.operator, b.operator);
740
+ /**
741
+ * Return the rules sorted by their size-free bytes, repeats dropped and lone range pairs fused. Fusing counts distinct
742
+ * rules, so fusing follows dropping repeats. A range can repeat a rule already present, so sorting follows fusing.
743
+ */
744
+ function finish(desc: Uint8Array, rules: Rule[], start?: number): Keyed[] {
745
+ const keyed = sortDedupe(
746
+ rules.map((rule) => keyedRule(desc, rule, start)),
747
+ ({ key }) => key,
748
+ );
749
+ return sortDedupe(fuseRanges(desc, keyed, start), ({ key }) => key);
750
+ }
751
+
752
+ /**
753
+ * Fuse each lone pair of bounds on one subject into a range: the lower bound becomes the range, the upper bound leaves.
754
+ * The range is defined as the pair, so no bound needs comparing.
755
+ */
756
+ function fuseRanges(desc: Uint8Array, keyed: Keyed[], start?: number): Keyed[] {
757
+ return keyed.flatMap((entry) => {
758
+ const rule = entry.rule;
759
+ const bound = boundOf(rule);
760
+ const partner = bound === undefined ? undefined : lonePartner(keyed, rule, bound);
761
+ if (bound === undefined || partner === undefined || rule.scope === Scope.LOOP) return [entry];
762
+ if (!bound.isLower) return [];
763
+
764
+ const operator = hexToBytes(rule.operator);
765
+ const upper = hexToBytes(partner.operator);
766
+ const range: BodyRule = {
767
+ scope: rule.scope,
768
+ path: rule.path,
769
+ operator: bytesToHex(
770
+ concatBytes(
771
+ Uint8Array.of(bound.isLength ? Op.LENGTH_BETWEEN : Op.BETWEEN),
772
+ operator.subarray(1),
773
+ upper.subarray(1),
774
+ ),
775
+ ),
776
+ };
777
+ return [keyedRule(desc, range, start)];
502
778
  });
503
779
  }
504
780
 
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}`);
781
+ /** The bound a rule places: whether it bounds a length, and whether from below. */
782
+ type Bound = { isLength: boolean; isLower: boolean };
783
+
784
+ /**
785
+ * Return the other bound of the lone pair `rule` belongs to, or undefined unless one rule bounds each side of its
786
+ * subject.
787
+ */
788
+ function lonePartner(keyed: Keyed[], rule: Rule, bound: Bound): BodyRule | undefined {
789
+ let sameSide = 0;
790
+ let partner: BodyRule | undefined;
791
+ let otherSide = 0;
792
+ for (const { rule: candidate } of keyed) {
793
+ const candidateBound = boundOf(candidate);
794
+ if (candidateBound === undefined || candidate.scope === Scope.LOOP || candidate.scope !== rule.scope) continue;
795
+ if (candidateBound.isLength !== bound.isLength || candidate.path.toLowerCase() !== rule.path.toLowerCase())
796
+ continue;
797
+ if (candidateBound.isLower === bound.isLower) {
798
+ sameSide++;
799
+ } else {
800
+ otherSide++;
801
+ partner = candidate;
802
+ }
803
+ }
804
+ return sameSide === 1 && otherSide === 1 ? partner : undefined;
509
805
  }
510
806
 
511
807
  /**
512
- * Serialize a single rule to its wire format bytes.
513
- * Path shape and operator framing are established by `flattenConstraint`.
808
+ * Return the bound the rule places, an unnegated constant `GTE`, `LTE`, `LENGTH_GTE` or `LENGTH_LTE`, or undefined.
514
809
  */
515
- function encodeRule(rule: Rule): Uint8Array {
516
- const depth = rule.path.length / 2;
517
- if (rule.scope === Scope.CONTEXT && depth !== 1) {
518
- throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope rules must have exactly one path step");
519
- }
520
- const data = rule.operator.subarray(1);
521
- const ruleSize = PF.RULE_FIXED_OVERHEAD + rule.path.length + rule.hint.length + data.length;
522
- requireFieldFits(ruleSize, PF.RULE_SIZE_SIZE, "RULE_SIZE_OVERFLOW", "Rule size");
523
-
524
- const buf = new Uint8Array(ruleSize);
525
- writeBE16(buf, 0, ruleSize);
526
- buf[2] = rule.scope;
527
- buf[3] = depth;
528
- buf.set(rule.path, 4);
529
- buf.set(rule.hint, 4 + rule.path.length);
530
- const opOffset = 4 + rule.path.length + rule.hint.length;
531
- buf[opOffset] = rule.operator[0]!;
532
- writeBE16(buf, opOffset + 1, data.length);
533
- buf.set(data, opOffset + 3);
534
-
535
- return buf;
536
- }
537
-
538
- /** Serialize all rules in a group to a single byte array. */
539
- 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;
810
+ function boundOf(rule: Rule): Bound | undefined {
811
+ if (rule.scope === Scope.LOOP) return undefined;
812
+ const operator = hexToBytes(rule.operator);
813
+ if (operator.length !== 33) return undefined;
814
+ switch (operator[0]!) {
815
+ case Op.GTE:
816
+ return { isLength: false, isLower: true };
817
+ case Op.LTE:
818
+ return { isLength: false, isLower: false };
819
+ case Op.LENGTH_GTE:
820
+ return { isLength: true, isLower: true };
821
+ case Op.LENGTH_LTE:
822
+ return { isLength: true, isLower: false };
823
+ default:
824
+ return undefined;
825
+ }
549
826
  }
550
827
 
551
828
  ///////////////////////////////////////////////////////////////////////////
552
- // PolicyCoder
829
+ // Rule bytes
553
830
  ///////////////////////////////////////////////////////////////////////////
554
831
 
555
- /** Build a single operator hex string from a decoded rule's opCode and data. */
556
- function buildOperatorHex(rule: DecodedRule): Hex {
557
- const opCodeHex = rule.opCode.value.toString(16).padStart(2, "0");
558
- const dataBody = rule.data.value.slice(2);
559
- return `0x${opCodeHex}${dataBody}`;
832
+ /**
833
+ * Return the rule with its size-free bytes and its content. Its paths compile from the starting point: the arguments,
834
+ * or the loop element whose node lies at `start`.
835
+ */
836
+ function keyedRule(desc: Uint8Array, rule: Rule, start?: number): Keyed {
837
+ if (rule.scope !== Scope.LOOP) {
838
+ const key = testBytes(desc, rule, start);
839
+ return { rule, key, content: key };
840
+ }
841
+
842
+ const steps = parsePathSteps(rule.path);
843
+ const compiled = Hint.requireCompiled(Hint.compileLoop(desc, steps), steps, 0);
844
+ const path = hexToBytes(rule.path);
845
+ const head = concatBytes(
846
+ Uint8Array.of(Scope.LOOP, rule.quantifier, steps.length),
847
+ path,
848
+ compiled.hint,
849
+ be16(rule.body.length),
850
+ );
851
+ const tests = rule.body.map((test) => testBytes(desc, test, compiled.node));
852
+ return {
853
+ rule,
854
+ key: concatBytes(head, ...tests),
855
+ content: concatBytes(head, ...tests.map((test) => concatBytes(be16(test.length + PF.RULE_SIZE_SIZE), test))),
856
+ };
857
+ }
858
+
859
+ /** Return a test's size-free bytes, its path compiled from the starting point at `start`. */
860
+ function testBytes(desc: Uint8Array, test: BodyRule, start?: number): Uint8Array {
861
+ const path = hexToBytes(test.path);
862
+ if (test.scope === Scope.CONTEXT) {
863
+ const contextPropertyId = readU16(path, 0);
864
+ const operator = writeOperator(hexToBytes(test.operator), contextPropertyType(contextPropertyId));
865
+ return concatBytes(Uint8Array.of(Scope.CONTEXT, CONTEXT_PATH_DEPTH), path, operator);
866
+ }
867
+
868
+ const steps = parsePathSteps(test.path);
869
+ const compiled = Hint.requireCompiled(Hint.compileTest(desc, steps, start), steps, 0);
870
+ const operator = writeOperator(hexToBytes(test.operator), desc[compiled.node]!);
871
+ return concatBytes(Uint8Array.of(Scope.CALLDATA, steps.length), path, compiled.hint, operator);
560
872
  }
561
873
 
562
874
  /**
563
- * Encode a PolicyData structure into the canonical binary format.
564
- * @param data - The policy data to encode.
565
- * @returns The encoded policy as a 0x-prefixed hex string.
875
+ * Return the operator as the wire carries it against a target of `typeCode`: `opCode | dataLength | data`. Each
876
+ * constant narrows to its operand type's width. A context property is written as its ID.
566
877
  */
567
- function encode(data: PolicyData): Hex {
568
- const descBytes = hexToBytes(data.descriptor);
878
+ function writeOperator(operator: Uint8Array, typeCode: number): Uint8Array {
879
+ const opCode = operator[0]!;
880
+ const operandType = operandTypeOf(comparisonOf(opCode), typeCode);
881
+ if (operandType === undefined) {
882
+ throw new CallciumError("OPERATOR_TARGET_MISMATCH", "Comparison does not apply to the target type");
883
+ }
569
884
 
570
- if (data.groups.length === 0) {
571
- throw new CallciumError("EMPTY_POLICY", "Policy must contain at least one group");
885
+ const data: Uint8Array[] = [];
886
+ if (sourceOf(opCode) === Source.CONTEXT) {
887
+ data.push(operator.subarray(31, 33));
888
+ } else {
889
+ const width = operandWidth(operandType)!;
890
+ for (let offset = 1; offset < operator.length; offset += 32) {
891
+ const word = toBigInt(operator, offset);
892
+ if (canonicalize(word, operandType) !== word) {
893
+ throw new CallciumError(
894
+ "OUT_OF_PHYSICAL_BOUNDS",
895
+ `Operand ${bigintToHex(word)} is outside the range of its operand type`,
896
+ );
897
+ }
898
+ // A narrow value fills the low bytes of its word, a left-aligned one the high bytes.
899
+ const start = isLeftAligned(operandType) ? offset : offset + 32 - width;
900
+ data.push(operator.subarray(start, start + width));
901
+ }
572
902
  }
573
- requireFieldFits(data.groups.length, PF.GROUP_COUNT_SIZE, "GROUP_COUNT_OVERFLOW", "Group count");
903
+ const dataBytes = concatBytes(...data);
904
+ return concatBytes(Uint8Array.of(opCode), be16(dataBytes.length), dataBytes);
905
+ }
574
906
 
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.
577
- const sortedGroups: Rule[][] = data.groups.map((group) => {
578
- const rules = group.flatMap((constraint) => flattenConstraint(constraint, descBytes));
579
- sortRules(rules);
580
- return rules;
581
- });
907
+ ///////////////////////////////////////////////////////////////////////////
908
+ // Decoding
909
+ ///////////////////////////////////////////////////////////////////////////
582
910
 
583
- requireFieldFits(descBytes.length, PF.DESC_LENGTH_SIZE, "DESC_LENGTH_OVERFLOW", "Descriptor length");
911
+ /**
912
+ * Decode a policy blob into the policy it states.
913
+ * @param blob - The policy blob.
914
+ * @returns The decoded policy, each operand loaded to its 32-byte word.
915
+ * @throws {CallciumError} When the blob fails storage validation or its descriptor fails DV-1 or DV-2.
916
+ */
917
+ function decode(blob: Hex): PolicyData {
918
+ validatePolicy(blob);
919
+ const data = hexToBytes(blob);
584
920
 
585
- const encodedGroups = sortedGroups.map((rules, groupIndex) => {
586
- if (rules.length === 0) {
587
- throw new CallciumError("EMPTY_GROUP", `Group ${groupIndex} is empty`);
921
+ const isSelectorless = (data[0]! & PF.FLAG_NO_SELECTOR) !== 0;
922
+ const groupCountOffset = PF.DESC_OFFSET + readU16(data, PF.DESC_LENGTH_OFFSET);
923
+ Descriptor.validateMeta(data.subarray(PF.DESC_OFFSET, groupCountOffset));
924
+ const groups: Rule[][] = [];
925
+ let offset = groupCountOffset + PF.GROUP_COUNT_SIZE;
926
+ const groupCount = data[groupCountOffset]!;
927
+ for (let groupIndex = 0; groupIndex < groupCount; groupIndex++) {
928
+ const rules: Rule[] = [];
929
+ const ruleCount = readU16(data, offset);
930
+ offset += PF.GROUP_HEADER_SIZE;
931
+ for (let ruleIndex = 0; ruleIndex < ruleCount; ruleIndex++) {
932
+ rules.push(readRule(data, offset));
933
+ offset += readU16(data, offset);
588
934
  }
589
- requireFieldFits(rules.length, PF.GROUP_RULECOUNT_SIZE, "RULE_COUNT_OVERFLOW", `Group ${groupIndex} rule count`);
590
- return { wireBytes: encodeGroupRules(rules), ruleCount: rules.length };
591
- });
592
-
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.
595
- if (encodedGroups.length > 1) {
596
- const hashes = new Map(encodedGroups.map((group) => [group, keccak_256(group.wireBytes)]));
597
- encodedGroups.sort((a, b) => compareBytes(hashes.get(a)!, hashes.get(b)!));
935
+ groups.push(rules);
598
936
  }
937
+ return {
938
+ isSelectorless,
939
+ selector: isSelectorless ? "0x00000000" : toHex(data, PF.SELECTOR_OFFSET, PF.SELECTOR_OFFSET + PF.SELECTOR_SIZE),
940
+ descriptor: toHex(data, PF.DESC_OFFSET, groupCountOffset),
941
+ groups,
942
+ };
943
+ }
599
944
 
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
- }
945
+ /** Read the root rule at `ruleOffset`. */
946
+ function readRule(data: Uint8Array, ruleOffset: number): Rule {
947
+ if (data[ruleOffset + PF.RULE_SCOPE_OFFSET] !== Scope.LOOP) return readTest(data, ruleOffset);
607
948
 
608
- const headerByte = PF.VERSION | (data.isSelectorless ? PF.FLAG_NO_SELECTOR : 0);
949
+ const quantifier = data[ruleOffset + PF.RULE_QUANTIFIER_OFFSET]!;
950
+ if (!isQuantifierCode(quantifier)) {
951
+ throw new CallciumError("UNKNOWN_QUANTIFIER", `Unknown quantifier value ${quantifier}`, ruleOffset);
952
+ }
953
+ const pathStart = ruleOffset + PF.LOOP_DEPTH_OFFSET + PF.RULE_DEPTH_SIZE;
954
+ const hintOffset = pathStart + data[ruleOffset + PF.LOOP_DEPTH_OFFSET]! * PF.PATH_STEP_SIZE;
955
+
956
+ // The body follows the frame hint: its hops, the frame prefix, then `ruleCount`.
957
+ const bodyOffset =
958
+ hintOffset + PF.HINT_HEADER_SIZE + data[hintOffset]! * PF.HINT_HOP_SIZE + PF.HINT_FRAME_PREFIX_SIZE;
959
+ const body: BodyRule[] = [];
960
+ let testOffset = bodyOffset + PF.GROUP_RULECOUNT_SIZE;
961
+ const bodyCount = readU16(data, bodyOffset);
962
+ for (let bodyIndex = 0; bodyIndex < bodyCount; bodyIndex++) {
963
+ body.push(readTest(data, testOffset));
964
+ testOffset += readU16(data, testOffset);
965
+ }
966
+ return {
967
+ scope: Scope.LOOP,
968
+ quantifier,
969
+ path: toHex(data, pathStart, hintOffset),
970
+ hint: toHex(data, hintOffset, bodyOffset),
971
+ body,
972
+ };
973
+ }
609
974
 
610
- // Pre-compute total size.
611
- let totalSize = PF.HEADER_SIZE + PF.SELECTOR_SIZE + PF.DESC_LENGTH_SIZE + descBytes.length + PF.GROUP_COUNT_SIZE;
612
- for (const g of encodedGroups) {
613
- totalSize += PF.GROUP_HEADER_SIZE + g.wireBytes.length;
975
+ /** Read the test or context test at `ruleOffset`, each constant loaded to its 32-byte word. */
976
+ function readTest(data: Uint8Array, ruleOffset: number): BodyRule {
977
+ const testScope = data[ruleOffset + PF.RULE_SCOPE_OFFSET]!;
978
+ if (testScope !== Scope.CONTEXT && testScope !== Scope.CALLDATA) {
979
+ throw new CallciumError("UNKNOWN_SCOPE", `Unknown scope value ${testScope}`, ruleOffset);
980
+ }
981
+ const pathStart = ruleOffset + PF.RULE_PATH_OFFSET;
982
+ let pathEnd: number;
983
+ let opOffset: number;
984
+ let typeCode: number;
985
+ if (testScope === Scope.CONTEXT) {
986
+ pathEnd = pathStart + PF.PROPERTY_ID_SIZE;
987
+ opOffset = pathEnd;
988
+ typeCode = contextPropertyType(readU16(data, pathStart));
989
+ } else {
990
+ // The hint addresses the target: its hops, then the target block ending in the type code.
991
+ pathEnd = pathStart + data[ruleOffset + PF.RULE_DEPTH_OFFSET]! * PF.PATH_STEP_SIZE;
992
+ const targetOffset = pathEnd + PF.HINT_HEADER_SIZE + data[pathEnd]! * PF.HINT_HOP_SIZE;
993
+ typeCode = data[targetOffset + PF.HINT_TARGET_TYPECODE_OFFSET]!;
994
+ opOffset = targetOffset + PF.HINT_TARGET_SIZE;
614
995
  }
615
996
 
616
- const out = new Uint8Array(totalSize);
617
- let offset = 0;
997
+ const operator = operatorHex(readOperator(data, opOffset, typeCode));
998
+ const path = toHex(data, pathStart, pathEnd);
999
+ if (testScope === Scope.CONTEXT) return { scope: testScope, path, operator };
1000
+ return { scope: testScope, path, hint: toHex(data, pathEnd, opOffset), operator };
1001
+ }
618
1002
 
619
- // Policy header: header(1) | selector(4) | descLength(2) | desc(N) | groupCount(1).
620
- out[offset++] = headerByte;
1003
+ /**
1004
+ * Spell a decoded operator as policy data does. The result is the operator's code, then one 32-byte word per constant
1005
+ * operand, or the context property's ID as a 32-byte word.
1006
+ */
1007
+ export function operatorHex(operator: DecodedOperator): Hex {
1008
+ const words =
1009
+ operator.source === Source.CONTEXT ? [bigintToHex(BigInt(operator.contextPropertyId))] : operator.values;
1010
+ return `0x${operator.opCode.toString(16).padStart(2, "0")}${words.map((word) => word.slice(2)).join("")}`;
1011
+ }
621
1012
 
622
- out.set(selectorBytes, offset);
623
- offset += PF.SELECTOR_SIZE;
1013
+ ///////////////////////////////////////////////////////////////////////////
1014
+ // Inspection
1015
+ ///////////////////////////////////////////////////////////////////////////
624
1016
 
625
- writeBE16(out, offset, descBytes.length);
626
- offset += PF.DESC_LENGTH_SIZE;
1017
+ /**
1018
+ * Inspect a policy blob: every field it stores with the bytes that contain it. The descriptor is a node tree, the hints
1019
+ * are split into their fields and the operators are parsed.
1020
+ * @param blob - The policy blob.
1021
+ * @returns The inspected policy, each operand loaded to its 32-byte word.
1022
+ * @throws {CallciumError} When the blob fails storage validation or its descriptor fails DV-1 or DV-2.
1023
+ */
1024
+ function inspect(blob: Hex): DecodedPolicy {
1025
+ validatePolicy(blob);
1026
+ const data = hexToBytes(blob);
627
1027
 
628
- out.set(descBytes, offset);
629
- offset += descBytes.length;
1028
+ const headerByte = data[0]!;
1029
+ const header = fieldOf(
1030
+ {
1031
+ major: headerByte >> PF.MAJOR_SHIFT,
1032
+ minor: (headerByte >> PF.MINOR_SHIFT) & PF.MINOR_MASK,
1033
+ isSelectorless: (headerByte & PF.FLAG_NO_SELECTOR) !== 0,
1034
+ },
1035
+ 0,
1036
+ PF.SELECTOR_OFFSET,
1037
+ );
1038
+ const descLength = uintField(data, PF.DESC_LENGTH_OFFSET, PF.DESC_LENGTH_OFFSET + PF.DESC_LENGTH_SIZE);
1039
+ const groupCountOffset = PF.DESC_OFFSET + descLength.value;
1040
+ const descriptor = inspectDescriptor(data.subarray(PF.DESC_OFFSET, groupCountOffset), PF.DESC_OFFSET);
1041
+ const groupCount = uintField(data, groupCountOffset, groupCountOffset + PF.GROUP_COUNT_SIZE);
630
1042
 
631
- out[offset++] = encodedGroups.length;
1043
+ const groups: DecodedGroup[] = [];
1044
+ let offset = groupCount.span.end;
1045
+ for (let groupIndex = 0; groupIndex < groupCount.value; groupIndex++) {
1046
+ const ruleCount = uintField(data, offset, offset + PF.GROUP_RULECOUNT_SIZE);
1047
+ const groupSize = uintField(data, ruleCount.span.end, offset + PF.GROUP_HEADER_SIZE);
1048
+ const groupEnd = groupSize.span.end + groupSize.value;
1049
+ const rules: DecodedRule[] = [];
1050
+ let ruleOffset = groupSize.span.end;
1051
+ for (let ruleIndex = 0; ruleIndex < ruleCount.value; ruleIndex++) {
1052
+ const rule = inspectRule(data, ruleOffset);
1053
+ rules.push(rule);
1054
+ ruleOffset = rule.span.end;
1055
+ }
1056
+ groups.push({ ruleCount, groupSize, rules, span: { start: offset, end: groupEnd } });
1057
+ offset = groupEnd;
1058
+ }
632
1059
 
633
- for (const g of encodedGroups) {
634
- // Group header: ruleCount(2) | groupSize(4).
635
- writeBE16(out, offset, g.ruleCount);
636
- offset += PF.GROUP_RULECOUNT_SIZE;
1060
+ return {
1061
+ header,
1062
+ selector: hexField(data, PF.SELECTOR_OFFSET, PF.SELECTOR_OFFSET + PF.SELECTOR_SIZE),
1063
+ descLength,
1064
+ descriptor,
1065
+ groupCount,
1066
+ groups,
1067
+ span: { start: 0, end: data.length },
1068
+ };
1069
+ }
637
1070
 
638
- writeBE32(out, offset, g.wireBytes.length);
639
- offset += PF.GROUP_SIZE_SIZE;
1071
+ /** Inspect the root rule at `ruleOffset`. */
1072
+ function inspectRule(data: Uint8Array, ruleOffset: number): DecodedRule {
1073
+ if (data[ruleOffset + PF.RULE_SCOPE_OFFSET] !== Scope.LOOP) return inspectTest(data, ruleOffset);
640
1074
 
641
- out.set(g.wireBytes, offset);
642
- offset += g.wireBytes.length;
1075
+ const size = uintField(data, ruleOffset, ruleOffset + PF.RULE_SIZE_SIZE);
1076
+ const quantifierOffset = ruleOffset + PF.RULE_QUANTIFIER_OFFSET;
1077
+ const quantifier = data[quantifierOffset]!;
1078
+ if (!isQuantifierCode(quantifier)) {
1079
+ throw new CallciumError("UNKNOWN_QUANTIFIER", `Unknown quantifier value ${quantifier}`, ruleOffset);
643
1080
  }
1081
+ const depthOffset = ruleOffset + PF.LOOP_DEPTH_OFFSET;
1082
+ const pathDepth = uintField(data, depthOffset, depthOffset + PF.RULE_DEPTH_SIZE);
1083
+ const path = hexField(data, pathDepth.span.end, pathDepth.span.end + pathDepth.value * PF.PATH_STEP_SIZE);
1084
+ const hint = inspectFrameHint(data, path.span.end);
1085
+ const ruleCount = uintField(data, hint.span.end, hint.span.end + PF.GROUP_RULECOUNT_SIZE);
1086
+ const body: DecodedTest[] = [];
1087
+ let testOffset = ruleCount.span.end;
1088
+ for (let bodyIndex = 0; bodyIndex < ruleCount.value; bodyIndex++) {
1089
+ const test = inspectTest(data, testOffset);
1090
+ body.push(test);
1091
+ testOffset = test.span.end;
1092
+ }
1093
+ const scopeOffset = ruleOffset + PF.RULE_SCOPE_OFFSET;
1094
+ return {
1095
+ ruleSize: size,
1096
+ scope: fieldOf(Scope.LOOP, scopeOffset, scopeOffset + PF.RULE_SCOPE_SIZE),
1097
+ quantifier: fieldOf(quantifier, quantifierOffset, quantifierOffset + PF.RULE_QUANTIFIER_SIZE),
1098
+ pathDepth,
1099
+ path,
1100
+ hint,
1101
+ ruleCount,
1102
+ body,
1103
+ span: { start: ruleOffset, end: ruleOffset + size.value },
1104
+ };
1105
+ }
644
1106
 
645
- return bytesToHex(out);
1107
+ /** Inspect the test or context test at `ruleOffset`. */
1108
+ function inspectTest(data: Uint8Array, ruleOffset: number): DecodedTest {
1109
+ const size = uintField(data, ruleOffset, ruleOffset + PF.RULE_SIZE_SIZE);
1110
+ const scopeOffset = ruleOffset + PF.RULE_SCOPE_OFFSET;
1111
+ const testScope = data[scopeOffset]!;
1112
+ if (testScope !== Scope.CONTEXT && testScope !== Scope.CALLDATA) {
1113
+ throw new CallciumError("UNKNOWN_SCOPE", `Unknown scope value ${testScope}`, ruleOffset);
1114
+ }
1115
+ const pathDepth = uintField(data, ruleOffset + PF.RULE_DEPTH_OFFSET, ruleOffset + PF.RULE_PATH_OFFSET);
1116
+ const pathStart = ruleOffset + PF.RULE_PATH_OFFSET;
1117
+ let path: Field<Hex>;
1118
+ let hint: DecodedHint | undefined;
1119
+ let targetType: number;
1120
+ if (testScope === Scope.CONTEXT) {
1121
+ path = hexField(data, pathStart, pathStart + PF.PROPERTY_ID_SIZE);
1122
+ targetType = contextPropertyType(readU16(data, pathStart));
1123
+ } else {
1124
+ path = hexField(data, pathStart, pathStart + pathDepth.value * PF.PATH_STEP_SIZE);
1125
+ hint = inspectTestHint(data, path.span.end);
1126
+ targetType = hint.typeCode.value;
1127
+ }
1128
+ const opOffset = hint === undefined ? path.span.end : hint.span.end;
1129
+ const opCode = uintField(data, opOffset, opOffset + PF.RULE_OPCODE_SIZE);
1130
+ const dataLength = uintField(data, opCode.span.end, opCode.span.end + PF.RULE_DATALENGTH_SIZE);
1131
+ return {
1132
+ ruleSize: size,
1133
+ scope: fieldOf(testScope, scopeOffset, scopeOffset + PF.RULE_SCOPE_SIZE),
1134
+ pathDepth,
1135
+ path,
1136
+ ...(hint !== undefined && { hint }),
1137
+ targetType,
1138
+ opCode,
1139
+ dataLength,
1140
+ data: hexField(data, dataLength.span.end, dataLength.span.end + dataLength.value),
1141
+ operator: readOperator(data, opOffset, targetType),
1142
+ span: { start: ruleOffset, end: ruleOffset + size.value },
1143
+ };
646
1144
  }
647
1145
 
648
- /**
649
- * Decode a binary policy blob into a PolicyData structure.
650
- * @param blob - Binary policy as 0x-prefixed hex string.
651
- * @returns The decoded policy data with constraints grouped by scope and path.
652
- * @throws {CallciumError} If the blob is structurally malformed.
653
- */
654
- function decode(blob: Hex): PolicyData {
655
- const { policy } = decodePolicy(blob);
656
-
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.
659
- const constraintMap = new Map<string, Constraint>();
660
-
661
- for (const rule of group.rules) {
662
- const hintHex = rule.hint?.value;
663
- const key = `${rule.scope.value}:${rule.path.value}:${hintHex ?? ""}`;
664
- const opHex = buildOperatorHex(rule);
665
-
666
- const existing = constraintMap.get(key);
667
- if (existing !== undefined) {
668
- existing.operators.push(opHex);
669
- } else {
670
- const constraint: Constraint = {
671
- scope: rule.scope.value,
672
- path: rule.path.value,
673
- operators: [opHex],
674
- ...(hintHex !== undefined && { hint: hintHex }),
675
- span: rule.span,
676
- };
677
- constraintMap.set(key, constraint);
678
- }
679
- }
1146
+ /** Inspect the header and hops of the hint at `hintOffset`, with the offset past its last hop. */
1147
+ function inspectHops(
1148
+ data: Uint8Array,
1149
+ hintOffset: number,
1150
+ ): { hopCount: Field<number>; hops: DecodedHop[]; end: number } {
1151
+ const hopCount = uintField(data, hintOffset, hintOffset + PF.HINT_HEADER_SIZE);
1152
+ const hops: DecodedHop[] = [];
1153
+ let offset = hopCount.span.end;
1154
+ for (let hopIndex = 0; hopIndex < hopCount.value; hopIndex++) {
1155
+ const index = uintField(data, offset + PF.HINT_HOP_INDEX_OFFSET, offset + PF.HINT_HOP_META_OFFSET);
1156
+ hops.push({
1157
+ kind: index.value === PF.HINT_NO_INDEX ? "plain" : "element",
1158
+ delta: uintField(data, offset, offset + PF.HINT_HOP_INDEX_OFFSET),
1159
+ index,
1160
+ meta: metaField(data, offset + PF.HINT_HOP_META_OFFSET, offset + PF.HINT_HOP_SIZE),
1161
+ span: { start: offset, end: offset + PF.HINT_HOP_SIZE },
1162
+ });
1163
+ offset += PF.HINT_HOP_SIZE;
1164
+ }
1165
+ return { hopCount, hops, end: offset };
1166
+ }
680
1167
 
681
- return [...constraintMap.values()];
682
- });
1168
+ /** Inspect the test hint at `hintOffset`. */
1169
+ function inspectTestHint(data: Uint8Array, hintOffset: number): DecodedHint {
1170
+ const { hopCount, hops, end: target } = inspectHops(data, hintOffset);
1171
+ return {
1172
+ hopCount,
1173
+ hops,
1174
+ targetDelta: uintField(data, target, target + PF.HINT_TARGET_META_OFFSET),
1175
+ targetMeta: metaField(data, target + PF.HINT_TARGET_META_OFFSET, target + PF.HINT_TARGET_TYPECODE_OFFSET),
1176
+ typeCode: uintField(data, target + PF.HINT_TARGET_TYPECODE_OFFSET, target + PF.HINT_TARGET_SIZE),
1177
+ span: { start: hintOffset, end: target + PF.HINT_TARGET_SIZE },
1178
+ };
1179
+ }
683
1180
 
1181
+ /** Inspect the frame hint at `hintOffset`. */
1182
+ function inspectFrameHint(data: Uint8Array, hintOffset: number): DecodedFrameHint {
1183
+ const { hopCount, hops, end: frame } = inspectHops(data, hintOffset);
684
1184
  return {
685
- isSelectorless: policy.isSelectorless,
686
- selector: policy.selector.value,
687
- descriptor: policy.descriptor.raw,
688
- groups,
689
- span: policy.span,
1185
+ hopCount,
1186
+ hops,
1187
+ arrayDelta: uintField(data, frame, frame + PF.HINT_FRAME_COUNT_OFFSET),
1188
+ count: uintField(data, frame + PF.HINT_FRAME_COUNT_OFFSET, frame + PF.HINT_FRAME_META_OFFSET),
1189
+ meta: metaField(data, frame + PF.HINT_FRAME_META_OFFSET, frame + PF.HINT_FRAME_PREFIX_SIZE),
1190
+ span: { start: hintOffset, end: frame + PF.HINT_FRAME_PREFIX_SIZE },
690
1191
  };
691
1192
  }
692
1193
 
1194
+ /** A value with the bytes from `start` to `end` that contain it. */
1195
+ function fieldOf<T>(value: T, start: number, end: number): Field<T> {
1196
+ return { value, span: { start, end } };
1197
+ }
1198
+
1199
+ /** The bytes from `start` to `end` as a hex field. */
1200
+ function hexField(data: Uint8Array, start: number, end: number): Field<Hex> {
1201
+ return fieldOf(toHex(data, start, end), start, end);
1202
+ }
1203
+
1204
+ /** The big-endian unsigned integer the bytes from `start` to `end` represent. */
1205
+ function uintField(data: Uint8Array, start: number, end: number): Field<number> {
1206
+ let value = 0;
1207
+ for (let offset = start; offset < end; offset++) value = value * 256 + data[offset]!;
1208
+ return fieldOf(value, start, end);
1209
+ }
1210
+
1211
+ /** The meta word the bytes from `start` to `end` represent. */
1212
+ function metaField(data: Uint8Array, start: number, end: number): Field<ArrayMeta> {
1213
+ const meta = readU16(data, start);
1214
+ const value = {
1215
+ isElementDynamic: (meta & PF.HINT_META_ELEM_DYNAMIC) !== 0,
1216
+ isDynamicArray: (meta & PF.HINT_META_DYNAMIC_ARRAY) !== 0,
1217
+ strideWords: meta & PF.HINT_META_STRIDE_MASK,
1218
+ };
1219
+ return fieldOf(value, start, end);
1220
+ }
1221
+
1222
+ ///////////////////////////////////////////////////////////////////////////
1223
+ // Helpers
1224
+ ///////////////////////////////////////////////////////////////////////////
1225
+
1226
+ /** The `pathDepth` of a context test, whose path is its property ID. */
1227
+ const CONTEXT_PATH_DEPTH = 1;
1228
+
1229
+ /** Sort items ascending by their key bytes and drop repeats. */
1230
+ function sortDedupe<T>(items: T[], key: (item: T) => Uint8Array): T[] {
1231
+ const sorted = items.toSorted((first, second) => compareBytes(key(first), key(second)));
1232
+ return sorted.filter((item, index) => index === 0 || compareBytes(key(item), key(sorted[index - 1]!)) !== 0);
1233
+ }
1234
+
1235
+ /** Compare two byte arrays lexicographically. A prefix sorts first. */
1236
+ function compareBytes(first: Uint8Array, second: Uint8Array): number {
1237
+ const length = Math.min(first.length, second.length);
1238
+ for (let i = 0; i < length; i++) {
1239
+ if (first[i] !== second[i]) return first[i]! - second[i]!;
1240
+ }
1241
+ return first.length - second.length;
1242
+ }
1243
+
1244
+ /** Whether the rule is an `ALL` loop. */
1245
+ function isAllLoop(rule: Rule): rule is LoopRule {
1246
+ return rule.scope === Scope.LOOP && rule.quantifier === LoopQuantifier.ALL;
1247
+ }
1248
+
1249
+ /** Whether the rule is an `ANY` loop whose body contains a calldata test, which gives up its context tests. */
1250
+ function hoists(rule: Rule): rule is LoopRule {
1251
+ return (
1252
+ rule.scope === Scope.LOOP &&
1253
+ rule.quantifier === LoopQuantifier.ANY &&
1254
+ rule.body.some((test) => test.scope === Scope.CALLDATA)
1255
+ );
1256
+ }
1257
+
1258
+ /** Return the comparison equal to `NOT comparison`, or undefined where none is. */
1259
+ function negationOf(comparison: number): number | undefined {
1260
+ switch (comparison) {
1261
+ case Op.GT:
1262
+ return Op.LTE;
1263
+ case Op.LT:
1264
+ return Op.GTE;
1265
+ case Op.GTE:
1266
+ return Op.LT;
1267
+ case Op.LTE:
1268
+ return Op.GT;
1269
+ case Op.BITMASK_ANY:
1270
+ return Op.BITMASK_NONE;
1271
+ case Op.BITMASK_NONE:
1272
+ return Op.BITMASK_ANY;
1273
+ case Op.LENGTH_GT:
1274
+ return Op.LENGTH_LTE;
1275
+ case Op.LENGTH_LT:
1276
+ return Op.LENGTH_GTE;
1277
+ case Op.LENGTH_GTE:
1278
+ return Op.LENGTH_LT;
1279
+ case Op.LENGTH_LTE:
1280
+ return Op.LENGTH_GT;
1281
+ default:
1282
+ return undefined;
1283
+ }
1284
+ }
1285
+
693
1286
  /**
694
- * Inspect a binary policy blob, returning the structural representation with full byte-level spans.
695
- * @param blob - Binary policy as 0x-prefixed hex string.
696
- * @returns The inspected policy with per-field spans for every structural element.
697
- * @throws {CallciumError} If the blob is structurally malformed.
1287
+ * Return the comparison that holds with subject and operand exchanged, or undefined where a context operand takes
1288
+ * none.
698
1289
  */
699
- function inspect(blob: Hex): DecodedPolicy {
700
- return decodePolicy(blob).policy;
1290
+ function swapOf(comparison: number): number | undefined {
1291
+ switch (comparison) {
1292
+ case Op.EQ:
1293
+ return Op.EQ;
1294
+ case Op.GT:
1295
+ return Op.LT;
1296
+ case Op.LT:
1297
+ return Op.GT;
1298
+ case Op.GTE:
1299
+ return Op.LTE;
1300
+ case Op.LTE:
1301
+ return Op.GTE;
1302
+ default:
1303
+ return undefined;
1304
+ }
1305
+ }
1306
+
1307
+ /** Require a value to fit the big-endian field of `byteWidth` bytes. */
1308
+ function requireFieldFits(value: number, byteWidth: number, code: CallciumErrorCode, subject: string): void {
1309
+ const max = fieldMax(byteWidth);
1310
+ if (value > max) throw new CallciumError(code, `${subject} ${value} exceeds maximum ${max}`);
1311
+ }
1312
+
1313
+ /** Return `value` as a 2-byte big-endian field. */
1314
+ function be16(value: number): Uint8Array {
1315
+ const field = new Uint8Array(2);
1316
+ writeBE16(field, 0, value);
1317
+ return field;
1318
+ }
1319
+
1320
+ /** Return `value` as a 4-byte big-endian field. */
1321
+ function be32(value: number): Uint8Array {
1322
+ const field = new Uint8Array(4);
1323
+ writeBE32(field, 0, value);
1324
+ return field;
701
1325
  }
702
1326
 
703
- /** Encode, decode, and inspect policies in the canonical binary format. */
1327
+ /** Encode policies in canonical form, and decode and inspect policy blobs. */
704
1328
  export const PolicyCoder = { encode, decode, inspect };