@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,1169 +1,680 @@
1
- import { bigintToHex, bytesToHex, hexToBytes } from "./bytes";
1
+ import { bigintToHex, bytesToHex, hexToBytes, readU16 } from "./bytes";
2
2
  import {
3
- fieldMax,
4
- findContextProperty,
3
+ comparisonOf,
4
+ isComparisonCode,
5
5
  MAX_CONTEXT_PROPERTY_ID,
6
6
  Op,
7
- operandsOf,
8
- PolicyFormat,
7
+ PolicyFormat as PF,
9
8
  Scope,
9
+ Source,
10
+ sourceOf,
10
11
  TypeCode,
11
12
  } from "./constants";
12
- import { Descriptor, type TypeInfo } from "./descriptor";
13
- import { decodeDescriptor } from "./descriptor-coder";
14
- import { CallciumError, type CallciumErrorCode } from "./errors";
13
+ import { Descriptor } from "./descriptor";
14
+ import { Hint } from "./hint";
15
15
  import {
16
- canonicalize,
16
+ acceptsContextSource,
17
+ contextPropertyType,
18
+ hasCalldataLength,
17
19
  isLeftAligned,
18
20
  isSigned,
19
- isContextOp,
20
- isLengthOp,
21
- isLengthValidType,
22
- isElementary,
23
- isValidOperatorData,
24
- toOperatorBytes,
21
+ operandTypeOf,
22
+ operandWidth,
23
+ toBigInt,
25
24
  } from "./operators";
26
- import { Quantifier, isQuantifier, parsePathSteps } from "./path";
25
+ import { parsePathSteps } from "./path";
26
+ import { PolicyCoder, validatePolicy } from "./policy-coder";
27
27
  import * as ValidationIssue from "./validation-issue";
28
28
 
29
- import type { Constraint, Hex, Issue, IssueCode, PolicyData } from "./types";
29
+ import type { BodyRule, Hex, Issue, IssueCode, LoopRule, Rule, RuleLocation } from "./types";
30
30
 
31
31
  ///////////////////////////////////////////////////////////////////////////
32
- // Internal types
32
+ // Compatibility
33
33
  ///////////////////////////////////////////////////////////////////////////
34
34
 
35
- /** Codes a path walk raises when the path does not navigate the descriptor (PV-1). */
36
- const navigabilityCodes: ReadonlySet<CallciumErrorCode> = new Set<CallciumErrorCode>([
37
- "PARAM_INDEX_OUT_OF_BOUNDS",
38
- "TUPLE_FIELD_OUT_OF_BOUNDS",
39
- "STATIC_ARRAY_INDEX_OUT_OF_BOUNDS",
40
- "NOT_COMPOSITE",
41
- ]);
42
-
43
- type BoundDomain = {
44
- isSigned: boolean;
45
- min: bigint;
46
- max: bigint;
47
- hasEq: boolean;
48
- eq: bigint;
49
- hasLower: boolean;
50
- lower: bigint;
51
- lowerInclusive: boolean;
52
- hasUpper: boolean;
53
- upper: bigint;
54
- upperInclusive: boolean;
55
- holes: bigint[];
56
- };
57
-
58
- type BitmaskDomain = {
59
- mustBeOne: bigint;
60
- mustBeZero: bigint;
61
- };
62
-
63
- type SetDomain = {
64
- hasIn: boolean;
65
- inValues: bigint[];
66
- notInValues: bigint[];
67
- };
68
-
69
- type ConstraintContext = {
70
- scope: number;
71
- path: Hex;
72
- /** Path steps of every constraint sharing this context. */
73
- steps: number[];
74
- typeInfo: TypeInfo;
75
- numeric: BoundDomain;
76
- bitmask: BitmaskDomain;
77
- length: BoundDomain;
78
- set: SetDomain;
79
- };
80
-
81
- ///////////////////////////////////////////////////////////////////////////
82
- // Constants
83
- ///////////////////////////////////////////////////////////////////////////
84
-
85
- /**
86
- * Maximum length value for dynamic types.
87
- */
88
- const LENGTH_MAX = 0xffffffffn;
89
-
90
- // Mask for reinterpreting uint256 as int256 (two's complement).
91
- const UINT256_MAX = (1n << 256n) - 1n;
92
- const INT256_SIGN_BIT = 1n << 255n;
93
-
94
- ///////////////////////////////////////////////////////////////////////////
95
- // Signed-aware comparisons
96
- ///////////////////////////////////////////////////////////////////////////
97
-
98
- /** Signed-aware difference of two raw uint256 values: negative if a<b, zero if equal, positive if a>b. */
99
- function signedCompare(a: bigint, b: bigint, signed: boolean): bigint {
100
- if (!signed) return a - b;
101
- const toSigned = (word: bigint) => (word >= INT256_SIGN_BIT ? word - (1n << 256n) : word);
102
- return toSigned(a) - toSigned(b);
103
- }
104
-
105
- /** True when a value lies outside the domain's physical bounds. */
106
- function isOutsideDomain(domain: BoundDomain, value: bigint): boolean {
107
- return (
108
- signedCompare(value, domain.min, domain.isSigned) < 0n || signedCompare(value, domain.max, domain.isSigned) > 0n
109
- );
35
+ /** Return true for a `uintN` type code. */
36
+ function isUnsigned(typeCode: number): boolean {
37
+ return typeCode >= TypeCode.UINT_MIN && typeCode <= TypeCode.UINT_MAX;
110
38
  }
111
39
 
112
- ///////////////////////////////////////////////////////////////////////////
113
- // Type classification
114
- ///////////////////////////////////////////////////////////////////////////
115
-
116
- /** True for uint and int type codes. */
117
- function isNumericType(typeCode: number): boolean {
118
- return (
119
- (typeCode >= TypeCode.UINT_MIN && typeCode <= TypeCode.UINT_MAX) ||
120
- (typeCode >= TypeCode.INT_MIN && typeCode <= TypeCode.INT_MAX)
121
- );
122
- }
123
-
124
- /** True for uint and bytes32 type codes. */
125
- function isBitmaskCompatible(typeCode: number): boolean {
126
- return (typeCode >= TypeCode.UINT_MIN && typeCode <= TypeCode.UINT_MAX) || typeCode === TypeCode.FIXED_BYTES_MIN + 31;
40
+ /** Return true for a `uintN` or `intN` type code. */
41
+ function isNumeric(typeCode: number): boolean {
42
+ return isUnsigned(typeCode) || (typeCode >= TypeCode.INT_MIN && typeCode <= TypeCode.INT_MAX);
127
43
  }
128
44
 
129
- /** Physical domain limits for a type code (returned as raw uint256 bigints). */
130
- function getDomainLimits(typeCode: number): { min: bigint; max: bigint } {
131
- if (typeCode >= TypeCode.UINT_MIN && typeCode <= TypeCode.UINT_MAX) {
132
- const bits = BigInt(typeCode - TypeCode.UINT_MIN + 1) * 8n;
133
- return { min: 0n, max: bits === 256n ? UINT256_MAX : (1n << bits) - 1n };
45
+ /** Return the code and message a constant comparison raises against its target type, or undefined when it applies. */
46
+ function incompatibility(comparison: number, typeCode: number): { code: IssueCode; message: string } | undefined {
47
+ const isOrdering =
48
+ comparison === Op.GT ||
49
+ comparison === Op.LT ||
50
+ comparison === Op.GTE ||
51
+ comparison === Op.LTE ||
52
+ comparison === Op.BETWEEN;
53
+ if (isOrdering && !isNumeric(typeCode)) {
54
+ return { code: "NUMERIC_OP_ON_NON_NUMERIC", message: "Comparison operator used on non-numeric type" };
134
55
  }
135
- if (typeCode >= TypeCode.INT_MIN && typeCode <= TypeCode.INT_MAX) {
136
- const bits = BigInt(typeCode - TypeCode.INT_MIN + 1) * 8n;
137
- if (bits === 256n) {
138
- // int256: min = uint256(type(int256).min), max = uint256(type(int256).max).
139
- return { min: INT256_SIGN_BIT, max: INT256_SIGN_BIT - 1n };
140
- }
141
- const half = 1n << (bits - 1n);
142
- // Stored as raw uint256: min is two's complement of -(2^(bits-1)).
143
- const max = half - 1n;
144
- const min = (UINT256_MAX - half + 1n) & UINT256_MAX;
145
- return { min, max };
56
+ if (comparison === Op.IN && typeCode === TypeCode.BOOL) {
57
+ return { code: "IN_ON_BOOL", message: "IN operator used on boolean type" };
146
58
  }
147
- if (typeCode === TypeCode.BOOL) return { min: 0n, max: 1n };
148
- if (typeCode === TypeCode.ADDRESS) return { min: 0n, max: (1n << 160n) - 1n };
149
- return { min: 0n, max: UINT256_MAX };
150
- }
151
-
152
- ///////////////////////////////////////////////////////////////////////////
153
- // Operator classification
154
- ///////////////////////////////////////////////////////////////////////////
155
-
156
- /** True if an operator code is in the value-operator range (unassigned codes included). */
157
- function isValueOp(opBase: number): boolean {
158
- return opBase <= Op.BITMASK_NONE;
59
+ const isMask = comparison === Op.BITMASK_ALL || comparison === Op.BITMASK_ANY || comparison === Op.BITMASK_NONE;
60
+ if (isMask && (typeCode === TypeCode.BOOL || typeCode === TypeCode.FUNCTION)) {
61
+ return { code: "BITMASK_ON_INVALID", message: "Bitmask operator used on incompatible type" };
62
+ }
63
+ return undefined;
159
64
  }
160
65
 
161
- /** True for GT, LT, GTE, LTE, BETWEEN. */
162
- function isComparisonOp(opBase: number): boolean {
163
- return opBase >= Op.GT && opBase <= Op.BETWEEN;
66
+ /** Return true when a context operand of `propertyType` pairs with a target of `typeCode` under `comparison`. */
67
+ function pairsWithContext(comparison: number, typeCode: number, propertyType: number): boolean {
68
+ if (isUnsigned(typeCode)) return propertyType === TypeCode.UINT_MAX;
69
+ return comparison === Op.EQ && typeCode === TypeCode.ADDRESS && propertyType === TypeCode.ADDRESS;
164
70
  }
165
71
 
166
- /** True for BITMASK_ALL, BITMASK_ANY, BITMASK_NONE. */
167
- function isBitmaskOp(opBase: number): boolean {
168
- return opBase >= Op.BITMASK_ALL && opBase <= Op.BITMASK_NONE;
72
+ /**
73
+ * Determine whether the compatibility matrix admits an operator on a target type. A context operand is admitted on a
74
+ * target its comparison can pair with a context property. These targets are `uintN` under `EQ`, `GT`, `LT`, `GTE` and
75
+ * `LTE`, and `address` under `EQ`.
76
+ * @param opCode - The operator code, with or without the NOT flag.
77
+ * @param typeCode - The target's type code.
78
+ * @returns True when the operator applies to the target.
79
+ */
80
+ export function isOpAllowed(opCode: number, typeCode: number): boolean {
81
+ const comparison = comparisonOf(opCode);
82
+ if (!isComparisonCode(comparison)) return false;
83
+ if (sourceOf(opCode) === Source.CONTEXT) {
84
+ if (!acceptsContextSource(comparison)) return false;
85
+ return isUnsigned(typeCode) || (comparison === Op.EQ && typeCode === TypeCode.ADDRESS);
86
+ }
87
+ if (sourceOf(opCode) !== Source.CONSTANT) return false;
88
+ return operandTypeOf(comparison, typeCode) !== undefined && incompatibility(comparison, typeCode) === undefined;
169
89
  }
170
90
 
171
91
  ///////////////////////////////////////////////////////////////////////////
172
- // Type compatibility check
92
+ // Rule checks
173
93
  ///////////////////////////////////////////////////////////////////////////
174
94
 
175
- /** Return the incompatibility reason, or null if the operator is allowed. */
176
- function checkCompatibility(opBase: number, typeInfo: TypeInfo): { code: IssueCode; message: string } | null {
177
- const { typeCode, isDynamic, staticSize } = typeInfo;
178
- if (isValueOp(opBase)) {
179
- if (isDynamic || staticSize !== 32) {
180
- return { code: "VALUE_OP_ON_DYNAMIC", message: "Value operator used on dynamic type" };
181
- }
182
- if (!isElementary(typeCode)) {
183
- return { code: "VALUE_OP_ON_COMPOSITE", message: "Value operator used on composite type" };
184
- }
185
- if (isComparisonOp(opBase) && !isNumericType(typeCode)) {
186
- return { code: "NUMERIC_OP_ON_NON_NUMERIC", message: "Comparison operator used on non-numeric type" };
187
- }
188
- if (isBitmaskOp(opBase) && !isBitmaskCompatible(typeCode)) {
189
- return { code: "BITMASK_ON_INVALID", message: "Bitmask operator used on incompatible type" };
190
- }
191
- // IN on bool is degenerate: two values reduce every set to an equality, a tautology, or dead members.
192
- if (opBase === Op.IN && typeCode === TypeCode.BOOL) {
193
- return { code: "IN_ON_BOOL", message: "IN operator used on boolean type" };
194
- }
195
- // Context properties are addresses or uint256, so only those target classes can match, and an address is an
196
- // identity rather than a magnitude, so it pairs under equality only.
197
- if (
198
- isContextOp(opBase) &&
199
- !(typeCode >= TypeCode.UINT_MIN && typeCode <= TypeCode.UINT_MAX) &&
200
- !(opBase === Op.EQ_CTX && typeCode === TypeCode.ADDRESS)
201
- ) {
202
- return { code: "CONTEXT_TYPE_MISMATCH", message: "Context reference operator used on incompatible type" };
203
- }
204
- return null;
95
+ /**
96
+ * Push the PV-5 and PV-6 issues of a test's operator against a target of `typeCode`.
97
+ * Return true when the operator raises none.
98
+ */
99
+ function checkOperator(test: BodyRule, typeCode: number, location: RuleLocation, issues: Issue[]): boolean {
100
+ const operator = hexToBytes(test.operator);
101
+ const opCode = operator[0]!;
102
+ const comparison = comparisonOf(opCode);
103
+ if (sourceOf(opCode) === Source.CONTEXT) {
104
+ const propertyId = Number(toBigInt(operator, 1));
105
+ if (pairsWithContext(comparison, typeCode, contextPropertyType(propertyId))) return true;
106
+ issues.push(ValidationIssue.contextTypeMismatch(location, propertyId, typeCode));
107
+ return false;
205
108
  }
206
- if (isLengthOp(opBase)) {
207
- if (!isLengthValidType(typeCode)) {
208
- return { code: "LENGTH_ON_STATIC", message: "Length operator used on non-dynamic type" };
109
+
110
+ const incompatible = incompatibility(comparison, typeCode);
111
+ if (incompatible !== undefined) {
112
+ issues.push(ValidationIssue.fromOpRule(incompatible.code, incompatible.message, location, opCode));
113
+ return false;
114
+ }
115
+ if (typeCode !== TypeCode.BOOL) return true;
116
+ for (let offset = 1; offset < operator.length; offset += 32) {
117
+ const value = toBigInt(operator, offset);
118
+ if (value > 1n) {
119
+ issues.push(ValidationIssue.outOfPhysicalBounds(location, bigintToHex(value)));
120
+ return false;
209
121
  }
210
- return null;
211
122
  }
212
- return { code: "UNKNOWN_OPERATOR", message: "Unknown operator code" };
123
+ return true;
213
124
  }
214
125
 
215
126
  /**
216
- * True if operator `opCode` is a defined operator allowed on a target with the given type info. Tolerates the NOT flag
217
- * on `opCode`.
127
+ * Push the issues of a test, from the arguments or, given `element`, from the loop element whose node lies there.
128
+ * Returns the test, or undefined when an issue leaves it without a target to analyze or its hint differs from its
129
+ * path's.
218
130
  */
219
- export function isOpAllowed(opCode: number, typeInfo: TypeInfo): boolean {
220
- const opBase = opCode & ~Op.NOT;
221
- return operandsOf(opBase) !== undefined && checkCompatibility(opBase, typeInfo) === null;
131
+ function checkTest(
132
+ desc: Uint8Array,
133
+ test: BodyRule,
134
+ element: number | undefined,
135
+ location: RuleLocation,
136
+ issues: Issue[],
137
+ ): BodyRule | undefined {
138
+ let typeCode: number;
139
+ let hintMatches = true;
140
+ if (test.scope === Scope.CONTEXT) {
141
+ typeCode = contextPropertyType(readU16(hexToBytes(test.path), 0));
142
+ } else {
143
+ const compiled = Hint.compileTest(desc, parsePathSteps(test.path), element);
144
+ if ("fault" in compiled) {
145
+ issues.push(ValidationIssue.unnavigablePath(location, compiled.stepIndex, compiled.bound));
146
+ return undefined;
147
+ }
148
+ hintMatches = bytesToHex(compiled.hint) === test.hint;
149
+ if (!hintMatches) issues.push(ValidationIssue.hintMismatch(location));
150
+ typeCode = desc[compiled.node]!;
151
+ }
152
+ // A test whose hint differs from its path's has constants sized to the hint's type, so it leaves the analysis.
153
+ return checkOperator(test, typeCode, location, issues) && hintMatches ? test : undefined;
222
154
  }
223
155
 
224
- ///////////////////////////////////////////////////////////////////////////
225
- // Payload reading
226
- ///////////////////////////////////////////////////////////////////////////
227
-
228
- /** Read a single uint256 from operator hex (bytes after the opcode byte). */
229
- function readValue(opHex: Hex): bigint {
230
- return BigInt(`0x${opHex.slice(4)}`);
156
+ /** Return the declared length a loop's frame hint carries, or zero for a dynamic array. */
157
+ function frameCount(hint: Uint8Array): number {
158
+ const hopCount = hint[0]!;
159
+ return readU16(hint, PF.HINT_HEADER_SIZE + hopCount * PF.HINT_HOP_SIZE + PF.HINT_FRAME_COUNT_OFFSET);
231
160
  }
232
161
 
233
- /** First payload word deviating from the canonical encoding for the type, with its canonical form, or null. */
234
- function findNonCanonicalWord(opHex: Hex, typeCode: number): { word: bigint; canonical: bigint } | null {
235
- const body = opHex.slice(4);
236
- for (let i = 0; i + 64 <= body.length; i += 64) {
237
- const word = BigInt(`0x${body.slice(i, i + 64)}`);
238
- const canonical = canonicalize(word, typeCode);
239
- if (canonical !== word) return { word, canonical };
162
+ /**
163
+ * Push the issues of a loop and its body. Returns the loop without the body tests left without a target. The result is
164
+ * undefined when the loop's array path does not compile or no body test is left.
165
+ */
166
+ function checkLoop(desc: Uint8Array, loop: LoopRule, location: RuleLocation, issues: Issue[]): LoopRule | undefined {
167
+ const compiled = Hint.compileLoop(desc, parsePathSteps(loop.path));
168
+ if ("fault" in compiled) {
169
+ issues.push(
170
+ compiled.fault === "NOT_ARRAY"
171
+ ? ValidationIssue.quantifierOnNonArray(location, compiled.bound)
172
+ : ValidationIssue.unnavigablePath(location, compiled.stepIndex, compiled.bound),
173
+ );
174
+ return undefined;
175
+ }
176
+ if (bytesToHex(compiled.hint) !== loop.hint) issues.push(ValidationIssue.hintMismatch(location));
177
+ const count = frameCount(compiled.hint);
178
+ if (count > PF.MAX_QUANTIFIED_ARRAY_LENGTH) {
179
+ issues.push(ValidationIssue.quantifierOverStaticLimit(location, count, PF.MAX_QUANTIFIED_ARRAY_LENGTH));
240
180
  }
241
- return null;
242
- }
243
181
 
244
- /** Read a pair of uint256 values from a BETWEEN operator. */
245
- function readPair(opHex: Hex): { low: bigint; high: bigint } {
246
- const body = opHex.slice(4);
247
- return {
248
- low: BigInt(`0x${body.slice(0, 64)}`),
249
- high: BigInt(`0x${body.slice(64, 128)}`),
250
- };
182
+ const body: BodyRule[] = [];
183
+ loop.body.forEach((test, bodyRuleIndex) => {
184
+ const testLocation = { ...location, bodyRuleIndex, scope: test.scope, path: test.path, operator: test.operator };
185
+ const kept = checkTest(desc, test, compiled.node, testLocation, issues);
186
+ if (kept !== undefined) body.push(kept);
187
+ });
188
+ return body.length === 0 ? undefined : { ...loop, body };
251
189
  }
252
190
 
253
- /** Unpack an IN operator's payload into an array of bigint values. */
254
- function unpackSet(opHex: Hex): bigint[] {
255
- const body = opHex.slice(4);
256
- const count = body.length / 64;
257
- const values: bigint[] = [];
258
- for (let i = 0; i < count; i++) {
259
- values.push(BigInt(`0x${body.slice(i * 64, (i + 1) * 64)}`));
260
- }
261
- return values;
191
+ /** The identity of a loop within its group across normalization: its quantifier and its array path. */
192
+ function loopKey(loop: LoopRule): string {
193
+ return `${loop.quantifier}:${loop.path}`;
262
194
  }
263
195
 
264
- /** True if every element is strictly greater than the previous. */
265
- function isStrictlyAscending(values: bigint[]): boolean {
266
- for (let i = 1; i < values.length; i++) {
267
- if (values[i]! <= values[i - 1]!) return false;
268
- }
269
- return true;
196
+ /**
197
+ * Push a PV-8 issue for each loop of a group's normal form whose body contains no calldata test. Skip a loop if the
198
+ * group lost a test from a loop with the same quantifier and array path.
199
+ */
200
+ function checkBodies(rules: Rule[], groupIndex: number, lost: ReadonlySet<string>, issues: Issue[]): void {
201
+ rules.forEach((rule, ruleIndex) => {
202
+ if (rule.scope !== Scope.LOOP || lost.has(loopKey(rule))) return;
203
+ if (rule.body.some((test) => test.scope === Scope.CALLDATA)) return;
204
+ issues.push(ValidationIssue.contextOnlyBody({ groupIndex, ruleIndex, scope: rule.scope, path: rule.path }));
205
+ });
270
206
  }
271
207
 
272
208
  ///////////////////////////////////////////////////////////////////////////
273
- // Opcode normalization
209
+ // Subject analysis
274
210
  ///////////////////////////////////////////////////////////////////////////
275
211
 
276
- /** Convert LENGTH_* opcode to its core comparison equivalent. */
277
- function normalizeLengthOp(base: number): number {
278
- if (base === Op.LENGTH_EQ) return Op.EQ;
279
- if (base === Op.LENGTH_GT) return Op.GT;
280
- if (base === Op.LENGTH_LT) return Op.LT;
281
- if (base === Op.LENGTH_GTE) return Op.GTE;
282
- if (base === Op.LENGTH_LTE) return Op.LTE;
283
- return base;
284
- }
212
+ /** The values of a subject as keys: `width`-bit patterns ordered unsigned, a signed one with its top bit flipped. */
213
+ type Domain = {
214
+ width: number;
215
+ /** The greatest key. */
216
+ max: bigint;
217
+ /** The top bit of a signed type's pattern, zero otherwise. */
218
+ flip: bigint;
219
+ /** The bits below a left-aligned type's pattern in its word, zero otherwise. */
220
+ shift: bigint;
221
+ };
285
222
 
286
- /** Convert a negated bound opcode to its positive equivalent. */
287
- function negateBoundOp(base: number): number {
288
- if (base === Op.GT) return Op.LTE;
289
- if (base === Op.GTE) return Op.LT;
290
- if (base === Op.LT) return Op.GTE;
291
- if (base === Op.LTE) return Op.GT;
292
- return base;
293
- }
223
+ /** The relations of a subject to a context property's value: below it, equal to it, above it. */
224
+ const LESS = 1;
225
+ const EQUAL = 2;
226
+ const GREATER = 4;
227
+ const ANY_RELATION = LESS | EQUAL | GREATER;
228
+
229
+ /** The sides of a subject's range a rule bounds. */
230
+ const LOWER_SIDE = 1;
231
+ const UPPER_SIDE = 2;
232
+
233
+ /** A rule's condition on its subject's key. */
234
+ type Literal =
235
+ /** Keys from `lower` to `upper`, none when `lower` exceeds `upper`. */
236
+ | { kind: "range"; negated: boolean; lower: bigint; upper: bigint }
237
+ /** The member keys. */
238
+ | { kind: "set"; negated: boolean; members: bigint[] }
239
+ /** Keys with every bit of `ones` set and every bit of `zeros` clear. */
240
+ | { kind: "bits"; negated: boolean; ones: bigint; zeros: bigint }
241
+ /** Keys in one of `relations` to the value of a context property. */
242
+ | { kind: "relation"; negated: boolean; property: number; relations: number };
243
+
244
+ /** A rule on a subject: where it lies, its literal, the context properties it reads and the sides it bounds. */
245
+ type SubjectRule = { location: RuleLocation; literal: Literal; reads: number; sides: number };
246
+
247
+ /** The first `count` literals, the one at `flipped` negated, with their range and fixed bits folded. */
248
+ type Conjunction = {
249
+ literals: readonly Literal[];
250
+ count: number;
251
+ flipped: number;
252
+ lower: bigint;
253
+ upper: bigint;
254
+ ones: bigint;
255
+ zeros: bigint;
256
+ };
294
257
 
295
- ///////////////////////////////////////////////////////////////////////////
296
- // Context initialization
297
- ///////////////////////////////////////////////////////////////////////////
258
+ /** Index of no literal. */
259
+ const NO_LITERAL = -1;
298
260
 
299
- /** Fresh bound domain with no constraints recorded yet. */
300
- function emptyBoundDomain(boundIsSigned: boolean, min: bigint, max: bigint): BoundDomain {
261
+ /** Return the domain of a subject of `typeCode`. A length compares as 256 bits. */
262
+ function domainOf(typeCode: number): Domain {
263
+ const width = typeCode === TypeCode.BOOL ? 1 : 8 * (operandWidth(typeCode) ?? 32);
301
264
  return {
302
- isSigned: boundIsSigned,
303
- min,
304
- max,
305
- hasEq: false,
306
- eq: 0n,
307
- hasLower: false,
308
- lower: 0n,
309
- lowerInclusive: false,
310
- hasUpper: false,
311
- upper: 0n,
312
- upperInclusive: false,
313
- holes: [],
265
+ width,
266
+ max: (1n << BigInt(width)) - 1n,
267
+ flip: isSigned(typeCode) ? 1n << BigInt(width - 1) : 0n,
268
+ shift: isLeftAligned(typeCode) ? BigInt(256 - width) : 0n,
314
269
  };
315
270
  }
316
271
 
317
- /** Create a fresh constraint context with domain limits from the type. */
318
- function initContext(scope: number, path: Hex, steps: number[], typeInfo: TypeInfo): ConstraintContext {
319
- const { min, max } = getDomainLimits(typeInfo.typeCode);
320
- return {
321
- scope,
322
- path,
323
- steps,
324
- typeInfo,
325
- numeric: emptyBoundDomain(isSigned(typeInfo.typeCode), min, max),
326
- // Bits above the target's width are zero in every canonical value.
327
- bitmask: { mustBeOne: 0n, mustBeZero: isBitmaskCompatible(typeInfo.typeCode) ? UINT256_MAX ^ max : 0n },
328
- length: emptyBoundDomain(false, 0n, LENGTH_MAX),
329
- set: {
330
- hasIn: false,
331
- inValues: [],
332
- notInValues: [],
333
- },
334
- };
272
+ /** Return the pattern a loaded word contains. */
273
+ function patternOf(domain: Domain, word: bigint): bigint {
274
+ return (word >> domain.shift) & domain.max;
335
275
  }
336
276
 
337
- ///////////////////////////////////////////////////////////////////////////
338
- // Bound domain update
339
- ///////////////////////////////////////////////////////////////////////////
340
-
341
- /** Narrow the numeric bound domain with an operator and emit issues on contradiction or redundancy. */
342
- function updateBound(
343
- domain: BoundDomain,
344
- base: number,
345
- isNegated: boolean,
346
- value: bigint,
347
- isLength: boolean,
348
- groupIndex: number,
349
- constraintIndex: number,
350
- issues: Issue[],
351
- ): void {
352
- if (isNegated) {
353
- if (base === Op.EQ) {
354
- // A hole outside the type's domain excludes nothing.
355
- if (isOutsideDomain(domain, value)) {
356
- issues.push(ValidationIssue.outOfPhysicalBounds(isLength, groupIndex, constraintIndex, bigintToHex(value)));
357
- }
358
- if (domain.hasEq && domain.eq === value) {
359
- issues.push(ValidationIssue.eqNeqContradiction(isLength, groupIndex, constraintIndex, bigintToHex(value)));
360
- }
361
- let alreadyHole = false;
362
- for (let j = 0; j < domain.holes.length; j++) {
363
- if (domain.holes[j] === value) {
364
- alreadyHole = true;
365
- break;
366
- }
367
- }
368
- if (!alreadyHole) {
369
- domain.holes.push(value);
370
- }
371
- } else {
372
- updateBound(domain, negateBoundOp(base), false, value, isLength, groupIndex, constraintIndex, issues);
373
- }
374
- return;
375
- }
376
-
377
- // Vacuity checks.
378
- if (base === Op.GTE && value === domain.min) {
379
- issues.push(ValidationIssue.vacuousGte(isLength, groupIndex, constraintIndex, bigintToHex(value)));
380
- } else if (base === Op.LTE && value === domain.max) {
381
- issues.push(ValidationIssue.vacuousLte(isLength, groupIndex, constraintIndex, bigintToHex(value)));
382
- }
383
-
384
- // Physical bounds and impossibility.
385
- if (isOutsideDomain(domain, value)) {
386
- issues.push(ValidationIssue.outOfPhysicalBounds(isLength, groupIndex, constraintIndex, bigintToHex(value)));
387
- } else if (base === Op.GT && value === domain.max) {
388
- issues.push(ValidationIssue.impossibleGt(isLength, groupIndex, constraintIndex, bigintToHex(value)));
389
- } else if (base === Op.LT && value === domain.min) {
390
- issues.push(ValidationIssue.impossibleLt(isLength, groupIndex, constraintIndex, bigintToHex(value)));
391
- }
392
-
393
- let changedEq = false;
394
- let changedLower = false;
395
- let changedUpper = false;
396
-
397
- // Equality handling.
398
- if (base === Op.EQ) {
399
- if (!domain.hasEq || domain.eq !== value) changedEq = true;
400
- if (domain.hasEq) {
401
- if (domain.eq !== value) {
402
- issues.push(
403
- ValidationIssue.conflictingEquality(
404
- isLength,
405
- groupIndex,
406
- constraintIndex,
407
- bigintToHex(domain.eq),
408
- bigintToHex(value),
409
- ),
410
- );
411
- }
412
- }
413
- for (let j = 0; j < domain.holes.length; j++) {
414
- if (domain.holes[j] === value) {
415
- issues.push(ValidationIssue.eqNeqContradiction(isLength, groupIndex, constraintIndex, bigintToHex(value)));
416
- }
417
- }
418
- domain.hasEq = true;
419
- domain.eq = value;
420
- } else if (base === Op.GT || base === Op.GTE) {
421
- const inclusive = base === Op.GTE;
422
- if (domain.hasLower) {
423
- let redundant = false;
424
- let strictlyBetter = false;
277
+ /** Return the word a key of a numeric or length subject loads from. */
278
+ function wordOf(domain: Domain, key: bigint): bigint {
279
+ if (domain.flip === 0n) return key;
280
+ return BigInt.asUintN(256, BigInt.asIntN(domain.width, key ^ domain.flip));
281
+ }
425
282
 
426
- if (signedCompare(value, domain.lower, domain.isSigned) < 0n) {
427
- redundant = true;
428
- } else if (value === domain.lower) {
429
- if (domain.lowerInclusive) {
430
- if (!inclusive) strictlyBetter = true;
431
- else redundant = true;
432
- } else {
433
- if (!inclusive) redundant = true;
434
- }
435
- } else {
436
- strictlyBetter = true;
437
- }
283
+ /** Return the relations to a context property's value a comparison admits. */
284
+ function relationsOf(comparison: number): number {
285
+ if (comparison === Op.EQ) return EQUAL;
286
+ if (comparison === Op.GT) return GREATER;
287
+ if (comparison === Op.LT) return LESS;
288
+ if (comparison === Op.GTE) return GREATER | EQUAL;
289
+ return LESS | EQUAL;
290
+ }
438
291
 
439
- if (redundant) {
440
- issues.push(ValidationIssue.dominatedBound(isLength, groupIndex, constraintIndex, bigintToHex(value)));
441
- }
292
+ /** Return the range literal of the keys from `lower` to `upper`. */
293
+ function range(lower: bigint, upper: bigint): Literal {
294
+ return { kind: "range", negated: false, lower, upper };
295
+ }
442
296
 
443
- if (strictlyBetter) {
444
- issues.push(ValidationIssue.dominatedBound(isLength, groupIndex, constraintIndex, bigintToHex(domain.lower)));
445
- domain.lower = value;
446
- domain.lowerInclusive = inclusive;
447
- changedLower = true;
448
- }
297
+ /**
298
+ * Return the literal of an operator on a subject, and the sides it bounds. `subjectProperty` is the context property a
299
+ * context test compares, which a context operand naming it equals.
300
+ */
301
+ function literalOf(
302
+ domain: Domain,
303
+ operator: Hex,
304
+ subjectProperty: number | undefined,
305
+ ): { literal: Literal; sides: number } {
306
+ const bytes = hexToBytes(operator);
307
+ const opCode = bytes[0]!;
308
+ const negated = (opCode & Op.NOT) !== 0;
309
+ const comparison = comparisonOf(opCode);
310
+ const key = (index: number) => patternOf(domain, toBigInt(bytes, 1 + 32 * index)) ^ domain.flip;
311
+ let literal: Literal;
312
+ let sides = 0;
313
+
314
+ if (sourceOf(opCode) === Source.CONTEXT) {
315
+ const property = Number(toBigInt(bytes, 1));
316
+ const relations = relationsOf(comparison);
317
+ if (property === subjectProperty) {
318
+ literal = (relations & EQUAL) !== 0 ? range(0n, domain.max) : range(1n, 0n);
449
319
  } else {
450
- domain.hasLower = true;
451
- domain.lower = value;
452
- domain.lowerInclusive = inclusive;
453
- changedLower = true;
454
- }
455
- } else if (base === Op.LT || base === Op.LTE) {
456
- const inclusive = base === Op.LTE;
457
- if (domain.hasUpper) {
458
- let redundant = false;
459
- let strictlyBetter = false;
460
-
461
- if (signedCompare(value, domain.upper, domain.isSigned) > 0n) {
462
- redundant = true;
463
- } else if (value === domain.upper) {
464
- if (domain.upperInclusive) {
465
- if (!inclusive) strictlyBetter = true;
466
- else redundant = true;
467
- } else {
468
- if (!inclusive) redundant = true;
469
- }
470
- } else {
471
- strictlyBetter = true;
472
- }
320
+ literal = { kind: "relation", negated: false, property, relations };
321
+ }
322
+ } else if (comparison === Op.EQ || comparison === Op.LENGTH_EQ) {
323
+ literal = { kind: "set", negated: false, members: [key(0)] };
324
+ } else if (comparison === Op.IN) {
325
+ const members: bigint[] = [];
326
+ for (let index = 0; 1 + 32 * index < bytes.length; index++) members.push(key(index));
327
+ literal = { kind: "set", negated: false, members };
328
+ } else if (comparison === Op.GT || comparison === Op.LENGTH_GT) {
329
+ literal = range(key(0) + 1n, domain.max);
330
+ sides = LOWER_SIDE;
331
+ } else if (comparison === Op.LT || comparison === Op.LENGTH_LT) {
332
+ literal = range(0n, key(0) - 1n);
333
+ sides = UPPER_SIDE;
334
+ } else if (comparison === Op.GTE || comparison === Op.LENGTH_GTE) {
335
+ literal = range(key(0), domain.max);
336
+ sides = LOWER_SIDE;
337
+ } else if (comparison === Op.LTE || comparison === Op.LENGTH_LTE) {
338
+ literal = range(0n, key(0));
339
+ sides = UPPER_SIDE;
340
+ } else if (comparison === Op.BETWEEN || comparison === Op.LENGTH_BETWEEN) {
341
+ literal = range(key(0), key(1));
342
+ sides = LOWER_SIDE | UPPER_SIDE;
343
+ } else {
344
+ // A mask's top bit asks the opposite of a signed key's top bit.
345
+ const mask = patternOf(domain, toBigInt(bytes, 1));
346
+ const set = mask & (domain.max ^ domain.flip);
347
+ const flipped = mask & domain.flip;
348
+ literal =
349
+ comparison === Op.BITMASK_ALL
350
+ ? { kind: "bits", negated: false, ones: set, zeros: flipped }
351
+ : { kind: "bits", negated: comparison === Op.BITMASK_ANY, ones: flipped, zeros: set };
352
+ }
353
+ return { literal: { ...literal, negated: literal.negated !== negated }, sides: negated ? 0 : sides };
354
+ }
473
355
 
474
- if (redundant) {
475
- issues.push(ValidationIssue.dominatedBound(isLength, groupIndex, constraintIndex, bigintToHex(value)));
476
- }
356
+ /** Return true when the literal at `index` is negated in a conjunction. */
357
+ function isNegated(conjunction: Conjunction, index: number): boolean {
358
+ return conjunction.literals[index]!.negated !== (index === conjunction.flipped);
359
+ }
477
360
 
478
- if (strictlyBetter) {
479
- issues.push(ValidationIssue.dominatedBound(isLength, groupIndex, constraintIndex, bigintToHex(domain.upper)));
480
- domain.upper = value;
481
- domain.upperInclusive = inclusive;
482
- changedUpper = true;
483
- }
361
+ /**
362
+ * Return true when some key and some context meet the first `count` literals, the literal at `flipped` negated. A
363
+ * context property each literal reads is present.
364
+ */
365
+ function satisfiable(domain: Domain, literals: readonly Literal[], count: number, flipped: number): boolean {
366
+ const conjunction: Conjunction = { literals, count, flipped, lower: 0n, upper: domain.max, ones: 0n, zeros: 0n };
367
+ const relations = Array.from({ length: MAX_CONTEXT_PROPERTY_ID + 1 }, () => ANY_RELATION);
368
+ let firstSet: Extract<Literal, { kind: "set" }> | undefined;
369
+ for (let index = 0; index < count; index++) {
370
+ const literal = literals[index]!;
371
+ const negated = isNegated(conjunction, index);
372
+ if (literal.kind === "relation") {
373
+ relations[literal.property]! &= negated ? ANY_RELATION ^ literal.relations : literal.relations;
374
+ } else if (negated) {
375
+ continue;
376
+ } else if (literal.kind === "range") {
377
+ if (literal.lower > conjunction.lower) conjunction.lower = literal.lower;
378
+ if (literal.upper < conjunction.upper) conjunction.upper = literal.upper;
379
+ } else if (literal.kind === "bits") {
380
+ conjunction.ones |= literal.ones;
381
+ conjunction.zeros |= literal.zeros;
484
382
  } else {
485
- domain.hasUpper = true;
486
- domain.upper = value;
487
- domain.upperInclusive = inclusive;
488
- changedUpper = true;
383
+ firstSet ??= literal;
489
384
  }
490
385
  }
491
386
 
492
- // Cross-checks: equality vs lower bound.
493
- if (changedEq || changedLower) {
494
- if (domain.hasEq && domain.hasLower) {
495
- const contradiction = domain.lowerInclusive
496
- ? signedCompare(domain.eq, domain.lower, domain.isSigned) < 0n
497
- : signedCompare(domain.eq, domain.lower, domain.isSigned) <= 0n;
498
- if (contradiction) {
499
- issues.push(
500
- ValidationIssue.boundsExcludeEquality(
501
- isLength,
502
- groupIndex,
503
- constraintIndex,
504
- bigintToHex(domain.eq),
505
- bigintToHex(domain.lower),
506
- ),
507
- );
508
- } else {
509
- issues.push(
510
- ValidationIssue.redundantBound(
511
- isLength,
512
- groupIndex,
513
- constraintIndex,
514
- bigintToHex(domain.lower),
515
- bigintToHex(domain.eq),
516
- ),
517
- );
518
- }
387
+ // A context property's value lies anywhere in its type, so the relations left to it bound the subject's key.
388
+ for (let property = 0; property <= MAX_CONTEXT_PROPERTY_ID; property++) {
389
+ const held = relations[property]!;
390
+ if (held === 0) return false;
391
+ if (held === LESS) {
392
+ const propertyMax = domainOf(contextPropertyType(property)).max;
393
+ if (propertyMax - 1n < conjunction.upper) conjunction.upper = propertyMax - 1n;
519
394
  }
395
+ if (held === GREATER && conjunction.lower < 1n) conjunction.lower = 1n;
520
396
  }
397
+ if ((conjunction.ones & conjunction.zeros) !== 0n || conjunction.lower > conjunction.upper) return false;
521
398
 
522
- // Cross-checks: equality vs upper bound.
523
- if (changedEq || changedUpper) {
524
- if (domain.hasEq && domain.hasUpper) {
525
- const contradiction = domain.upperInclusive
526
- ? signedCompare(domain.eq, domain.upper, domain.isSigned) > 0n
527
- : signedCompare(domain.eq, domain.upper, domain.isSigned) >= 0n;
528
- if (contradiction) {
529
- issues.push(
530
- ValidationIssue.boundsExcludeEquality(
531
- isLength,
532
- groupIndex,
533
- constraintIndex,
534
- bigintToHex(domain.eq),
535
- bigintToHex(domain.upper),
536
- ),
537
- );
538
- } else {
539
- issues.push(
540
- ValidationIssue.redundantBound(
541
- isLength,
542
- groupIndex,
543
- constraintIndex,
544
- bigintToHex(domain.upper),
545
- bigintToHex(domain.eq),
546
- ),
547
- );
548
- }
549
- }
550
- }
399
+ if (firstSet !== undefined) return firstSet.members.some((key) => admits(conjunction, key));
551
400
 
552
- // Cross-check: lower vs upper bound.
553
- if (changedLower || changedUpper) {
554
- if (domain.hasLower && domain.hasUpper) {
555
- const impossible =
556
- signedCompare(domain.lower, domain.upper, domain.isSigned) > 0n ||
557
- (domain.lower === domain.upper && (!domain.lowerInclusive || !domain.upperInclusive));
558
- if (impossible) {
559
- issues.push(
560
- ValidationIssue.impossibleRange(
561
- isLength,
562
- groupIndex,
563
- constraintIndex,
564
- bigintToHex(domain.lower),
565
- bigintToHex(domain.upper),
566
- ),
567
- );
568
- }
569
- }
401
+ // Each step passes one hole or one excluded key, so the walk takes at most one step per literal member.
402
+ let key = nextKey(conjunction, domain.max, conjunction.lower);
403
+ while (key !== undefined && key <= conjunction.upper) {
404
+ const end = excludedTo(conjunction, key);
405
+ if (end === undefined) return true;
406
+ if (end >= conjunction.upper) return false;
407
+ key = nextKey(conjunction, domain.max, end + 1n);
570
408
  }
409
+ return false;
571
410
  }
572
411
 
573
- ///////////////////////////////////////////////////////////////////////////
574
- // Bitmask domain update
575
- ///////////////////////////////////////////////////////////////////////////
576
-
577
- /** Update the bitmask domain and emit issues on contradiction or redundancy. */
578
- function updateBitmask(
579
- ctx: ConstraintContext,
580
- base: number,
581
- isNegated: boolean,
582
- mask: bigint,
583
- groupIndex: number,
584
- constraintIndex: number,
585
- issues: Issue[],
586
- ): void {
587
- if (isNegated) return;
588
-
589
- if (base === Op.BITMASK_ALL) {
590
- if ((ctx.bitmask.mustBeZero & mask) !== 0n) {
591
- issues.push(
592
- ValidationIssue.bitmaskContradiction(
593
- groupIndex,
594
- constraintIndex,
595
- bigintToHex(mask),
596
- bigintToHex(ctx.bitmask.mustBeZero),
597
- ),
598
- );
599
- }
600
- if ((ctx.bitmask.mustBeOne & mask) === mask) {
601
- issues.push(
602
- ValidationIssue.redundantBitmask(
603
- groupIndex,
604
- constraintIndex,
605
- bigintToHex(mask),
606
- bigintToHex(ctx.bitmask.mustBeOne),
607
- ),
608
- );
609
- }
610
- ctx.bitmask.mustBeOne |= mask;
611
- } else if (base === Op.BITMASK_NONE) {
612
- if ((ctx.bitmask.mustBeOne & mask) !== 0n) {
613
- issues.push(
614
- ValidationIssue.bitmaskContradiction(
615
- groupIndex,
616
- constraintIndex,
617
- bigintToHex(mask),
618
- bigintToHex(ctx.bitmask.mustBeOne),
619
- ),
620
- );
621
- }
622
- if ((ctx.bitmask.mustBeZero & mask) === mask) {
623
- issues.push(
624
- ValidationIssue.redundantBitmask(
625
- groupIndex,
626
- constraintIndex,
627
- bigintToHex(mask),
628
- bigintToHex(ctx.bitmask.mustBeZero),
629
- ),
630
- );
631
- }
632
- ctx.bitmask.mustBeZero |= mask;
633
- } else if (base === Op.BITMASK_ANY) {
634
- if (mask !== 0n && (ctx.bitmask.mustBeZero & mask) === mask) {
635
- issues.push(
636
- ValidationIssue.bitmaskAnyImpossible(
637
- groupIndex,
638
- constraintIndex,
639
- bigintToHex(mask),
640
- bigintToHex(ctx.bitmask.mustBeZero),
641
- ),
642
- );
643
- }
644
- if ((ctx.bitmask.mustBeOne & mask) !== 0n) {
645
- issues.push(
646
- ValidationIssue.redundantBitmask(
647
- groupIndex,
648
- constraintIndex,
649
- bigintToHex(mask),
650
- bigintToHex(ctx.bitmask.mustBeOne),
651
- ),
652
- );
653
- }
412
+ /** Return true when `key` meets every literal of a conjunction. */
413
+ function admits(conjunction: Conjunction, key: bigint): boolean {
414
+ if (key < conjunction.lower || key > conjunction.upper || !meetsBits(conjunction, key)) return false;
415
+ for (let index = 0; index < conjunction.count; index++) {
416
+ const literal = conjunction.literals[index]!;
417
+ if (literal.kind === "set" && literal.members.includes(key) === isNegated(conjunction, index)) return false;
654
418
  }
419
+ return excludedTo(conjunction, key) === undefined;
655
420
  }
656
421
 
657
- ///////////////////////////////////////////////////////////////////////////
658
- // Set domain update
659
- ///////////////////////////////////////////////////////////////////////////
660
-
661
- /** Emit an issue if the IN set has been fully excluded by NOT_IN or NEQ holes. */
662
- function checkSetEmpty(ctx: ConstraintContext, groupIndex: number, constraintIndex: number, issues: Issue[]): void {
663
- if (!ctx.set.hasIn) return;
664
-
665
- let possibleCount = 0;
666
- const inCount = ctx.set.inValues.length;
667
- for (let i = 0; i < inCount; i++) {
668
- const value = ctx.set.inValues[i]!;
669
- let forbidden = false;
670
- for (let k = 0; k < ctx.numeric.holes.length; k++) {
671
- if (ctx.numeric.holes[k] === value) {
672
- forbidden = true;
673
- break;
674
- }
675
- }
676
- if (!forbidden) {
677
- for (let k = 0; k < ctx.set.notInValues.length; k++) {
678
- if (ctx.set.notInValues[k] === value) {
679
- forbidden = true;
680
- break;
681
- }
682
- }
683
- }
684
- if (!forbidden) possibleCount++;
685
- }
686
-
687
- if (possibleCount === 0) {
688
- issues.push(ValidationIssue.setFullyExcluded(groupIndex, constraintIndex));
689
- } else if (possibleCount < inCount) {
690
- issues.push(
691
- ValidationIssue.setPartiallyExcluded(groupIndex, constraintIndex, bigintToHex(BigInt(inCount - possibleCount))),
692
- );
422
+ /**
423
+ * Return the greatest key the negated range or set of a conjunction that contains `key` excludes from `key` on, or
424
+ * undefined when none contains it.
425
+ */
426
+ function excludedTo(conjunction: Conjunction, key: bigint): bigint | undefined {
427
+ for (let index = 0; index < conjunction.count; index++) {
428
+ const literal = conjunction.literals[index]!;
429
+ if (!isNegated(conjunction, index)) continue;
430
+ if (literal.kind === "range" && literal.lower <= key && key <= literal.upper) return literal.upper;
431
+ if (literal.kind === "set" && literal.members.includes(key)) return key;
693
432
  }
433
+ return undefined;
694
434
  }
695
435
 
696
- /** Update the set domain with IN or NOT_IN values and emit issues on contradiction or redundancy. */
697
- function updateSet(
698
- ctx: ConstraintContext,
699
- isNegated: boolean,
700
- values: bigint[],
701
- groupIndex: number,
702
- constraintIndex: number,
703
- issues: Issue[],
704
- ): void {
705
- // Physical bounds: a member outside the type's domain never occurs, so it matches and excludes nothing.
706
- for (const value of values) {
707
- if (isOutsideDomain(ctx.numeric, value)) {
708
- issues.push(ValidationIssue.outOfPhysicalBounds(false, groupIndex, constraintIndex, bigintToHex(value)));
709
- }
710
- }
711
-
712
- if (isNegated) {
713
- for (const value of values) {
714
- if (ctx.numeric.hasEq && ctx.numeric.eq === value) {
715
- issues.push(ValidationIssue.setExcludesEquality(groupIndex, constraintIndex, bigintToHex(value)));
716
- }
717
- if (ctx.set.hasIn) {
718
- let inSet = false;
719
- for (const existing of ctx.set.inValues) {
720
- if (existing === value) {
721
- inSet = true;
722
- break;
723
- }
724
- }
725
- if (inSet) {
726
- issues.push(ValidationIssue.setReduction(groupIndex, constraintIndex, bigintToHex(value)));
727
- }
728
- }
729
- ctx.set.notInValues.push(value);
730
- }
731
- checkSetEmpty(ctx, groupIndex, constraintIndex, issues);
732
- } else {
733
- if (ctx.numeric.hasEq) {
734
- let found = false;
735
- for (const candidate of values) {
736
- if (candidate === ctx.numeric.eq) {
737
- found = true;
738
- break;
739
- }
740
- }
741
- if (!found) {
742
- issues.push(ValidationIssue.setExcludesEquality(groupIndex, constraintIndex, bigintToHex(ctx.numeric.eq)));
743
- }
744
- }
745
-
746
- if (ctx.set.hasIn) {
747
- const intersection: bigint[] = [];
748
- for (const existing of ctx.set.inValues) {
749
- for (const candidate of values) {
750
- if (existing === candidate) {
751
- intersection.push(existing);
752
- break;
753
- }
754
- }
755
- }
756
-
757
- if (intersection.length === 0) {
758
- issues.push(ValidationIssue.emptySetIntersection(groupIndex, constraintIndex));
759
- } else if (intersection.length < values.length || intersection.length < ctx.set.inValues.length) {
760
- issues.push(
761
- ValidationIssue.setRedundancy(groupIndex, constraintIndex, bigintToHex(BigInt(intersection.length))),
762
- );
763
- }
764
- ctx.set.inValues = intersection;
765
- } else {
766
- ctx.set.hasIn = true;
767
- ctx.set.inValues = values;
768
- }
769
-
770
- checkSetEmpty(ctx, groupIndex, constraintIndex, issues);
436
+ /** Return true when `key` meets the fixed bits and every negated bits literal of a conjunction. */
437
+ function meetsBits(conjunction: Conjunction, key: bigint): boolean {
438
+ if ((key & conjunction.ones) !== conjunction.ones || (key & conjunction.zeros) !== 0n) return false;
439
+ for (let index = 0; index < conjunction.count; index++) {
440
+ const literal = conjunction.literals[index]!;
441
+ if (literal.kind !== "bits" || !isNegated(conjunction, index)) continue;
442
+ if ((key & literal.ones) === literal.ones && (key & literal.zeros) === 0n) return false;
771
443
  }
444
+ return true;
772
445
  }
773
446
 
774
- ///////////////////////////////////////////////////////////////////////////
775
- // Duplicate detection
776
- ///////////////////////////////////////////////////////////////////////////
777
-
778
- /** Emit a warning if the constraint contains any identical operators. */
779
- function checkDuplicates(issues: Issue[], operators: Hex[], groupIndex: number, constraintIndex: number): void {
780
- const seen = new Set<string>();
781
- for (const op of operators) {
782
- const key = op.toLowerCase();
783
- if (seen.has(key)) {
784
- issues.push(ValidationIssue.duplicateConstraint(groupIndex, constraintIndex));
785
- return;
786
- }
787
- seen.add(key);
447
+ /**
448
+ * Return the least key from `from` up that meets the fixed bits and every negated bits literal. If `from` meets these
449
+ * conditions, the key is `from`. Otherwise the key sets the lowest clear bit of `from` that can be set and keeps the
450
+ * bits of `from` above that bit. The key completes the bits below that bit, least first.
451
+ */
452
+ function nextKey(conjunction: Conjunction, max: bigint, from: bigint): bigint | undefined {
453
+ if (meetsBits(conjunction, from)) return from;
454
+ for (let bit = 1n; bit <= max; bit <<= 1n) {
455
+ if ((from & bit) !== 0n) continue;
456
+ let fixedBits = max ^ (bit - 1n);
457
+ let key = (from & fixedBits) | bit;
458
+ if (!completable(conjunction, max, fixedBits, key)) continue;
459
+ for (let lowBit = bit >> 1n; lowBit !== 0n; lowBit >>= 1n) {
460
+ fixedBits |= lowBit;
461
+ if (!completable(conjunction, max, fixedBits, key)) key |= lowBit;
462
+ }
463
+ return key;
788
464
  }
465
+ return undefined;
789
466
  }
790
467
 
791
- ///////////////////////////////////////////////////////////////////////////
792
- // Fusible range detection
793
- ///////////////////////////////////////////////////////////////////////////
468
+ /**
469
+ * Return true when some key whose bits in `fixedBits` are those of `key` meets the fixed bits and every negated bits
470
+ * literal.
471
+ */
472
+ function completable(conjunction: Conjunction, max: bigint, fixedBits: bigint, key: bigint): boolean {
473
+ if ((key & conjunction.zeros) !== 0n || (conjunction.ones & fixedBits & ~key) !== 0n) return false;
474
+ const known = fixedBits | conjunction.ones | conjunction.zeros;
475
+ return solvable(conjunction, max, known, key | conjunction.ones);
476
+ }
794
477
 
795
478
  /**
796
- * Report a lone unnegated gte/lte pair (value or length domain) as fusible into the corresponding single range
797
- * operator. Fires only for a satisfiable pair on a compatible type; contradictions and dominated bounds are already
798
- * reported by the domain checks.
479
+ * Return true when some assignment of the bits outside `known` meets every negated bits literal, with the known bits as
480
+ * in `value`. Propagates the literals left with one free bit. Then branches on the free bits of the first literal left
481
+ * open. Each branch meets that literal, so the recursion runs at most one level per negated bits literal.
799
482
  */
800
- function checkFusibleRange(
801
- ctx: ConstraintContext,
802
- operators: Hex[],
803
- groupIndex: number,
804
- constraintIndex: number,
805
- issues: Issue[],
806
- ): void {
807
- const numericPair = findLonePair(operators, Op.GTE, Op.LTE);
808
- if (
809
- numericPair &&
810
- checkCompatibility(Op.GTE, ctx.typeInfo) === null &&
811
- signedCompare(numericPair.low, numericPair.high, ctx.numeric.isSigned) <= 0n
812
- ) {
813
- issues.push(
814
- ValidationIssue.fusibleRange(
815
- false,
816
- groupIndex,
817
- constraintIndex,
818
- bigintToHex(numericPair.low),
819
- bigintToHex(numericPair.high),
820
- ),
821
- );
483
+ function solvable(conjunction: Conjunction, max: bigint, known: bigint, value: bigint): boolean {
484
+ let openOnes = 0n;
485
+ let openZeros = 0n;
486
+ let index = 0;
487
+ while (index < conjunction.count) {
488
+ const literal = conjunction.literals[index++]!;
489
+ if (literal.kind !== "bits" || !isNegated(conjunction, index - 1)) continue;
490
+ if ((literal.ones & known & ~value) !== 0n || (literal.zeros & known & value) !== 0n) continue;
491
+ const ones = literal.ones & max & ~known;
492
+ const zeros = literal.zeros & max & ~known;
493
+ const free = ones | zeros;
494
+ if (free === 0n) return false;
495
+ if ((free & (free - 1n)) === 0n) {
496
+ known |= free;
497
+ value = ones === 0n ? value | free : value & ~free;
498
+ [openOnes, openZeros, index] = [0n, 0n, 0];
499
+ } else if ((openOnes | openZeros) === 0n) {
500
+ [openOnes, openZeros] = [ones, zeros];
501
+ }
822
502
  }
823
-
824
- const lengthPair = findLonePair(operators, Op.LENGTH_GTE, Op.LENGTH_LTE);
825
- if (lengthPair && checkCompatibility(Op.LENGTH_GTE, ctx.typeInfo) === null && lengthPair.low <= lengthPair.high) {
826
- issues.push(
827
- ValidationIssue.fusibleRange(
828
- true,
829
- groupIndex,
830
- constraintIndex,
831
- bigintToHex(lengthPair.low),
832
- bigintToHex(lengthPair.high),
833
- ),
834
- );
503
+ if ((openOnes | openZeros) === 0n) return true;
504
+
505
+ // Each branch clears one free bit the literal wants set, or sets one it wants clear; a failed branch's bit then takes
506
+ // the other value.
507
+ for (let free = openOnes | openZeros; free !== 0n; free &= free - 1n) {
508
+ const bit = free & -free;
509
+ const meeting = (openOnes & bit) !== 0n ? value & ~bit : value | bit;
510
+ if (solvable(conjunction, max, known | bit, meeting)) return true;
511
+ known |= bit;
512
+ value = meeting ^ bit;
835
513
  }
514
+ return false;
836
515
  }
837
516
 
838
- /** Check whether a payload length fits the `dataLength` field and matches the operator's arity. */
839
- function hasValidPayload(opBase: number, dataLength: number): boolean {
840
- return dataLength <= fieldMax(PolicyFormat.RULE_DATALENGTH_SIZE) && isValidOperatorData(opBase, dataLength);
841
- }
842
-
843
- /** Return the operand pair when the operators contain exactly one of each unnegated bound opcode. */
844
- function findLonePair(operators: Hex[], lowerOp: number, upperOp: number): { low: bigint; high: bigint } | null {
845
- let lowerCount = 0;
846
- let upperCount = 0;
847
- let low = 0n;
848
- let high = 0n;
849
-
850
- for (const opHex of operators) {
851
- const opCode = parseInt(opHex.slice(2, 4), 16);
852
- const dataLength = (opHex.length - 4) / 2;
853
- if (!hasValidPayload(opCode & ~Op.NOT, dataLength)) continue;
854
- if (opCode === lowerOp) {
855
- lowerCount++;
856
- low = readValue(opHex);
857
- } else if (opCode === upperOp) {
858
- upperCount++;
859
- high = readValue(opHex);
860
- }
517
+ /**
518
+ * Push the PV-9 issues of the rules on one subject, in normal-form order. If the rules stop being satisfiable
519
+ * together, push one issue for the rule at which they stop, and nothing else. Otherwise, push an issue for each rule
520
+ * that holds for every value and context, and for each other rule the others imply. Then, among the rules not yet
521
+ * named, push an issue for each constant bound looser than the tightest on its side.
522
+ */
523
+ function checkSubject(domain: Domain, isLength: boolean, rules: readonly SubjectRule[], issues: Issue[]): void {
524
+ const literals = rules.map((rule) => rule.literal);
525
+ for (let count = 1; count <= literals.length; count++) {
526
+ if (satisfiable(domain, literals, count, NO_LITERAL)) continue;
527
+ issues.push(ValidationIssue.contradictoryRule(rules[count - 1]!.location));
528
+ return;
861
529
  }
862
530
 
863
- return lowerCount === 1 && upperCount === 1 ? { low, high } : null;
531
+ const named = rules.map((rule, index) => {
532
+ if (rule.reads === 0 && !satisfiable(domain, [rule.literal], 1, 0)) {
533
+ issues.push(ValidationIssue.vacuousRule(rule.location));
534
+ return true;
535
+ }
536
+ let othersRead = 0;
537
+ rules.forEach((other, otherIndex) => {
538
+ if (otherIndex !== index) othersRead |= other.reads;
539
+ });
540
+ if ((rule.reads & ~othersRead) !== 0 || satisfiable(domain, literals, literals.length, index)) return false;
541
+ issues.push(ValidationIssue.redundantRule(rule.location));
542
+ return true;
543
+ });
544
+ checkBounds(domain, isLength, rules, named, issues);
864
545
  }
865
546
 
866
- ///////////////////////////////////////////////////////////////////////////
867
- // Constraint validation
868
- ///////////////////////////////////////////////////////////////////////////
869
-
870
- /** Validate all operators on a single constraint against its domain context. */
871
- function validateConstraint(
872
- ctx: ConstraintContext,
873
- constraint: Constraint,
874
- groupIndex: number,
875
- constraintIndex: number,
547
+ /**
548
+ * Push an issue for each constant bound of a rule not in `named` looser than the tightest on its side among those
549
+ * rules.
550
+ */
551
+ function checkBounds(
552
+ domain: Domain,
553
+ isLength: boolean,
554
+ rules: readonly SubjectRule[],
555
+ named: readonly boolean[],
876
556
  issues: Issue[],
877
557
  ): void {
878
- const operators = constraint.operators;
879
- const underAny = constraint.scope === Scope.CALLDATA && ctx.steps.includes(Quantifier.ANY);
880
-
881
- for (const opHex of operators) {
882
- // Every later read slices the hex, so the operator is established as bytes first.
883
- const opBytes = toOperatorBytes(opHex);
884
- const opCode = opBytes[0]!;
885
- const base = opCode & ~Op.NOT;
886
-
887
- // An unassigned opcode or mismatched payload size has no defined semantics to analyze.
888
- const dataLength = opBytes.length - 1;
889
- if (!hasValidPayload(base, dataLength)) {
890
- issues.push(
891
- ValidationIssue.fromOpRule(
892
- "UNKNOWN_OPERATOR",
893
- "Unknown operator code",
894
- groupIndex,
895
- constraintIndex,
896
- bigintToHex(BigInt(opCode)),
897
- ),
898
- );
899
- continue;
900
- }
901
-
902
- const isNegated = (opCode & Op.NOT) !== 0;
903
-
904
- // A negated operator under any() is satisfied by a single decoy element.
905
- if (underAny && isNegated) {
906
- issues.push(ValidationIssue.negationUnderAny(groupIndex, constraintIndex, bigintToHex(BigInt(opCode))));
907
- }
908
-
909
- const incompatibility = checkCompatibility(base, ctx.typeInfo);
910
- if (incompatibility) {
911
- issues.push(
912
- ValidationIssue.fromOpRule(
913
- incompatibility.code,
914
- incompatibility.message,
915
- groupIndex,
916
- constraintIndex,
917
- bigintToHex(BigInt(opCode)),
918
- ),
919
- );
920
- continue;
558
+ let tightestLower = 0n;
559
+ let tightestUpper = domain.max;
560
+ rules.forEach(({ literal, sides }, index) => {
561
+ if (named[index] || literal.kind !== "range") return;
562
+ if ((sides & LOWER_SIDE) !== 0 && literal.lower > tightestLower) tightestLower = literal.lower;
563
+ if ((sides & UPPER_SIDE) !== 0 && literal.upper < tightestUpper) tightestUpper = literal.upper;
564
+ });
565
+ rules.forEach(({ location, literal, sides }, index) => {
566
+ if (named[index] || literal.kind !== "range") return;
567
+ let bound: bigint;
568
+ let tightest: bigint;
569
+ if ((sides & LOWER_SIDE) !== 0 && literal.lower < tightestLower) {
570
+ [bound, tightest] = [literal.lower, tightestLower];
571
+ } else if ((sides & UPPER_SIDE) !== 0 && literal.upper > tightestUpper) {
572
+ [bound, tightest] = [literal.upper, tightestUpper];
573
+ } else {
574
+ return;
921
575
  }
576
+ const [boundWord, tightestWord] = [bigintToHex(wordOf(domain, bound)), bigintToHex(wordOf(domain, tightest))];
577
+ issues.push(ValidationIssue.dominatedBound(isLength, location, boundWord, tightestWord));
578
+ });
579
+ }
922
580
 
923
- if (isContextOp(base)) {
924
- // The operand names a context property, not a value: nothing to fold into a domain.
925
- const ctxOperand = readValue(opHex);
926
- const property = findContextProperty(Number(ctxOperand));
927
- if (!property) {
928
- issues.push(
929
- ValidationIssue.unknownContextProperty(
930
- groupIndex,
931
- constraintIndex,
932
- bigintToHex(ctxOperand),
933
- bigintToHex(BigInt(MAX_CONTEXT_PROPERTY_ID)),
934
- ),
935
- );
936
- } else {
937
- // Compatibility has already narrowed the target to an address or unsigned type, so the pairing reduces to
938
- // whether both sides are addresses.
939
- const propertyIsAddress = property.typeCode === TypeCode.ADDRESS;
940
- const targetIsAddress = ctx.typeInfo.typeCode === TypeCode.ADDRESS;
941
- if (propertyIsAddress !== targetIsAddress) {
942
- issues.push(
943
- ValidationIssue.contextTypeMismatch(
944
- groupIndex,
945
- constraintIndex,
946
- bigintToHex(ctxOperand),
947
- bigintToHex(BigInt(ctx.typeInfo.typeCode)),
948
- ),
949
- );
950
- }
951
- }
952
- } else if (isValueOp(base)) {
953
- // An operand that breaks PC-1 never matches, since enforcement only admits canonical values, so the rule is
954
- // unsatisfiable or vacuous; skip it. Numeric, address and bool operands are already covered by the domain limits.
955
- const typeCode = ctx.typeInfo.typeCode;
956
- if (isLeftAligned(typeCode)) {
957
- const nonCanonical = findNonCanonicalWord(opHex, typeCode);
958
- if (nonCanonical !== null) {
959
- issues.push(
960
- ValidationIssue.nonCanonicalOperand(
961
- groupIndex,
962
- constraintIndex,
963
- bigintToHex(nonCanonical.word),
964
- bigintToHex(nonCanonical.canonical),
965
- ),
966
- );
967
- continue;
968
- }
969
- }
970
-
971
- if (base >= Op.EQ && base <= Op.BETWEEN) {
972
- if (base === Op.BETWEEN) {
973
- // A negated range is a disjunction (value < low OR value > high); distributing the negation over the
974
- // decomposed bounds would conjoin the complement halves into a spurious contradiction. Leave it un-analyzed,
975
- // like a neq hole. Inverted bounds still surface: negating an empty range excludes nothing.
976
- const { low, high } = readPair(opHex);
977
- if (!isNegated) {
978
- updateBound(ctx.numeric, Op.GTE, false, low, false, groupIndex, constraintIndex, issues);
979
- updateBound(ctx.numeric, Op.LTE, false, high, false, groupIndex, constraintIndex, issues);
980
- } else if (signedCompare(low, high, ctx.numeric.isSigned) > 0n) {
981
- issues.push(
982
- ValidationIssue.vacuousNegatedRange(
983
- false,
984
- groupIndex,
985
- constraintIndex,
986
- bigintToHex(low),
987
- bigintToHex(high),
988
- ),
989
- );
990
- }
991
- } else {
992
- const value = readValue(opHex);
993
- const holesBefore = ctx.numeric.holes.length;
994
- updateBound(ctx.numeric, base, isNegated, value, false, groupIndex, constraintIndex, issues);
995
- if (ctx.numeric.holes.length > holesBefore) {
996
- checkSetEmpty(ctx, groupIndex, constraintIndex, issues);
997
- }
998
- }
999
- } else if (isBitmaskOp(base)) {
1000
- const mask = readValue(opHex);
1001
- updateBitmask(ctx, base, isNegated, mask, groupIndex, constraintIndex, issues);
1002
- } else if (base === Op.IN) {
1003
- const values = unpackSet(opHex);
1004
- if (!isStrictlyAscending(values)) {
1005
- issues.push(ValidationIssue.unsortedInSet(groupIndex, constraintIndex));
1006
- } else {
1007
- updateSet(ctx, isNegated, values, groupIndex, constraintIndex, issues);
1008
- }
1009
- }
1010
- } else if (isLengthOp(base)) {
1011
- if (base === Op.LENGTH_BETWEEN) {
1012
- // Same disjunction reasoning as the negated value-range above.
1013
- const { low, high } = readPair(opHex);
1014
- if (!isNegated) {
1015
- updateBound(ctx.length, Op.GTE, false, low, true, groupIndex, constraintIndex, issues);
1016
- updateBound(ctx.length, Op.LTE, false, high, true, groupIndex, constraintIndex, issues);
1017
- } else if (low > high) {
1018
- issues.push(
1019
- ValidationIssue.vacuousNegatedRange(true, groupIndex, constraintIndex, bigintToHex(low), bigintToHex(high)),
1020
- );
1021
- }
1022
- } else {
1023
- const value = readValue(opHex);
1024
- updateBound(ctx.length, normalizeLengthOp(base), isNegated, value, true, groupIndex, constraintIndex, issues);
1025
- }
1026
- }
581
+ /** Push the PV-9 issues of the tests of a group or a loop body, subject by subject in order of first appearance. */
582
+ function checkSubjects(tests: readonly { test: BodyRule; location: RuleLocation }[], issues: Issue[]): void {
583
+ const subjects = new Map<string, { test: BodyRule; location: RuleLocation }[]>();
584
+ for (const entry of tests) {
585
+ const subject = `${entry.test.scope}:${entry.test.path}`;
586
+ const entries = subjects.get(subject);
587
+ if (entries === undefined) subjects.set(subject, [entry]);
588
+ else entries.push(entry);
589
+ }
590
+ for (const entries of subjects.values()) {
591
+ const { scope, path, hint } = entries[0]!.test;
592
+ const subjectProperty = scope === Scope.CONTEXT ? readU16(hexToBytes(path), 0) : undefined;
593
+ const typeCode = subjectProperty === undefined ? hexToBytes(hint!).at(-1)! : contextPropertyType(subjectProperty);
594
+ const domain = domainOf(typeCode);
595
+ const rules = entries.map(({ test, location }) => subjectRule(domain, test, location, subjectProperty));
596
+ checkSubject(domain, hasCalldataLength(typeCode), rules, issues);
1027
597
  }
1028
-
1029
- checkDuplicates(issues, operators, groupIndex, constraintIndex);
1030
598
  }
1031
599
 
1032
- ///////////////////////////////////////////////////////////////////////////
1033
- // Group validation
1034
- ///////////////////////////////////////////////////////////////////////////
1035
-
1036
- /** Validate all constraints in a group, building per-path contexts for cross-constraint analysis. */
1037
- function validateGroup(data: PolicyData, descBytes: Uint8Array, groupIndex: number, issues: Issue[]): void {
1038
- const constraints = data.groups[groupIndex]!;
1039
- const contexts: ConstraintContext[] = [];
1040
-
1041
- for (let constraintIndex = 0; constraintIndex < constraints.length; constraintIndex++) {
1042
- const constraint = constraints[constraintIndex]!;
1043
- const normalizedPath: Hex = `0x${constraint.path.slice(2).toLowerCase()}`;
1044
-
1045
- // Find existing context for this (scope, path) pair.
1046
- let ctx: ConstraintContext | undefined;
1047
- for (const existing of contexts) {
1048
- if (existing.scope === constraint.scope && existing.path === normalizedPath) {
1049
- ctx = existing;
1050
- break;
1051
- }
1052
- }
1053
-
1054
- if (!ctx) {
1055
- const steps = parsePathSteps(constraint.path);
1056
- if (steps.length === 0) throw new CallciumError("EMPTY_PATH", "Path must have at least one step");
1057
- let typeInfo: TypeInfo;
1058
- if (constraint.scope === Scope.CALLDATA) {
1059
- // Compatibility issues against the limits the spec fixes (PWF-17, PV-7).
1060
- if (steps.length > PolicyFormat.MAX_PATH_DEPTH) {
1061
- issues.push(
1062
- ValidationIssue.pathDepthExceeded(
1063
- groupIndex,
1064
- constraintIndex,
1065
- bigintToHex(BigInt(steps.length)),
1066
- bigintToHex(BigInt(PolicyFormat.MAX_PATH_DEPTH)),
1067
- ),
1068
- );
1069
- }
1070
- let walk: ReturnType<typeof Descriptor.walkPath>;
1071
- try {
1072
- walk = Descriptor.walkPath(descBytes, steps);
1073
- } catch (error) {
1074
- // Navigability faults become issues; anything else propagates.
1075
- if (error instanceof CallciumError && navigabilityCodes.has(error.code)) {
1076
- issues.push(ValidationIssue.unnavigablePath(groupIndex, constraintIndex));
1077
- continue;
1078
- }
1079
- throw error;
1080
- }
1081
- // One array at a time carries a quantifier, so the element chain stays singular.
1082
- if (steps.filter(isQuantifier).length > 1) {
1083
- issues.push(ValidationIssue.nestedQuantifier(groupIndex, constraintIndex));
1084
- continue;
1085
- }
1086
- if (walk.quantifiedStaticLength > PolicyFormat.MAX_QUANTIFIED_ARRAY_LENGTH) {
1087
- issues.push(
1088
- ValidationIssue.quantifierOverStaticLimit(
1089
- groupIndex,
1090
- constraintIndex,
1091
- bigintToHex(BigInt(walk.quantifiedStaticLength)),
1092
- bigintToHex(BigInt(PolicyFormat.MAX_QUANTIFIED_ARRAY_LENGTH)),
1093
- ),
1094
- );
1095
- }
1096
- typeInfo = walk.typeInfo;
1097
- } else {
1098
- const ctxId = steps[0]!;
1099
- const property = findContextProperty(ctxId);
1100
- if (!property) {
1101
- // An unassigned ID declares no type, so the analysis proceeds over the widest unsigned domain and the warning
1102
- // carries the ID.
1103
- issues.push(
1104
- ValidationIssue.unknownContextProperty(
1105
- groupIndex,
1106
- constraintIndex,
1107
- bigintToHex(BigInt(ctxId)),
1108
- bigintToHex(BigInt(MAX_CONTEXT_PROPERTY_ID)),
1109
- ),
1110
- );
1111
- }
1112
- typeInfo = { typeCode: property?.typeCode ?? TypeCode.UINT_MAX, isDynamic: false, staticSize: 32 };
1113
- }
1114
- ctx = initContext(constraint.scope, normalizedPath, steps, typeInfo);
1115
- contexts.push(ctx);
1116
- }
1117
-
1118
- // A carried hint must equal the compilation of its own path; constraints without an encoding carry none, an empty
1119
- // one carries nothing to compare, and the encoder compiles theirs from the same descriptor. Reaching here means the
1120
- // path navigates the descriptor and quantifies at most once, so only the PWF-17 depth check remains.
1121
- if (
1122
- constraint.scope === Scope.CALLDATA &&
1123
- constraint.hint !== undefined &&
1124
- constraint.hint !== "0x" &&
1125
- ctx.steps.length <= PolicyFormat.MAX_PATH_DEPTH
1126
- ) {
1127
- const compiled = bytesToHex(Descriptor.compileHint(descBytes, ctx.steps));
1128
- if (compiled !== constraint.hint.toLowerCase()) {
1129
- issues.push(ValidationIssue.hintMismatch(groupIndex, constraintIndex));
1130
- }
1131
- }
1132
-
1133
- validateConstraint(ctx, constraint, groupIndex, constraintIndex, issues);
600
+ /** Return a test as a rule on its subject, whose context property is `subjectProperty` for a context test. */
601
+ function subjectRule(
602
+ domain: Domain,
603
+ test: BodyRule,
604
+ location: RuleLocation,
605
+ subjectProperty: number | undefined,
606
+ ): SubjectRule {
607
+ const { literal, sides } = literalOf(domain, test.operator, subjectProperty);
608
+ let reads = subjectProperty === undefined ? 0 : 1 << subjectProperty;
609
+ if (literal.kind === "relation") reads |= 1 << literal.property;
610
+ return { location, literal, reads, sides };
611
+ }
1134
612
 
1135
- checkFusibleRange(ctx, constraint.operators, groupIndex, constraintIndex, issues);
613
+ /** Push the PV-9 issues of a group's normal form: its tests and context tests, then each loop body apart. */
614
+ function checkGroupSubjects(rules: Rule[], groupIndex: number, issues: Issue[]): void {
615
+ const tests: { test: BodyRule; location: RuleLocation }[] = [];
616
+ for (const [ruleIndex, rule] of rules.entries()) {
617
+ if (rule.scope === Scope.LOOP) continue;
618
+ tests.push({
619
+ test: rule,
620
+ location: { groupIndex, ruleIndex, scope: rule.scope, path: rule.path, operator: rule.operator },
621
+ });
622
+ }
623
+ checkSubjects(tests, issues);
624
+ for (const [ruleIndex, rule] of rules.entries()) {
625
+ if (rule.scope !== Scope.LOOP) continue;
626
+ const body = rule.body.map((test, bodyRuleIndex) => ({
627
+ test,
628
+ location: { groupIndex, ruleIndex, bodyRuleIndex, scope: test.scope, path: test.path, operator: test.operator },
629
+ }));
630
+ checkSubjects(body, issues);
1136
631
  }
1137
632
  }
1138
633
 
1139
634
  ///////////////////////////////////////////////////////////////////////////
1140
- // Public API
635
+ // Validation
1141
636
  ///////////////////////////////////////////////////////////////////////////
1142
637
 
1143
638
  /**
1144
- * Validate a policy for type mismatches, contradictions, redundancies, and vacuities.
1145
- * @param data - The canonical policy data to validate.
1146
- * @returns All validation issues found, ordered by group and constraint index.
1147
- * @throws {CallciumError} If the descriptor is malformed; semantic validation is defined only over a well-formed one.
1148
- * @throws {CallciumError} With `EMPTY_PATH` or `MALFORMED_PATH` for a path that is empty or not whole steps, and
1149
- * `INVALID_HEX` or `INVALID_OPERATOR_BYTES` for an unreadable path or operator. Analysis is defined over canonical
1150
- * data, so these reject the input rather than report an issue.
639
+ * Validate a policy blob (PV-1 to PV-9). Storage validation runs first, since the descriptor check reads the descriptor
640
+ * before anything is decoded. Issues of PV-1 to PV-7 name a rule where the blob stores it. Issues of PV-8 and PV-9 name
641
+ * the group where the blob stores it and the rule's position in the normal form of that group. Only rules that raise no
642
+ * PV-2, PV-3, PV-5 or PV-6 issue and no PV-4 issue on a test enter the normal form. PV-8 skips a loop that lost a body
643
+ * test to such an issue.
644
+ * @param blob - A policy blob that passes storage validation.
645
+ * @returns Every issue found, or one issue alone when the descriptor is not valid.
646
+ * @throws {CallciumError} When the blob fails storage validation, or a group's normal form exceeds a format limit.
1151
647
  */
1152
- function validate(data: PolicyData): Issue[] {
1153
- const descBytes = hexToBytes(data.descriptor);
1154
- decodeDescriptor(descBytes);
1155
-
648
+ function validate(blob: Hex): Issue[] {
649
+ validatePolicy(blob);
650
+ const bytes = hexToBytes(blob);
651
+ const desc = bytes.subarray(PF.DESC_OFFSET, PF.DESC_OFFSET + readU16(bytes, PF.DESC_LENGTH_OFFSET));
652
+ const invalid = Descriptor.tryValidateMeta(desc);
653
+ if (invalid !== undefined) return [ValidationIssue.invalidDescriptor(invalid.offset)];
654
+
655
+ const data = PolicyCoder.decode(blob);
1156
656
  const issues: Issue[] = [];
1157
- for (let groupIndex = 0; groupIndex < data.groups.length; groupIndex++) {
1158
- if (data.groups[groupIndex]!.length === 0) {
1159
- issues.push(ValidationIssue.emptyGroup(groupIndex));
1160
- continue;
1161
- }
1162
- validateGroup(data, descBytes, groupIndex, issues);
1163
- }
1164
-
657
+ data.groups.forEach((rules, groupIndex) => {
658
+ const kept: Rule[] = [];
659
+ const lost = new Set<string>();
660
+ rules.forEach((rule, ruleIndex) => {
661
+ if (rule.scope === Scope.LOOP) {
662
+ const loop = checkLoop(desc, rule, { groupIndex, ruleIndex, scope: rule.scope, path: rule.path }, issues);
663
+ if (loop === undefined || loop.body.length < rule.body.length) lost.add(loopKey(rule));
664
+ if (loop !== undefined) kept.push(loop);
665
+ return;
666
+ }
667
+ const location = { groupIndex, ruleIndex, scope: rule.scope, path: rule.path, operator: rule.operator };
668
+ const test = checkTest(desc, rule, undefined, location, issues);
669
+ if (test !== undefined) kept.push(test);
670
+ });
671
+ if (kept.length === 0) return;
672
+ const normal = PolicyCoder.decode(PolicyCoder.encode({ ...data, groups: [kept] })).groups[0]!;
673
+ checkBodies(normal, groupIndex, lost, issues);
674
+ checkGroupSubjects(normal, groupIndex, issues);
675
+ });
1165
676
  return issues;
1166
677
  }
1167
678
 
1168
- /** Semantic validation for policies. */
679
+ /** Build-time validation of policy blobs. */
1169
680
  export const PolicyValidator = { validate };