@callcium/sdk 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +150 -103
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +683 -579
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/calldata-reader.ts +14 -1
- package/src/constants.ts +54 -11
- package/src/constraint.ts +131 -69
- package/src/descriptor-coder.ts +37 -66
- package/src/descriptor.ts +54 -26
- package/src/errors.ts +12 -2
- package/src/index.ts +1 -0
- package/src/operators.ts +36 -5
- package/src/path.ts +15 -6
- package/src/policy-builder.ts +94 -32
- package/src/policy-coder.ts +100 -45
- package/src/policy-enforcer.ts +85 -103
- package/src/policy-validator.ts +52 -35
- package/src/signature.ts +21 -19
- package/src/type-desc.ts +6 -6
package/src/policy-enforcer.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { hexToBytes, bytesToHex, readU16, readU32, bigintToHex, toAddress } from "./bytes";
|
|
2
|
-
import { loadWord, readPointer } from "./calldata-reader";
|
|
3
|
-
import { PolicyFormat as PF, Scope,
|
|
2
|
+
import { loadWord, readLength, readPointer } from "./calldata-reader";
|
|
3
|
+
import { PolicyFormat as PF, Scope, Op, TypeCode, lookupContextProperty } from "./constants";
|
|
4
4
|
import { CallciumError, PolicyViolationError } from "./errors";
|
|
5
|
-
import { applyOperator, toBigInt,
|
|
5
|
+
import { applyOperator, toBigInt, isLengthValidType, canonicalize } from "./operators";
|
|
6
6
|
import { decodePolicy } from "./policy-coder";
|
|
7
7
|
|
|
8
8
|
import type { ReadResult } from "./calldata-reader";
|
|
9
9
|
import type {
|
|
10
|
+
Address,
|
|
10
11
|
Context,
|
|
11
12
|
DecodedRule,
|
|
12
13
|
EnforceResult,
|
|
@@ -29,9 +30,19 @@ const abortViolationCodes: ReadonlySet<ViolationCode> = new Set<ViolationCode>([
|
|
|
29
30
|
"QUANTIFIER_LIMIT_EXCEEDED",
|
|
30
31
|
]);
|
|
31
32
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Read a supplied context value as the 32-byte word that carries it.
|
|
35
|
+
* @throws {CallciumError} When an integer lies outside the range a 32-byte word represents.
|
|
36
|
+
*/
|
|
37
|
+
function contextValueToWord(value: bigint | Address): bigint {
|
|
38
|
+
if (typeof value === "string") return BigInt(toAddress(value));
|
|
39
|
+
if (BigInt.asUintN(256, value) !== value) {
|
|
40
|
+
throw new CallciumError(
|
|
41
|
+
"CONTEXT_VALUE_OVERFLOW",
|
|
42
|
+
`Context value ${value} is outside the range a 32-byte word represents`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
///////////////////////////////////////////////////////////////////////////
|
|
@@ -44,7 +55,8 @@ function addressToBigInt(hex: string): bigint {
|
|
|
44
55
|
* @param callData - ABI-encoded call data as 0x-prefixed hex string.
|
|
45
56
|
* @param context - Optional execution context for context-scoped rules.
|
|
46
57
|
* @returns Pass with matched group index, or fail with one violation per evaluated failing group.
|
|
47
|
-
* @throws {CallciumError} If the policy blob is structurally malformed
|
|
58
|
+
* @throws {CallciumError} If the policy blob is structurally malformed, or a context value a rule
|
|
59
|
+
* reads is not an address or an integer a 32-byte word represents.
|
|
48
60
|
*/
|
|
49
61
|
function check(policy: Hex, callData: Hex, context?: Context): EnforceResult {
|
|
50
62
|
const { policy: decoded, data: policyBytes } = decodePolicy(policy);
|
|
@@ -111,7 +123,8 @@ function check(policy: Hex, callData: Hex, context?: Context): EnforceResult {
|
|
|
111
123
|
* @param callData - ABI-encoded call data as 0x-prefixed hex string.
|
|
112
124
|
* @param context - Optional execution context for context-scoped rules.
|
|
113
125
|
* @throws {PolicyViolationError} If the policy rejects the call data.
|
|
114
|
-
* @throws {CallciumError} If the policy blob is structurally malformed
|
|
126
|
+
* @throws {CallciumError} If the policy blob is structurally malformed, or a context value a rule
|
|
127
|
+
* reads is not an address or an integer a 32-byte word represents.
|
|
115
128
|
*/
|
|
116
129
|
function enforce(policy: Hex, callData: Hex, context?: Context): void {
|
|
117
130
|
const result = check(policy, callData, context);
|
|
@@ -165,15 +178,24 @@ function evaluateRule(
|
|
|
165
178
|
);
|
|
166
179
|
}
|
|
167
180
|
|
|
181
|
+
/** An `EQ_CTX` operand resolved to its word, or the missing property's type code. */
|
|
182
|
+
type ReferenceOperand = bigint | { ctxTypeCode: number };
|
|
183
|
+
|
|
168
184
|
/**
|
|
169
185
|
* Resolve an EQ_CTX operand word to the referenced context property's value as a raw word.
|
|
170
186
|
* Returns the missing property's type code when the context does not supply it.
|
|
171
187
|
*/
|
|
172
|
-
function resolveContextOperand(operandData: Uint8Array, context?: Context):
|
|
188
|
+
function resolveContextOperand(operandData: Uint8Array, context?: Context): ReferenceOperand {
|
|
173
189
|
const propertyInfo = lookupContextProperty(Number(toBigInt(operandData, 0)));
|
|
174
190
|
const value = context?.[propertyInfo.contextKey];
|
|
175
191
|
if (value === undefined) return { ctxTypeCode: propertyInfo.typeCode };
|
|
176
|
-
return
|
|
192
|
+
return contextValueToWord(value);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Resolve the reference operand of an `EQ_CTX` rule; every other operator reads a literal. */
|
|
196
|
+
function referenceOperand(opCode: number, operandData: Uint8Array, context?: Context): ReferenceOperand | undefined {
|
|
197
|
+
if ((opCode & ~Op.NOT) !== Op.EQ_CTX) return undefined;
|
|
198
|
+
return resolveContextOperand(operandData, context);
|
|
177
199
|
}
|
|
178
200
|
|
|
179
201
|
///////////////////////////////////////////////////////////////////////////
|
|
@@ -225,8 +247,8 @@ function chainResolve(
|
|
|
225
247
|
const meta = readU16(hint, hop + PF.HINT_HOP_META_OFFSET);
|
|
226
248
|
let elems = base;
|
|
227
249
|
if ((meta & PF.HINT_META_DYNAMIC_ARRAY) !== 0) {
|
|
228
|
-
const length =
|
|
229
|
-
if (typeof length !== "
|
|
250
|
+
const length = readLength(callData, elems);
|
|
251
|
+
if (typeof length !== "bigint") return length;
|
|
230
252
|
if (index >= length) return { code: "ARRAY_INDEX_OUT_OF_BOUNDS" };
|
|
231
253
|
elems += 32;
|
|
232
254
|
}
|
|
@@ -266,30 +288,24 @@ function evalTarget(
|
|
|
266
288
|
block: TargetBlock,
|
|
267
289
|
opCode: number,
|
|
268
290
|
operandData: Uint8Array,
|
|
269
|
-
|
|
291
|
+
ctxOperand?: ReferenceOperand,
|
|
270
292
|
): TargetResult {
|
|
271
293
|
const typeCode = block.typeCode;
|
|
272
294
|
|
|
273
295
|
// A dynamic target's chain ends at its payload, so the word there is the declared length.
|
|
274
296
|
if (isLengthValidType(typeCode)) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
const length = readPointer(callData, target);
|
|
279
|
-
if (typeof length !== "number") return { error: length.code };
|
|
297
|
+
const length = readLength(callData, target);
|
|
298
|
+
if (typeof length !== "bigint") return { error: length.code };
|
|
280
299
|
|
|
281
300
|
// The declared payload of `length` items must lie within calldata past the length word.
|
|
282
301
|
const stride = typeCode === TypeCode.DYNAMIC_ARRAY ? (block.targetMeta & PF.HINT_META_STRIDE_MASK) * 32 : 1;
|
|
283
302
|
const room = callData.length - (target + 32);
|
|
284
|
-
if (stride !== 0 && length > Math.floor(room / stride)) return { error: "CALLDATA_OUT_OF_BOUNDS" };
|
|
303
|
+
if (stride !== 0 && length > BigInt(Math.floor(room / stride))) return { error: "CALLDATA_OUT_OF_BOUNDS" };
|
|
285
304
|
|
|
286
305
|
const passed = applyOperator(opCode, 0n, length, operandData, typeCode);
|
|
287
|
-
return { passed, value:
|
|
306
|
+
return { passed, value: length };
|
|
288
307
|
}
|
|
289
308
|
|
|
290
|
-
if (classifyTypeCode(typeCode).typeClass !== "elementary") {
|
|
291
|
-
throw new CallciumError("OPERATOR_TARGET_MISMATCH", "Operator target does not carry a scalar word");
|
|
292
|
-
}
|
|
293
309
|
const word = loadWord(callData, target);
|
|
294
310
|
if (!(word instanceof Uint8Array)) return { error: word.code };
|
|
295
311
|
|
|
@@ -298,14 +314,13 @@ function evalTarget(
|
|
|
298
314
|
return { error: "NON_CANONICAL_VALUE", value };
|
|
299
315
|
}
|
|
300
316
|
|
|
301
|
-
if (
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return { passed, value, ctxOperand: operand };
|
|
317
|
+
if (ctxOperand !== undefined) {
|
|
318
|
+
if (typeof ctxOperand !== "bigint") return { error: "MISSING_CONTEXT", ctxTypeCode: ctxOperand.ctxTypeCode };
|
|
319
|
+
const passed = (value === ctxOperand) !== ((opCode & Op.NOT) !== 0);
|
|
320
|
+
return { passed, value, ctxOperand };
|
|
306
321
|
}
|
|
307
322
|
|
|
308
|
-
const passed = applyOperator(opCode, value,
|
|
323
|
+
const passed = applyOperator(opCode, value, 32n, operandData, typeCode);
|
|
309
324
|
return { passed, value };
|
|
310
325
|
}
|
|
311
326
|
|
|
@@ -323,17 +338,24 @@ type RuleFrame = {
|
|
|
323
338
|
typeCode: number;
|
|
324
339
|
};
|
|
325
340
|
|
|
326
|
-
/**
|
|
327
|
-
function
|
|
341
|
+
/** The coordinates, target and operator fields every violation of a calldata rule carries. */
|
|
342
|
+
function ruleFields(frame: RuleFrame) {
|
|
328
343
|
return {
|
|
329
344
|
group: frame.group,
|
|
330
345
|
rule: frame.rule,
|
|
331
|
-
code,
|
|
332
346
|
scope: Scope.CALLDATA,
|
|
333
347
|
path: frame.path,
|
|
334
348
|
opCode: frame.opCode,
|
|
335
349
|
operandData: bytesToHex(frame.operandData),
|
|
336
350
|
typeCode: frame.typeCode,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Build a navigation violation for a read the enforcer cannot perform. */
|
|
355
|
+
function navigationViolation(frame: RuleFrame, code: NavigationViolationCode, elementIndex?: number): Violation {
|
|
356
|
+
return {
|
|
357
|
+
code,
|
|
358
|
+
...ruleFields(frame),
|
|
337
359
|
...(elementIndex !== undefined && { elementIndex }),
|
|
338
360
|
};
|
|
339
361
|
}
|
|
@@ -342,27 +364,17 @@ function navigationViolation(frame: RuleFrame, code: NavigationViolationCode, el
|
|
|
342
364
|
function targetViolation(frame: RuleFrame, result: TargetResult, elementIndex?: number): Violation | null {
|
|
343
365
|
if ("error" in result) {
|
|
344
366
|
if (result.error === "MISSING_CONTEXT") {
|
|
367
|
+
// The type reported is the absent property's, not the target the operator reads.
|
|
345
368
|
return {
|
|
346
|
-
group: frame.group,
|
|
347
|
-
rule: frame.rule,
|
|
348
369
|
code: result.error,
|
|
349
|
-
|
|
350
|
-
path: frame.path,
|
|
351
|
-
opCode: frame.opCode,
|
|
352
|
-
operandData: bytesToHex(frame.operandData),
|
|
370
|
+
...ruleFields(frame),
|
|
353
371
|
typeCode: result.ctxTypeCode,
|
|
354
372
|
};
|
|
355
373
|
}
|
|
356
374
|
if (result.error === "NON_CANONICAL_VALUE") {
|
|
357
375
|
return {
|
|
358
|
-
group: frame.group,
|
|
359
|
-
rule: frame.rule,
|
|
360
376
|
code: result.error,
|
|
361
|
-
|
|
362
|
-
path: frame.path,
|
|
363
|
-
opCode: frame.opCode,
|
|
364
|
-
operandData: bytesToHex(frame.operandData),
|
|
365
|
-
typeCode: frame.typeCode,
|
|
377
|
+
...ruleFields(frame),
|
|
366
378
|
resolvedValue: bigintToHex(result.value),
|
|
367
379
|
...(elementIndex !== undefined && { elementIndex }),
|
|
368
380
|
};
|
|
@@ -372,14 +384,8 @@ function targetViolation(frame: RuleFrame, result: TargetResult, elementIndex?:
|
|
|
372
384
|
|
|
373
385
|
if (result.passed) return null;
|
|
374
386
|
return {
|
|
375
|
-
group: frame.group,
|
|
376
|
-
rule: frame.rule,
|
|
377
387
|
code: "VALUE_MISMATCH",
|
|
378
|
-
|
|
379
|
-
path: frame.path,
|
|
380
|
-
opCode: frame.opCode,
|
|
381
|
-
operandData: bytesToHex(frame.operandData),
|
|
382
|
-
typeCode: frame.typeCode,
|
|
388
|
+
...ruleFields(frame),
|
|
383
389
|
resolvedValue: bigintToHex(result.value),
|
|
384
390
|
...(result.ctxOperand !== undefined && { resolvedOperand: bigintToHex(result.ctxOperand) }),
|
|
385
391
|
...(elementIndex !== undefined && { elementIndex }),
|
|
@@ -435,7 +441,14 @@ function evaluateCalldataRule(
|
|
|
435
441
|
|
|
436
442
|
return targetViolation(
|
|
437
443
|
frame,
|
|
438
|
-
evalTarget(
|
|
444
|
+
evalTarget(
|
|
445
|
+
callDataBytes,
|
|
446
|
+
base + block.targetDelta,
|
|
447
|
+
block,
|
|
448
|
+
opCode,
|
|
449
|
+
operandData,
|
|
450
|
+
referenceOperand(opCode, operandData, context),
|
|
451
|
+
),
|
|
439
452
|
);
|
|
440
453
|
}
|
|
441
454
|
|
|
@@ -480,27 +493,27 @@ function evaluateQuantified(
|
|
|
480
493
|
let elems = chained + arrayDelta;
|
|
481
494
|
|
|
482
495
|
// A frame declaring no count spans a dynamic array, whose length word precedes its elements.
|
|
483
|
-
let count = readU16(hint, frameOffset + PF.HINT_FRAME_COUNT_OFFSET);
|
|
484
|
-
if (count ===
|
|
485
|
-
const length =
|
|
486
|
-
if (typeof length !== "
|
|
496
|
+
let count = BigInt(readU16(hint, frameOffset + PF.HINT_FRAME_COUNT_OFFSET));
|
|
497
|
+
if (count === 0n) {
|
|
498
|
+
const length = readLength(callDataBytes, elems);
|
|
499
|
+
if (typeof length !== "bigint") return navigationViolation(frame, length.code);
|
|
487
500
|
count = length;
|
|
488
501
|
elems += 32;
|
|
489
502
|
}
|
|
490
503
|
|
|
491
|
-
if (count > PF.MAX_QUANTIFIED_ARRAY_LENGTH) {
|
|
504
|
+
if (count > BigInt(PF.MAX_QUANTIFIED_ARRAY_LENGTH)) {
|
|
492
505
|
return {
|
|
493
506
|
group: groupIndex,
|
|
494
507
|
rule: ruleIndex,
|
|
495
508
|
code: "QUANTIFIER_LIMIT_EXCEEDED",
|
|
496
509
|
scope: Scope.CALLDATA,
|
|
497
510
|
path: pathHex,
|
|
498
|
-
resolvedValue: bigintToHex(
|
|
511
|
+
resolvedValue: bigintToHex(count),
|
|
499
512
|
};
|
|
500
513
|
}
|
|
501
514
|
|
|
502
515
|
const isUniversal = kind === PF.HINT_KIND_ALL;
|
|
503
|
-
if (count ===
|
|
516
|
+
if (count === 0n) {
|
|
504
517
|
if (isUniversal) return null;
|
|
505
518
|
return {
|
|
506
519
|
group: groupIndex,
|
|
@@ -515,8 +528,9 @@ function evaluateQuantified(
|
|
|
515
528
|
const elemStride = (meta & PF.HINT_META_STRIDE_MASK) * 32;
|
|
516
529
|
const elemIsDynamic = (meta & PF.HINT_META_ELEM_DYNAMIC) !== 0;
|
|
517
530
|
|
|
518
|
-
// A reference operand resolves identically for every element
|
|
519
|
-
|
|
531
|
+
// A reference operand resolves identically for every element, so it resolves before iterating.
|
|
532
|
+
const reference = referenceOperand(opCode, operandData, context);
|
|
533
|
+
|
|
520
534
|
for (let elemIndex = 0; elemIndex < count; elemIndex++) {
|
|
521
535
|
const slot = elems + elemIndex * elemStride;
|
|
522
536
|
let elem: number = slot;
|
|
@@ -535,7 +549,7 @@ function evaluateQuantified(
|
|
|
535
549
|
base = chainedElem;
|
|
536
550
|
}
|
|
537
551
|
|
|
538
|
-
const applied = evalTarget(callDataBytes, base + block.targetDelta, block, opCode, operandData,
|
|
552
|
+
const applied = evalTarget(callDataBytes, base + block.targetDelta, block, opCode, operandData, reference);
|
|
539
553
|
if ("error" in applied) {
|
|
540
554
|
// Error results end the rule whatever the quantifier: a later element cannot rescue
|
|
541
555
|
// calldata the enforcer cannot read (an abort effect), and a missing context
|
|
@@ -549,21 +563,14 @@ function evaluateQuantified(
|
|
|
549
563
|
} else if (isUniversal) {
|
|
550
564
|
return targetViolation(frame, applied, elemIndex);
|
|
551
565
|
}
|
|
552
|
-
ctxOperand = applied.ctxOperand;
|
|
553
566
|
}
|
|
554
567
|
|
|
555
568
|
if (isUniversal) return null;
|
|
556
569
|
// Existential (ANY) failure: every element rejected the constraint.
|
|
557
570
|
return {
|
|
558
|
-
group: groupIndex,
|
|
559
|
-
rule: ruleIndex,
|
|
560
571
|
code: "VALUE_MISMATCH",
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
opCode,
|
|
564
|
-
operandData: bytesToHex(operandData),
|
|
565
|
-
typeCode: block.typeCode,
|
|
566
|
-
...(ctxOperand !== undefined && { resolvedOperand: bigintToHex(ctxOperand) }),
|
|
572
|
+
...ruleFields(frame),
|
|
573
|
+
...(typeof reference === "bigint" && { resolvedOperand: bigintToHex(reference) }),
|
|
567
574
|
};
|
|
568
575
|
}
|
|
569
576
|
|
|
@@ -582,34 +589,15 @@ function evaluateContextRule(
|
|
|
582
589
|
context?: Context,
|
|
583
590
|
): Violation | null {
|
|
584
591
|
const propertyId = readU16(pathBytes, 0);
|
|
585
|
-
|
|
586
|
-
if (propertyId > MAX_CONTEXT_PROPERTY_ID) {
|
|
587
|
-
throw new CallciumError(
|
|
588
|
-
"UNKNOWN_CONTEXT_PROPERTY",
|
|
589
|
-
`Unknown context property ID 0x${propertyId.toString(16).padStart(4, "0")}`,
|
|
590
|
-
);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
592
|
const propertyInfo = lookupContextProperty(propertyId);
|
|
594
593
|
const contextValue = context?.[propertyInfo.contextKey];
|
|
594
|
+
const site = { group: groupIndex, rule: ruleIndex, scope: Scope.CONTEXT, path: pathHex };
|
|
595
595
|
|
|
596
596
|
if (contextValue === undefined) {
|
|
597
|
-
return {
|
|
598
|
-
group: groupIndex,
|
|
599
|
-
rule: ruleIndex,
|
|
600
|
-
code: "MISSING_CONTEXT",
|
|
601
|
-
scope: Scope.CONTEXT,
|
|
602
|
-
path: pathHex,
|
|
603
|
-
typeCode: propertyInfo.typeCode,
|
|
604
|
-
};
|
|
597
|
+
return { code: "MISSING_CONTEXT", ...site, typeCode: propertyInfo.typeCode };
|
|
605
598
|
}
|
|
606
599
|
|
|
607
|
-
|
|
608
|
-
if (typeof contextValue === "string") {
|
|
609
|
-
value = addressToBigInt(contextValue);
|
|
610
|
-
} else {
|
|
611
|
-
value = contextValue;
|
|
612
|
-
}
|
|
600
|
+
const value = contextValueToWord(contextValue);
|
|
613
601
|
|
|
614
602
|
let result: boolean;
|
|
615
603
|
let ctxOperand: bigint | undefined;
|
|
@@ -617,11 +605,8 @@ function evaluateContextRule(
|
|
|
617
605
|
const operand = resolveContextOperand(operandData, context);
|
|
618
606
|
if (typeof operand !== "bigint") {
|
|
619
607
|
return {
|
|
620
|
-
group: groupIndex,
|
|
621
|
-
rule: ruleIndex,
|
|
622
608
|
code: "MISSING_CONTEXT",
|
|
623
|
-
|
|
624
|
-
path: pathHex,
|
|
609
|
+
...site,
|
|
625
610
|
opCode,
|
|
626
611
|
operandData: bytesToHex(operandData),
|
|
627
612
|
typeCode: operand.ctxTypeCode,
|
|
@@ -630,16 +615,13 @@ function evaluateContextRule(
|
|
|
630
615
|
result = (value === operand) !== ((opCode & Op.NOT) !== 0);
|
|
631
616
|
ctxOperand = operand;
|
|
632
617
|
} else {
|
|
633
|
-
result = applyOperator(opCode, value,
|
|
618
|
+
result = applyOperator(opCode, value, 32n, operandData, propertyInfo.typeCode);
|
|
634
619
|
}
|
|
635
620
|
|
|
636
621
|
if (!result) {
|
|
637
622
|
return {
|
|
638
|
-
group: groupIndex,
|
|
639
|
-
rule: ruleIndex,
|
|
640
623
|
code: "VALUE_MISMATCH",
|
|
641
|
-
|
|
642
|
-
path: pathHex,
|
|
624
|
+
...site,
|
|
643
625
|
opCode,
|
|
644
626
|
operandData: bytesToHex(operandData),
|
|
645
627
|
typeCode: propertyInfo.typeCode,
|
package/src/policy-validator.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { bigintToHex, bytesToHex, hexToBytes } from "./bytes";
|
|
2
|
-
import {
|
|
2
|
+
import { fieldMax, findContextProperty, MAX_CONTEXT_PROPERTY_ID, Op, PolicyFormat, Scope, TypeCode } from "./constants";
|
|
3
3
|
import { Descriptor, type TypeInfo } from "./descriptor";
|
|
4
4
|
import { decodeDescriptor } from "./descriptor-coder";
|
|
5
5
|
import { CallciumError, type CallciumErrorCode } from "./errors";
|
|
@@ -9,8 +9,9 @@ import {
|
|
|
9
9
|
isSigned,
|
|
10
10
|
isLengthOp,
|
|
11
11
|
isLengthValidType,
|
|
12
|
-
|
|
12
|
+
isElementary,
|
|
13
13
|
isValidOperatorData,
|
|
14
|
+
toOperatorBytes,
|
|
14
15
|
} from "./operators";
|
|
15
16
|
import { Quantifier, isQuantifier, parsePathSteps } from "./path";
|
|
16
17
|
import * as ValidationIssue from "./validation-issue";
|
|
@@ -23,7 +24,6 @@ import type { Constraint, Hex, Issue, IssueCode, PolicyData } from "./types";
|
|
|
23
24
|
|
|
24
25
|
/** Codes a path walk raises when the path does not navigate the descriptor (PV-1). */
|
|
25
26
|
const navigabilityCodes: ReadonlySet<CallciumErrorCode> = new Set<CallciumErrorCode>([
|
|
26
|
-
"EMPTY_PATH",
|
|
27
27
|
"PARAM_INDEX_OUT_OF_BOUNDS",
|
|
28
28
|
"TUPLE_FIELD_OUT_OF_BOUNDS",
|
|
29
29
|
"STATIC_ARRAY_INDEX_OUT_OF_BOUNDS",
|
|
@@ -88,8 +88,15 @@ const INT256_SIGN_BIT = 1n << 255n;
|
|
|
88
88
|
/** Signed-aware difference of two raw uint256 values: negative if a<b, zero if equal, positive if a>b. */
|
|
89
89
|
function signedCompare(a: bigint, b: bigint, signed: boolean): bigint {
|
|
90
90
|
if (!signed) return a - b;
|
|
91
|
-
const
|
|
92
|
-
return
|
|
91
|
+
const toSigned = (word: bigint) => (word >= INT256_SIGN_BIT ? word - (1n << 256n) : word);
|
|
92
|
+
return toSigned(a) - toSigned(b);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** True when a value lies outside the domain's physical bounds. */
|
|
96
|
+
function isOutsideDomain(domain: BoundDomain, value: bigint): boolean {
|
|
97
|
+
return (
|
|
98
|
+
signedCompare(value, domain.min, domain.isSigned) < 0n || signedCompare(value, domain.max, domain.isSigned) > 0n
|
|
99
|
+
);
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
///////////////////////////////////////////////////////////////////////////
|
|
@@ -162,7 +169,7 @@ function getIncompat(opBase: number, typeInfo: TypeInfo): { code: IssueCode; mes
|
|
|
162
169
|
if (isDynamic || staticSize !== 32) {
|
|
163
170
|
return { code: "VALUE_OP_ON_DYNAMIC", message: "Value operator used on dynamic type" };
|
|
164
171
|
}
|
|
165
|
-
if (
|
|
172
|
+
if (!isElementary(typeCode)) {
|
|
166
173
|
return { code: "VALUE_OP_ON_COMPOSITE", message: "Value operator used on composite type" };
|
|
167
174
|
}
|
|
168
175
|
if (isComparisonOp(opBase) && !isNumericType(typeCode)) {
|
|
@@ -304,7 +311,8 @@ function initContext(scope: number, path: Hex, steps: number[], typeInfo: TypeIn
|
|
|
304
311
|
steps,
|
|
305
312
|
typeInfo,
|
|
306
313
|
numeric: emptyBoundDomain(isSigned(typeInfo.typeCode), min, max),
|
|
307
|
-
|
|
314
|
+
// Bits above the target's width are zero in every canonical value.
|
|
315
|
+
bitmask: { mustBeOne: 0n, mustBeZero: isBitmaskCompatible(typeInfo.typeCode) ? UINT256_MAX ^ max : 0n },
|
|
308
316
|
length: emptyBoundDomain(false, 0n, LENGTH_MAX),
|
|
309
317
|
set: {
|
|
310
318
|
hasIn: false,
|
|
@@ -331,6 +339,10 @@ function updateBound(
|
|
|
331
339
|
): void {
|
|
332
340
|
if (isNegated) {
|
|
333
341
|
if (base === Op.EQ) {
|
|
342
|
+
// A hole outside the type's domain excludes nothing.
|
|
343
|
+
if (isOutsideDomain(domain, value)) {
|
|
344
|
+
issues.push(ValidationIssue.outOfPhysicalBounds(isLength, groupIndex, constraintIndex, bigintToHex(value)));
|
|
345
|
+
}
|
|
334
346
|
if (domain.hasEq && domain.eq === value) {
|
|
335
347
|
issues.push(ValidationIssue.eqNeqContradiction(isLength, groupIndex, constraintIndex, bigintToHex(value)));
|
|
336
348
|
}
|
|
@@ -358,10 +370,7 @@ function updateBound(
|
|
|
358
370
|
}
|
|
359
371
|
|
|
360
372
|
// Physical bounds and impossibility.
|
|
361
|
-
if (
|
|
362
|
-
signedCompare(value, domain.min, domain.isSigned) < 0n ||
|
|
363
|
-
signedCompare(value, domain.max, domain.isSigned) > 0n
|
|
364
|
-
) {
|
|
373
|
+
if (isOutsideDomain(domain, value)) {
|
|
365
374
|
issues.push(ValidationIssue.outOfPhysicalBounds(isLength, groupIndex, constraintIndex, bigintToHex(value)));
|
|
366
375
|
} else if (base === Op.GT && value === domain.max) {
|
|
367
376
|
issues.push(ValidationIssue.impossibleGt(isLength, groupIndex, constraintIndex, bigintToHex(value)));
|
|
@@ -681,6 +690,13 @@ function updateSet(
|
|
|
681
690
|
constraintIndex: number,
|
|
682
691
|
issues: Issue[],
|
|
683
692
|
): void {
|
|
693
|
+
// Physical bounds: a member outside the type's domain never occurs, so it neither matches nor excludes anything.
|
|
694
|
+
for (const value of values) {
|
|
695
|
+
if (isOutsideDomain(ctx.numeric, value)) {
|
|
696
|
+
issues.push(ValidationIssue.outOfPhysicalBounds(false, groupIndex, constraintIndex, bigintToHex(value)));
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
684
700
|
if (isNegated) {
|
|
685
701
|
for (const value of values) {
|
|
686
702
|
if (ctx.numeric.hasEq && ctx.numeric.eq === value) {
|
|
@@ -702,16 +718,6 @@ function updateSet(
|
|
|
702
718
|
}
|
|
703
719
|
checkSetEmpty(ctx, groupIndex, constraintIndex, issues);
|
|
704
720
|
} else {
|
|
705
|
-
// Physical bounds: a member outside the type's domain can never be matched.
|
|
706
|
-
for (const value of values) {
|
|
707
|
-
if (
|
|
708
|
-
signedCompare(value, ctx.numeric.min, ctx.numeric.isSigned) < 0n ||
|
|
709
|
-
signedCompare(value, ctx.numeric.max, ctx.numeric.isSigned) > 0n
|
|
710
|
-
) {
|
|
711
|
-
issues.push(ValidationIssue.outOfPhysicalBounds(false, groupIndex, constraintIndex, bigintToHex(value)));
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
721
|
if (ctx.numeric.hasEq) {
|
|
716
722
|
let found = false;
|
|
717
723
|
for (const candidate of values) {
|
|
@@ -817,6 +823,11 @@ function checkFusibleRange(
|
|
|
817
823
|
}
|
|
818
824
|
}
|
|
819
825
|
|
|
826
|
+
/** Check whether a payload length fits the `dataLength` field and matches the operator's arity. */
|
|
827
|
+
function hasValidPayload(opBase: number, dataLength: number): boolean {
|
|
828
|
+
return dataLength <= fieldMax(PolicyFormat.RULE_DATALENGTH_SIZE) && isValidOperatorData(opBase, dataLength);
|
|
829
|
+
}
|
|
830
|
+
|
|
820
831
|
/** Return the operand pair when the operators contain exactly one of each unnegated bound opcode. */
|
|
821
832
|
function findLonePair(operators: Hex[], lowerOp: number, upperOp: number): { low: bigint; high: bigint } | null {
|
|
822
833
|
let lowerCount = 0;
|
|
@@ -827,7 +838,7 @@ function findLonePair(operators: Hex[], lowerOp: number, upperOp: number): { low
|
|
|
827
838
|
for (const opHex of operators) {
|
|
828
839
|
const opCode = parseInt(opHex.slice(2, 4), 16);
|
|
829
840
|
const dataLength = (opHex.length - 4) / 2;
|
|
830
|
-
if (
|
|
841
|
+
if (!hasValidPayload(opCode & ~Op.NOT, dataLength)) continue;
|
|
831
842
|
if (opCode === lowerOp) {
|
|
832
843
|
lowerCount++;
|
|
833
844
|
low = readValue(opHex);
|
|
@@ -856,12 +867,14 @@ function validateConstraint(
|
|
|
856
867
|
const underAny = constraint.scope === Scope.CALLDATA && ctx.steps.includes(Quantifier.ANY);
|
|
857
868
|
|
|
858
869
|
for (const opHex of operators) {
|
|
859
|
-
|
|
870
|
+
// Every later read slices the hex, so the operator is established as bytes first.
|
|
871
|
+
const opBytes = toOperatorBytes(opHex);
|
|
872
|
+
const opCode = opBytes[0]!;
|
|
860
873
|
const base = opCode & ~Op.NOT;
|
|
861
874
|
|
|
862
875
|
// An unassigned opcode or mismatched payload size has no defined semantics to analyze.
|
|
863
|
-
const dataLength =
|
|
864
|
-
if (
|
|
876
|
+
const dataLength = opBytes.length - 1;
|
|
877
|
+
if (!hasValidPayload(base, dataLength)) {
|
|
865
878
|
issues.push(
|
|
866
879
|
ValidationIssue.fromOpRule(
|
|
867
880
|
"UNKNOWN_OPERATOR",
|
|
@@ -898,7 +911,8 @@ function validateConstraint(
|
|
|
898
911
|
if (base === Op.EQ_CTX) {
|
|
899
912
|
// The operand names a context property, not a value: nothing to fold into a domain.
|
|
900
913
|
const ctxOperand = readValue(opHex);
|
|
901
|
-
|
|
914
|
+
const property = findContextProperty(Number(ctxOperand));
|
|
915
|
+
if (!property) {
|
|
902
916
|
issues.push(
|
|
903
917
|
ValidationIssue.unknownContextProperty(
|
|
904
918
|
groupIndex,
|
|
@@ -910,7 +924,7 @@ function validateConstraint(
|
|
|
910
924
|
} else {
|
|
911
925
|
// Compatibility has already narrowed the target to an address or unsigned type,
|
|
912
926
|
// so the pairing reduces to whether both sides are addresses.
|
|
913
|
-
const propertyIsAddress =
|
|
927
|
+
const propertyIsAddress = property.typeCode === TypeCode.ADDRESS;
|
|
914
928
|
const targetIsAddress = ctx.typeInfo.typeCode === TypeCode.ADDRESS;
|
|
915
929
|
if (propertyIsAddress !== targetIsAddress) {
|
|
916
930
|
issues.push(
|
|
@@ -1030,6 +1044,7 @@ function validateGroup(data: PolicyData, descBytes: Uint8Array, groupIndex: numb
|
|
|
1030
1044
|
|
|
1031
1045
|
if (!ctx) {
|
|
1032
1046
|
const steps = parsePathSteps(constraint.path);
|
|
1047
|
+
if (steps.length === 0) throw new CallciumError("EMPTY_PATH", "Path must have at least one step");
|
|
1033
1048
|
let typeInfo: TypeInfo;
|
|
1034
1049
|
if (constraint.scope === Scope.CALLDATA) {
|
|
1035
1050
|
// Compatibility warnings against the limits the spec fixes (PWF-17, PV-7).
|
|
@@ -1072,7 +1087,8 @@ function validateGroup(data: PolicyData, descBytes: Uint8Array, groupIndex: numb
|
|
|
1072
1087
|
typeInfo = walk.typeInfo;
|
|
1073
1088
|
} else {
|
|
1074
1089
|
const ctxId = steps[0]!;
|
|
1075
|
-
|
|
1090
|
+
const property = findContextProperty(ctxId);
|
|
1091
|
+
if (!property) {
|
|
1076
1092
|
// An unassigned ID declares no type, so the analysis proceeds over the widest
|
|
1077
1093
|
// unsigned domain and the warning carries the ID.
|
|
1078
1094
|
issues.push(
|
|
@@ -1083,23 +1099,21 @@ function validateGroup(data: PolicyData, descBytes: Uint8Array, groupIndex: numb
|
|
|
1083
1099
|
bigintToHex(BigInt(MAX_CONTEXT_PROPERTY_ID)),
|
|
1084
1100
|
),
|
|
1085
1101
|
);
|
|
1086
|
-
typeInfo = { typeCode: TypeCode.UINT_MAX, isDynamic: false, staticSize: 32 };
|
|
1087
|
-
} else {
|
|
1088
|
-
const typeCode = lookupContextProperty(ctxId).typeCode;
|
|
1089
|
-
typeInfo = { typeCode, isDynamic: false, staticSize: 32 };
|
|
1090
1102
|
}
|
|
1103
|
+
typeInfo = { typeCode: property?.typeCode ?? TypeCode.UINT_MAX, isDynamic: false, staticSize: 32 };
|
|
1091
1104
|
}
|
|
1092
1105
|
ctx = initContext(constraint.scope, normalizedPath, steps, typeInfo);
|
|
1093
1106
|
contexts.push(ctx);
|
|
1094
1107
|
}
|
|
1095
1108
|
|
|
1096
1109
|
// A carried hint must equal the compilation of its own path; constraints without an encoding
|
|
1097
|
-
// carry none, and the encoder compiles theirs from
|
|
1098
|
-
// the path navigates the descriptor and quantifies at
|
|
1099
|
-
// admits remains to be established.
|
|
1110
|
+
// carry none, an empty one carries nothing to compare, and the encoder compiles theirs from
|
|
1111
|
+
// the same descriptor. Reaching here means the path navigates the descriptor and quantifies at
|
|
1112
|
+
// most once, so only the depth PWF-17 admits remains to be established.
|
|
1100
1113
|
if (
|
|
1101
1114
|
constraint.scope === Scope.CALLDATA &&
|
|
1102
1115
|
constraint.hint !== undefined &&
|
|
1116
|
+
constraint.hint !== "0x" &&
|
|
1103
1117
|
ctx.steps.length <= PolicyFormat.MAX_PATH_DEPTH
|
|
1104
1118
|
) {
|
|
1105
1119
|
const compiled = bytesToHex(Descriptor.compileHint(descBytes, ctx.steps));
|
|
@@ -1124,6 +1138,9 @@ function validateGroup(data: PolicyData, descBytes: Uint8Array, groupIndex: numb
|
|
|
1124
1138
|
* @returns All validation issues found, ordered by group and constraint index.
|
|
1125
1139
|
* @throws {CallciumError} If the descriptor is not well-formed; semantic validation is
|
|
1126
1140
|
* defined only over a well-formed descriptor.
|
|
1141
|
+
* @throws {CallciumError} With code `EMPTY_PATH` when a constraint carries no path step, and
|
|
1142
|
+
* `INVALID_HEX` or `INVALID_OPERATOR_BYTES` when an operator is not a readable encoding. Analysis
|
|
1143
|
+
* is defined over canonical data, so these reject the input rather than report an issue on it.
|
|
1127
1144
|
*/
|
|
1128
1145
|
function validate(data: PolicyData): Issue[] {
|
|
1129
1146
|
const descBytes = hexToBytes(data.descriptor);
|