@callcium/sdk 0.12.0 → 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,695 +1,1328 @@
1
- import { keccak_256 } from "@noble/hashes/sha3.js";
2
1
  import { concatBytes } from "@noble/hashes/utils.js";
3
2
 
4
- import { bytesToHex, hexToBytes, toHex, readU16, readU32, writeBE16, writeBE32 } from "./bytes";
3
+ import { bigintToHex, bytesToHex, hexToBytes, readU16, readU32, toHex, writeBE16, writeBE32 } from "./bytes";
5
4
  import {
6
- DescriptorFormat as DF,
5
+ comparisonOf,
7
6
  fieldMax,
8
7
  formatCode,
8
+ isComparisonCode,
9
+ isQuantifierCode,
10
+ isScopeCode,
11
+ isSourceCode,
12
+ LoopQuantifier,
9
13
  MAX_CONTEXT_PROPERTY_ID,
10
14
  Op,
11
15
  PolicyFormat as PF,
12
16
  Scope,
17
+ Source,
18
+ sourceOf,
13
19
  TypeCode,
20
+ type QuantifierCode,
21
+ type ScopeCode,
14
22
  } from "./constants";
15
23
  import { Descriptor } from "./descriptor";
16
- import { decodeDescriptor } from "./descriptor-coder";
17
- import { CallciumError, type CallciumErrorCode } from "./errors";
24
+ import { decodeDescriptor, inspectDescriptor } from "./descriptor-coder";
25
+ import { CallciumError } from "./errors";
26
+ import { Hint } from "./hint";
18
27
  import {
19
- isContextOp,
28
+ acceptsContextSource,
29
+ canonicalize,
30
+ contextPropertyType,
31
+ hasCalldataLength,
32
+ isLeftAligned,
20
33
  isLengthOp,
21
- isLengthValidType,
34
+ isValidPayloadSize,
35
+ LENGTH_TYPE,
36
+ operandTypeOf,
37
+ operandWidth,
38
+ readMember,
39
+ readOperator,
22
40
  toBigInt,
23
- isAddressableTarget,
24
- isValidOperatorData,
25
- toOperatorBytes,
26
41
  } from "./operators";
27
- import { parsePathSteps } from "./path";
42
+ import { encodePath, parsePathSteps } from "./path";
28
43
 
44
+ import type { CallciumErrorCode } from "./errors";
45
+ import type { Hex } from "./types";
29
46
  import type {
30
- Constraint,
31
- Field,
32
- Hex,
47
+ ArrayMeta,
48
+ BodyRule,
49
+ DecodedFrameHint,
33
50
  DecodedGroup,
34
- DecodedParam,
51
+ DecodedHint,
52
+ DecodedHop,
53
+ DecodedOperator,
35
54
  DecodedPolicy,
36
55
  DecodedRule,
56
+ DecodedTest,
57
+ Field,
58
+ LoopRule,
37
59
  PolicyData,
60
+ Rule,
38
61
  } from "./types";
39
62
 
40
63
  ///////////////////////////////////////////////////////////////////////////
41
- // Policy field helper
64
+ // Policy validation
42
65
  ///////////////////////////////////////////////////////////////////////////
43
66
 
44
- /** Wrap a value with its byte span for positional tracking. */
45
- function field<T>(value: T, start: number, end: number): Field<T> {
46
- return { value, span: { start, end } };
47
- }
48
-
49
67
  /**
50
- * Wrap the hex rendering of `data[start, end)` with its span, rendering on first read. Enforcement reads the span as
51
- * bytes, so the string is built only for a caller that asks. The field reads and assigns like a plain one.
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.
52
71
  */
53
- function hexField(data: Uint8Array, start: number, end: number): Field<Hex> {
54
- let hex: Hex | undefined;
55
- return {
56
- get value(): Hex {
57
- return (hex ??= toHex(data, start, end));
58
- },
59
- set value(replacement: Hex) {
60
- hex = replacement;
61
- },
62
- span: { start, end },
63
- };
64
- }
65
-
66
- ///////////////////////////////////////////////////////////////////////////
67
- // Hint block
68
- ///////////////////////////////////////////////////////////////////////////
69
-
70
- /** Resolve the hint block size at `hintStart` from its header and suffix header bytes. */
71
- function resolveHintSize(data: Uint8Array, hintStart: number, ruleEnd: number, ruleOffset: number): number {
72
- const overrun = (): never => {
73
- throw new CallciumError("RULE_FIELD_OUT_OF_BOUNDS", "Rule is too small to hold a hint block", ruleOffset);
74
- };
75
-
76
- if (hintStart + PF.HINT_HEADER_SIZE > ruleEnd) overrun();
77
- const header = data[hintStart]!;
78
- const hopsEnd = hintStart + PF.HINT_HEADER_SIZE + (header & PF.HINT_HOP_COUNT_MASK) * PF.HINT_HOP_SIZE;
79
- let hintSize = hopsEnd - hintStart + PF.HINT_TARGET_SIZE;
80
-
81
- // A quantifier frame sits between the main chain and the target, sized by its own header byte.
82
- if (header >> PF.HINT_KIND_SHIFT !== PF.HINT_KIND_NONE) {
83
- const suffixHeaderOffset = hopsEnd + PF.HINT_FRAME_PREFIX_SIZE;
84
- if (suffixHeaderOffset >= ruleEnd) overrun();
85
- const suffixHeader = data[suffixHeaderOffset]!;
86
- hintSize +=
87
- PF.HINT_FRAME_PREFIX_SIZE + PF.HINT_HEADER_SIZE + (suffixHeader & PF.HINT_HOP_COUNT_MASK) * PF.HINT_HOP_SIZE;
88
- }
89
-
90
- if (hintStart + hintSize > ruleEnd) overrun();
91
- return hintSize;
92
- }
93
-
94
- /** Throw a MALFORMED_HINT error with the given detail. */
95
- function malformedHint(detail: string, ruleOffset: number): never {
96
- throw new CallciumError("MALFORMED_HINT", detail, ruleOffset);
97
- }
98
-
99
- /** Require every hop entry in `[start, end)` to carry no reserved or unused state. */
100
- function validateHops(data: Uint8Array, start: number, end: number, ruleOffset: number): void {
101
- for (let offset = start; offset < end; offset += PF.HINT_HOP_SIZE) {
102
- const index = readU16(data, offset + PF.HINT_HOP_INDEX_OFFSET);
103
- if (index === PF.HINT_INDEX_RESERVED) malformedHint("Hop index is a reserved value", ruleOffset);
104
-
105
- const meta = readU16(data, offset + PF.HINT_HOP_META_OFFSET);
106
- // A plain hop carries no element meta; an element hop addresses no offset of its own.
107
- if (index === PF.HINT_NO_INDEX) {
108
- if (meta !== 0) malformedHint("Plain hop carries a meta word", ruleOffset);
109
- } else {
110
- if (readU32(data, offset) !== 0) malformedHint("Element hop carries a delta", ruleOffset);
111
- if ((meta & PF.HINT_META_RESERVED_MASK) !== 0) malformedHint("Hop meta carries reserved bits", ruleOffset);
112
- }
113
- }
114
- }
115
-
116
- /** Require the hint block at `hintStart` to carry no reserved or unused state. */
117
- function validateHint(data: Uint8Array, hintStart: number, hintSize: number, ruleOffset: number): void {
118
- const header = data[hintStart]!;
119
- const kind = header >> PF.HINT_KIND_SHIFT;
120
- if (kind > PF.HINT_KIND_MAX) malformedHint("Header kind is a reserved value", ruleOffset);
121
-
122
- const hopsStart = hintStart + PF.HINT_HEADER_SIZE;
123
- const hopsEnd = hopsStart + (header & PF.HINT_HOP_COUNT_MASK) * PF.HINT_HOP_SIZE;
124
- validateHops(data, hopsStart, hopsEnd, ruleOffset);
125
-
126
- const targetOffset = hintStart + hintSize - PF.HINT_TARGET_SIZE;
127
- if (kind !== PF.HINT_KIND_NONE) {
128
- const frameMeta = readU16(data, hopsEnd + PF.HINT_FRAME_META_OFFSET);
129
- if ((frameMeta & PF.HINT_META_RESERVED_MASK) !== 0) malformedHint("Frame meta carries reserved bits", ruleOffset);
130
- const suffixHeader = data[hopsEnd + PF.HINT_FRAME_PREFIX_SIZE]!;
131
- if ((suffixHeader & PF.HINT_SUFFIX_RESERVED_MASK) !== 0) {
132
- malformedHint("Suffix header carries reserved bits", ruleOffset);
133
- }
134
- validateHops(data, hopsEnd + PF.HINT_FRAME_PREFIX_SIZE + PF.HINT_HEADER_SIZE, targetOffset, ruleOffset);
135
- }
136
-
137
- // A target meta word describes a dynamic array and is absent for every other type.
138
- const targetMeta = readU16(data, targetOffset + PF.HINT_TARGET_META_OFFSET);
139
- const targetTypeCode = data[targetOffset + PF.HINT_TARGET_TYPECODE_OFFSET]!;
140
- const targetMetaOk =
141
- targetTypeCode === TypeCode.DYNAMIC_ARRAY ? (targetMeta & PF.HINT_META_RESERVED_MASK) === 0 : targetMeta === 0;
142
- if (!targetMetaOk) malformedHint("Target meta carries reserved or unused state", ruleOffset);
143
-
144
- // An operator reads either a scalar word or a declared length, so a target carrying neither — a tuple, a static
145
- // array, or an undefined code — addresses nothing.
146
- if (!isAddressableTarget(targetTypeCode)) malformedHint("Target type code addresses no value", ruleOffset);
147
- }
148
-
149
- /** Read the type code the hint block at `hintStart` declares for its target. */
150
- function hintTargetTypeCode(data: Uint8Array, hintStart: number, hintSize: number): number {
151
- return data[hintStart + hintSize - PF.HINT_TARGET_SIZE + PF.HINT_TARGET_TYPECODE_OFFSET]!;
152
- }
153
-
154
- ///////////////////////////////////////////////////////////////////////////
155
- // Policy decoder
156
- ///////////////////////////////////////////////////////////////////////////
157
-
158
- /** Decode a policy blob, returning the structural representation and the blob bytes it spans. */
159
- export function decodePolicy(blob: Hex): { policy: DecodedPolicy; data: Uint8Array } {
72
+ export function validatePolicy(blob: Hex): void {
160
73
  const data = hexToBytes(blob);
161
74
  if (data.length < PF.DESC_OFFSET) {
162
75
  throw new CallciumError("MALFORMED_HEADER", "Policy blob is too short");
163
76
  }
164
77
 
165
78
  const headerByte = data[0]!;
166
- const version = headerByte & PF.VERSION_MASK;
167
- if (version !== PF.VERSION) {
168
- 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})`);
169
82
  }
170
- if ((headerByte & PF.RESERVED_MASK) !== 0) {
171
- 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
+ );
172
90
  }
173
91
 
174
- const selectorStart = PF.SELECTOR_OFFSET;
175
- const selectorEnd = selectorStart + PF.SELECTOR_SIZE;
176
- const selectorHex = toHex(data, selectorStart, selectorEnd);
177
-
178
92
  const isSelectorless = (headerByte & PF.FLAG_NO_SELECTOR) !== 0;
179
- if (isSelectorless && selectorHex !== "0x00000000") {
93
+ if (isSelectorless && readU32(data, PF.SELECTOR_OFFSET) !== 0) {
180
94
  throw new CallciumError("MALFORMED_HEADER", "Selectorless policy must have a zeroed selector slot");
181
95
  }
182
96
 
183
- const descLengthStart = PF.DESC_LENGTH_OFFSET;
184
- const descLengthValue = readU16(data, descLengthStart);
185
- if (descLengthValue < 2) {
186
- throw new CallciumError("MALFORMED_HEADER", "Descriptor length must be at least 2");
187
- }
188
-
189
- const descStart = PF.DESC_OFFSET;
190
- const descEnd = descStart + descLengthValue;
191
- const groupCountOffset = descEnd;
192
-
97
+ const descLengthValue = readU16(data, PF.DESC_LENGTH_OFFSET);
98
+ const groupCountOffset = PF.DESC_OFFSET + descLengthValue;
193
99
  if (data.length < groupCountOffset + PF.GROUP_COUNT_SIZE) {
194
100
  throw new CallciumError("MALFORMED_HEADER", "Policy blob is too short for descriptor and group count");
195
101
  }
102
+ decodeDescriptor(data.subarray(PF.DESC_OFFSET, groupCountOffset));
196
103
 
197
- // Decode the embedded descriptor, offsetting spans to be relative to the policy blob.
198
- const descSlice = data.subarray(descStart, descEnd);
199
- const { descriptor: desc } = decodeDescriptor(descSlice);
200
- const params: DecodedParam[] = desc.params.map((param) => ({
201
- ...param,
202
- span: {
203
- start: descStart + param.span.start,
204
- end: descStart + param.span.end,
205
- },
206
- }));
207
-
208
- const descriptorRaw = hexField(data, descStart, descEnd);
209
-
210
- const groupCountStart = groupCountOffset;
211
- const groupCountEnd = groupCountStart + PF.GROUP_COUNT_SIZE;
212
104
  const groupCountValue = data[groupCountOffset]!;
213
105
  if (groupCountValue === 0) {
214
106
  throw new CallciumError("EMPTY_POLICY", "Policy must contain at least one group");
215
107
  }
216
108
 
217
- let offset = groupCountEnd;
218
- const groups: DecodedGroup[] = [];
219
-
109
+ let offset = groupCountOffset + PF.GROUP_COUNT_SIZE;
220
110
  for (let groupIndex = 0; groupIndex < groupCountValue; groupIndex++) {
221
111
  if (offset + PF.GROUP_HEADER_SIZE > data.length) {
222
112
  throw new CallciumError("UNEXPECTED_END", "Unexpected end while reading group header", offset);
223
113
  }
224
114
 
225
- const groupSizeStart = offset + PF.GROUP_RULECOUNT_SIZE;
226
- const groupSizeValue = readU32(data, groupSizeStart);
227
- const groupBodyStart = offset + PF.GROUP_HEADER_SIZE;
228
- const groupEnd = groupBodyStart + groupSizeValue;
229
-
115
+ const groupSizeValue = readU32(data, offset + PF.GROUP_RULECOUNT_SIZE);
116
+ const groupEnd = offset + PF.GROUP_HEADER_SIZE + groupSizeValue;
230
117
  if (groupEnd > data.length) {
231
118
  throw new CallciumError("GROUP_OVERFLOW", "Group extends beyond policy blob", offset);
232
119
  }
233
120
 
234
- const ruleCountStart = offset;
235
- const ruleCountValue = readU16(data, ruleCountStart);
121
+ const ruleCountValue = readU16(data, offset);
236
122
  if (ruleCountValue === 0) {
237
123
  throw new CallciumError("EMPTY_GROUP", "Group must contain at least one rule", offset);
238
124
  }
239
- if (groupSizeValue < ruleCountValue * PF.RULE_MIN_SIZE) {
240
- 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);
241
130
  }
242
131
 
243
- const rules: DecodedRule[] = [];
244
- let ruleOffset = groupBodyStart;
132
+ if (ruleOffset !== groupEnd) groupSizeMismatch(offset);
133
+ offset = groupEnd;
134
+ }
245
135
 
246
- for (let ruleIndex = 0; ruleIndex < ruleCountValue; ruleIndex++) {
247
- if (ruleOffset + PF.RULE_SIZE_SIZE > data.length) {
248
- throw new CallciumError("UNEXPECTED_END", "Unexpected end while reading rule size", ruleOffset);
249
- }
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
+ }
141
+
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);
250
204
 
251
- const ruleSizeValue = readU16(data, ruleOffset);
252
- if (ruleSizeValue < PF.RULE_MIN_SIZE) {
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) {
253
247
  throw new CallciumError(
254
- "RULE_TOO_SMALL",
255
- `Rule size ${ruleSizeValue} is below minimum ${PF.RULE_MIN_SIZE}`,
248
+ "INVALID_CONTEXT_PATH",
249
+ "Context-scope rules must have exactly one path step",
256
250
  ruleOffset,
257
251
  );
258
252
  }
259
- if (ruleOffset + ruleSizeValue > data.length) {
260
- 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
+ );
261
262
  }
262
-
263
- const scopeOffset = ruleOffset + PF.RULE_SCOPE_OFFSET;
264
- const scopeValue = data[scopeOffset]!;
265
- if (scopeValue !== Scope.CONTEXT && scopeValue !== Scope.CALLDATA) {
266
- 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);
267
271
  }
268
-
269
- const depthOffset = ruleOffset + PF.RULE_DEPTH_OFFSET;
270
- const depthValue = data[depthOffset]!;
271
- const pathStart = ruleOffset + PF.RULE_PATH_OFFSET;
272
- const pathLength = depthValue * PF.PATH_STEP_SIZE;
273
-
274
- // Resolve the hint block; context rules carry none.
275
- const ruleEnd = ruleOffset + ruleSizeValue;
276
- const hintStart = pathStart + pathLength;
277
- let hintSize = 0;
278
- if (scopeValue === Scope.CALLDATA) {
279
- hintSize = resolveHintSize(data, hintStart, ruleEnd, ruleOffset);
280
- 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);
281
288
  }
289
+ break;
290
+ }
291
+ default:
292
+ return scopeValue satisfies never;
293
+ }
282
294
 
283
- const opCodeOffset = hintStart + hintSize;
284
- const dataLengthOffset = opCodeOffset + PF.RULE_OPCODE_SIZE;
285
- const dataLengthValue = readU16(data, dataLengthOffset);
295
+ const operatorEnd = validateOperator(data, ruleOffset, operatorOffset, ruleEnd, typeCode);
296
+ if (operatorEnd !== ruleEnd) ruleSizeMismatch(ruleOffset);
297
+ }
286
298
 
287
- // Validate ruleSize matches the computed layout.
288
- const expectedRuleSize = PF.RULE_FIXED_OVERHEAD + pathLength + hintSize + dataLengthValue;
289
- if (ruleSizeValue !== expectedRuleSize) {
290
- throw new CallciumError(
291
- "RULE_SIZE_MISMATCH",
292
- `Declared rule size ${ruleSizeValue} does not match computed size ${expectedRuleSize}`,
293
- ruleOffset,
294
- );
295
- }
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
+ }
296
327
 
297
- // Validate operator and data length.
298
- const opCodeValue = data[opCodeOffset]!;
299
- const opBase = opCodeValue & ~Op.NOT;
300
- if (opBase === 0 || !isValidOperatorData(opBase, dataLengthValue)) {
301
- throw new CallciumError("UNKNOWN_OPERATOR", "Unrecognized or malformed operator", ruleOffset);
302
- }
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);
303
336
 
304
- // A length operator reads the declared length a dynamic target resolves to, and a value operator reads a scalar
305
- // word, so the target type fixes which family the rule may carry.
306
- if (hintSize !== 0) {
307
- const targetTypeCode = hintTargetTypeCode(data, hintStart, hintSize);
308
- if (isLengthValidType(targetTypeCode) !== isLengthOp(opBase)) {
309
- throw new CallciumError(
310
- "OPERATOR_TARGET_MISMATCH",
311
- "Operator family does not match the type the target declares",
312
- ruleOffset,
313
- );
314
- }
315
- }
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
+ }
316
343
 
317
- 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
+ }
318
351
 
319
- // IN operands must be strictly ascending (unsigned); strictness also rejects duplicates.
320
- if (opBase === Op.IN) {
321
- const wordCount = dataLengthValue / 32;
322
- let prev = toBigInt(data, dataStart);
323
- for (let word = 1; word < wordCount; word++) {
324
- const cur = toBigInt(data, dataStart + word * 32);
325
- if (cur <= prev) {
326
- throw new CallciumError("UNSORTED_IN_SET", "IN operands must be strictly ascending", ruleOffset);
327
- }
328
- prev = cur;
329
- }
330
- }
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
+ }
331
362
 
332
- // A context reference operand word must be a defined context property ID.
333
- if (isContextOp(opBase)) {
334
- if (toBigInt(data, dataStart) > BigInt(MAX_CONTEXT_PROPERTY_ID)) {
335
- throw new CallciumError(
336
- "UNKNOWN_CONTEXT_PROPERTY",
337
- "Context reference operand references an undefined context property",
338
- ruleOffset,
339
- );
340
- }
341
- }
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
+ }
342
382
 
343
- // The path must be non-empty and within the depth cap.
344
- if (depthValue === 0) {
345
- throw new CallciumError("EMPTY_PATH", "Rule path must have at least one step", ruleOffset);
346
- }
347
- 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)) {
348
401
  throw new CallciumError(
349
- "PATH_TOO_DEEP",
350
- `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",
351
404
  ruleOffset,
352
405
  );
353
406
  }
354
-
355
- // Context-scope rules must have exactly one path step naming a defined property.
356
- if (scopeValue === Scope.CONTEXT) {
357
- if (depthValue !== 1) {
358
- throw new CallciumError(
359
- "INVALID_CONTEXT_PATH",
360
- "Context-scope rules must have exactly one path step",
361
- ruleOffset,
362
- );
363
- }
364
- const contextPropertyId = readU16(data, pathStart);
365
- if (contextPropertyId > MAX_CONTEXT_PROPERTY_ID) {
366
- throw new CallciumError(
367
- "UNKNOWN_CONTEXT_PROPERTY",
368
- `Context rule references undefined context property ${formatCode(contextPropertyId, PF.PATH_STEP_SIZE)}`,
369
- ruleOffset,
370
- );
371
- }
372
- }
373
-
374
- const pathHex = toHex(data, pathStart, pathStart + pathLength);
375
- rules.push({
376
- ruleSize: field(ruleSizeValue, ruleOffset, ruleOffset + PF.RULE_SIZE_SIZE),
377
- scope: field(scopeValue, scopeOffset, scopeOffset + 1),
378
- pathDepth: field(depthValue, depthOffset, depthOffset + 1),
379
- path: field(pathHex, pathStart, pathStart + pathLength),
380
- ...(hintSize > 0 && {
381
- hint: hexField(data, hintStart, hintStart + hintSize),
382
- }),
383
- opCode: field(opCodeValue, opCodeOffset, opCodeOffset + PF.RULE_OPCODE_SIZE),
384
- dataLength: field(dataLengthValue, dataLengthOffset, dataLengthOffset + PF.RULE_DATALENGTH_SIZE),
385
- data: hexField(data, dataStart, dataStart + dataLengthValue),
386
- span: { start: ruleOffset, end: ruleOffset + ruleSizeValue },
387
- });
388
-
389
- if (ruleOffset + ruleSizeValue > groupEnd) {
390
- 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
+ );
391
417
  }
392
- ruleOffset += ruleSizeValue;
418
+ break;
393
419
  }
394
-
395
- if (ruleOffset !== groupEnd) {
396
- 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;
397
428
  }
398
-
399
- groups.push({
400
- ruleCount: field(ruleCountValue, ruleCountStart, ruleCountStart + PF.GROUP_RULECOUNT_SIZE),
401
- groupSize: field(groupSizeValue, groupSizeStart, groupSizeStart + PF.GROUP_SIZE_SIZE),
402
- rules,
403
- span: { start: offset, end: groupEnd },
404
- });
405
- offset = groupEnd;
429
+ default:
430
+ return source satisfies never;
406
431
  }
432
+ return operatorEnd;
433
+ }
407
434
 
408
- // Reject trailing bytes after the last group.
409
- if (offset !== data.length) {
410
- 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;
411
454
  }
455
+ }
412
456
 
413
- const policy: DecodedPolicy = {
414
- header: field(headerByte, 0, PF.HEADER_SIZE),
415
- selector: field(selectorHex, selectorStart, selectorEnd),
416
- descLength: field(descLengthValue, descLengthStart, descLengthStart + PF.DESC_LENGTH_SIZE),
417
- descriptor: {
418
- get raw(): Hex {
419
- return descriptorRaw.value;
420
- },
421
- set raw(replacement: Hex) {
422
- descriptorRaw.value = replacement;
423
- },
424
- header: field({ version: desc.version, paramCount: desc.params.length }, descStart, descStart + DF.HEADER_SIZE),
425
- params,
426
- span: { start: descStart, end: descEnd },
427
- },
428
- groupCount: field(groupCountValue, groupCountStart, groupCountEnd),
429
- groups,
430
- span: { start: 0, end: data.length },
431
- version,
432
- isSelectorless,
433
- };
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
+ }
461
+
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
+ }
434
480
 
435
- return { policy, data };
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
+ );
436
488
  }
437
489
 
438
490
  ///////////////////////////////////////////////////////////////////////////
439
- // Encoder internals
491
+ // Encoding
440
492
  ///////////////////////////////////////////////////////////////////////////
441
493
 
442
- 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 };
499
+
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
+ }
443
539
 
444
- /** The path bytes and compiled hint every rule a constraint produces carries. */
445
- type ConstraintTarget = { path: Uint8Array; hint: Uint8Array };
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
+ }
446
545
 
447
- /** Read a constraint's path and compile the hint its rules carry. */
448
- function readConstraintTarget(constraint: Constraint, desc: Uint8Array): ConstraintTarget {
449
- const path = hexToBytes(constraint.path);
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
+ }
450
616
 
451
- // Path shape checks precede compilation: the compiler assumes a framed, depth-bounded path.
452
- 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) {
453
623
  throw new CallciumError("EMPTY_PATH", "Rule path must have at least one step");
454
624
  }
455
- if ((path.length & 1) !== 0) {
625
+ if (path.length % PF.PATH_STEP_SIZE !== 0) {
456
626
  throw new CallciumError("MALFORMED_PATH", "Path byte length must be even");
457
627
  }
458
- const depth = path.length / 2;
628
+ const depth = depthBefore + path.length / PF.PATH_STEP_SIZE;
459
629
  if (depth > PF.MAX_PATH_DEPTH) {
460
630
  throw new CallciumError("PATH_TOO_DEEP", `Path depth ${depth} exceeds maximum ${PF.MAX_PATH_DEPTH}`);
461
631
  }
462
-
463
- const hint =
464
- constraint.scope === Scope.CALLDATA
465
- ? Descriptor.compileHint(desc, parsePathSteps(constraint.path))
466
- : new Uint8Array(0);
467
- return { path, hint };
632
+ return depth;
468
633
  }
469
634
 
470
- /** Flatten a Constraint into one Rule per operator, compiling its hint against the descriptor. */
471
- function flattenConstraint(constraint: Constraint, desc: Uint8Array): Rule[] {
472
- // The rule is the unit these checks run over: each rule's operator is established before the path and hint it shares
473
- // with its siblings. A constraint carrying no operator produces no rule, so its path is never read.
474
- let target: ConstraintTarget | undefined;
635
+ ///////////////////////////////////////////////////////////////////////////
636
+ // Normalization
637
+ ///////////////////////////////////////////////////////////////////////////
475
638
 
476
- return constraint.operators.map((operatorHex) => {
477
- const operator = toOperatorBytes(operatorHex);
478
- target ??= readConstraintTarget(constraint, desc);
479
- return { scope: constraint.scope, operator, ...target };
480
- });
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));
481
646
  }
482
647
 
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]!;
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;
488
682
  }
489
- return a.length - b.length;
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 };
490
699
  }
491
700
 
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);
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));
502
722
  });
723
+ return joined;
503
724
  }
504
725
 
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}`);
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];
509
738
  }
510
739
 
511
740
  /**
512
- * Serialize a single rule to its wire format bytes. `flattenConstraint` establishes path shape and operator framing.
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.
513
743
  */
514
- function encodeRule(rule: Rule): Uint8Array {
515
- const depth = rule.path.length / 2;
516
- if (rule.scope === Scope.CONTEXT && depth !== 1) {
517
- throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope rules must have exactly one path step");
518
- }
519
- const data = rule.operator.subarray(1);
520
- const ruleSize = PF.RULE_FIXED_OVERHEAD + rule.path.length + rule.hint.length + data.length;
521
- requireFieldFits(ruleSize, PF.RULE_SIZE_SIZE, "RULE_SIZE_OVERFLOW", "Rule size");
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
+ }
522
751
 
523
- const buf = new Uint8Array(ruleSize);
524
- writeBE16(buf, 0, ruleSize);
525
- buf[2] = rule.scope;
526
- buf[3] = depth;
527
- buf.set(rule.path, 4);
528
- buf.set(rule.hint, 4 + rule.path.length);
529
- const opOffset = 4 + rule.path.length + rule.hint.length;
530
- buf[opOffset] = rule.operator[0]!;
531
- writeBE16(buf, opOffset + 1, data.length);
532
- buf.set(data, opOffset + 3);
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)];
778
+ });
779
+ }
780
+
781
+ /** The bound a rule places: whether it bounds a length, and whether from below. */
782
+ type Bound = { isLength: boolean; isLower: boolean };
533
783
 
534
- return buf;
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;
535
805
  }
536
806
 
537
- /** Serialize all rules in a group to a single byte array. */
538
- function encodeGroupRules(rules: Rule[]): Uint8Array {
539
- return concatBytes(...rules.map(encodeRule));
807
+ /**
808
+ * Return the bound the rule places, an unnegated constant `GTE`, `LTE`, `LENGTH_GTE` or `LENGTH_LTE`, or undefined.
809
+ */
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
+ }
540
826
  }
541
827
 
542
828
  ///////////////////////////////////////////////////////////////////////////
543
- // PolicyCoder
829
+ // Rule bytes
544
830
  ///////////////////////////////////////////////////////////////////////////
545
831
 
546
- /** Build a single operator hex string from a decoded rule's opCode and data. */
547
- function buildOperatorHex(rule: DecodedRule): Hex {
548
- const opCodeHex = rule.opCode.value.toString(16).padStart(2, "0");
549
- const dataBody = rule.data.value.slice(2);
550
- 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);
551
872
  }
552
873
 
553
874
  /**
554
- * Encode a PolicyData structure into the canonical binary format.
555
- * @param data - The policy data to encode.
556
- * @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.
557
877
  */
558
- function encode(data: PolicyData): Hex {
559
- 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
+ }
560
884
 
561
- if (data.groups.length === 0) {
562
- 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
+ }
563
902
  }
564
- 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
+ }
565
906
 
566
- // Flatten constraints into rules and sort within each group. Hints are compiled first because they are part of the
567
- // rule bytes the group hash covers.
568
- const sortedGroups: Rule[][] = data.groups.map((group) => {
569
- const rules = group.flatMap((constraint) => flattenConstraint(constraint, descBytes));
570
- sortRules(rules);
571
- return rules;
572
- });
907
+ ///////////////////////////////////////////////////////////////////////////
908
+ // Decoding
909
+ ///////////////////////////////////////////////////////////////////////////
573
910
 
574
- 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);
575
920
 
576
- const encodedGroups = sortedGroups.map((rules, groupIndex) => {
577
- if (rules.length === 0) {
578
- 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);
579
934
  }
580
- requireFieldFits(rules.length, PF.GROUP_RULECOUNT_SIZE, "RULE_COUNT_OVERFLOW", `Group ${groupIndex} rule count`);
581
- return { wireBytes: encodeGroupRules(rules), ruleCount: rules.length };
582
- });
583
-
584
- // Several groups are ordered by ascending keccak256 over their rule bytes; a lone group is already in canonical
585
- // position, so it needs no hash.
586
- if (encodedGroups.length > 1) {
587
- const hashes = new Map(encodedGroups.map((group) => [group, keccak_256(group.wireBytes)]));
588
- encodedGroups.sort((a, b) => compareBytes(hashes.get(a)!, hashes.get(b)!));
935
+ groups.push(rules);
589
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
+ }
590
944
 
591
- const selectorBytes = data.isSelectorless ? new Uint8Array(PF.SELECTOR_SIZE) : hexToBytes(data.selector);
592
- if (selectorBytes.length !== PF.SELECTOR_SIZE) {
593
- throw new CallciumError(
594
- "MALFORMED_SELECTOR",
595
- `Selector must be ${PF.SELECTOR_SIZE} bytes, got ${selectorBytes.length}`,
596
- );
597
- }
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);
598
948
 
599
- 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
+ }
600
974
 
601
- // Pre-compute total size.
602
- let totalSize = PF.HEADER_SIZE + PF.SELECTOR_SIZE + PF.DESC_LENGTH_SIZE + descBytes.length + PF.GROUP_COUNT_SIZE;
603
- for (const g of encodedGroups) {
604
- 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;
605
995
  }
606
996
 
607
- const out = new Uint8Array(totalSize);
608
- 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
+ }
609
1002
 
610
- // Policy header: header(1) | selector(4) | descLength(2) | desc(N) | groupCount(1).
611
- 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
+ }
612
1012
 
613
- out.set(selectorBytes, offset);
614
- offset += PF.SELECTOR_SIZE;
1013
+ ///////////////////////////////////////////////////////////////////////////
1014
+ // Inspection
1015
+ ///////////////////////////////////////////////////////////////////////////
615
1016
 
616
- writeBE16(out, offset, descBytes.length);
617
- 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);
618
1027
 
619
- out.set(descBytes, offset);
620
- 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);
621
1042
 
622
- 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
+ }
623
1059
 
624
- for (const g of encodedGroups) {
625
- // Group header: ruleCount(2) | groupSize(4).
626
- writeBE16(out, offset, g.ruleCount);
627
- 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
+ }
628
1070
 
629
- writeBE32(out, offset, g.wireBytes.length);
630
- 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);
631
1074
 
632
- out.set(g.wireBytes, offset);
633
- 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);
634
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
+ }
635
1106
 
636
- 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
+ };
637
1144
  }
638
1145
 
639
- /**
640
- * Decode a binary policy blob into a PolicyData structure.
641
- * @param blob - Binary policy as 0x-prefixed hex string.
642
- * @returns The decoded policy data with constraints grouped by scope, path, and hint.
643
- * @throws {CallciumError} If the blob is structurally malformed.
644
- */
645
- function decode(blob: Hex): PolicyData {
646
- const { policy } = decodePolicy(blob);
647
-
648
- const groups: Constraint[][] = policy.groups.map((group) => {
649
- // A Map keeps its insertion order, which is the order the rules named their constraints in.
650
- const constraintMap = new Map<string, Constraint>();
651
-
652
- for (const rule of group.rules) {
653
- const hintHex = rule.hint?.value;
654
- const key = `${rule.scope.value}:${rule.path.value}:${hintHex ?? ""}`;
655
- const opHex = buildOperatorHex(rule);
656
-
657
- const existing = constraintMap.get(key);
658
- if (existing !== undefined) {
659
- existing.operators.push(opHex);
660
- } else {
661
- const constraint: Constraint = {
662
- scope: rule.scope.value,
663
- path: rule.path.value,
664
- operators: [opHex],
665
- ...(hintHex !== undefined && { hint: hintHex }),
666
- span: rule.span,
667
- };
668
- constraintMap.set(key, constraint);
669
- }
670
- }
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
+ }
671
1167
 
672
- return [...constraintMap.values()];
673
- });
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
+ }
674
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);
675
1184
  return {
676
- isSelectorless: policy.isSelectorless,
677
- selector: policy.selector.value,
678
- descriptor: policy.descriptor.raw,
679
- groups,
680
- 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 },
681
1191
  };
682
1192
  }
683
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
+
684
1286
  /**
685
- * Inspect a binary policy blob, returning the structural representation with full byte-level spans.
686
- * @param blob - Binary policy as 0x-prefixed hex string.
687
- * @returns The inspected policy with per-field spans for every structural element.
688
- * @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.
689
1289
  */
690
- function inspect(blob: Hex): DecodedPolicy {
691
- 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;
692
1325
  }
693
1326
 
694
- /** Encode, decode, and inspect policies in the canonical binary format. */
1327
+ /** Encode policies in canonical form, and decode and inspect policy blobs. */
695
1328
  export const PolicyCoder = { encode, decode, inspect };