@callcium/sdk 0.10.0 → 0.11.1
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 +684 -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 +88 -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
|
///////////////////////////////////////////////////////////////////////////
|
|
@@ -200,6 +222,9 @@ function readTargetBlock(hint: Uint8Array, targetOffset: number): TargetBlock {
|
|
|
200
222
|
function follow(callData: Uint8Array, from: number, at: number): ReadResult<number> {
|
|
201
223
|
const word = readPointer(callData, at);
|
|
202
224
|
if (typeof word !== "number") return word;
|
|
225
|
+
if (from > callData.length || word > callData.length - from) {
|
|
226
|
+
return { code: "CALLDATA_OUT_OF_BOUNDS" };
|
|
227
|
+
}
|
|
203
228
|
return from + word;
|
|
204
229
|
}
|
|
205
230
|
|
|
@@ -225,8 +250,8 @@ function chainResolve(
|
|
|
225
250
|
const meta = readU16(hint, hop + PF.HINT_HOP_META_OFFSET);
|
|
226
251
|
let elems = base;
|
|
227
252
|
if ((meta & PF.HINT_META_DYNAMIC_ARRAY) !== 0) {
|
|
228
|
-
const length =
|
|
229
|
-
if (typeof length !== "
|
|
253
|
+
const length = readLength(callData, elems);
|
|
254
|
+
if (typeof length !== "bigint") return length;
|
|
230
255
|
if (index >= length) return { code: "ARRAY_INDEX_OUT_OF_BOUNDS" };
|
|
231
256
|
elems += 32;
|
|
232
257
|
}
|
|
@@ -266,30 +291,24 @@ function evalTarget(
|
|
|
266
291
|
block: TargetBlock,
|
|
267
292
|
opCode: number,
|
|
268
293
|
operandData: Uint8Array,
|
|
269
|
-
|
|
294
|
+
ctxOperand?: ReferenceOperand,
|
|
270
295
|
): TargetResult {
|
|
271
296
|
const typeCode = block.typeCode;
|
|
272
297
|
|
|
273
298
|
// A dynamic target's chain ends at its payload, so the word there is the declared length.
|
|
274
299
|
if (isLengthValidType(typeCode)) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
const length = readPointer(callData, target);
|
|
279
|
-
if (typeof length !== "number") return { error: length.code };
|
|
300
|
+
const length = readLength(callData, target);
|
|
301
|
+
if (typeof length !== "bigint") return { error: length.code };
|
|
280
302
|
|
|
281
303
|
// The declared payload of `length` items must lie within calldata past the length word.
|
|
282
304
|
const stride = typeCode === TypeCode.DYNAMIC_ARRAY ? (block.targetMeta & PF.HINT_META_STRIDE_MASK) * 32 : 1;
|
|
283
305
|
const room = callData.length - (target + 32);
|
|
284
|
-
if (stride !== 0 && length > Math.floor(room / stride)) return { error: "CALLDATA_OUT_OF_BOUNDS" };
|
|
306
|
+
if (stride !== 0 && length > BigInt(Math.floor(room / stride))) return { error: "CALLDATA_OUT_OF_BOUNDS" };
|
|
285
307
|
|
|
286
308
|
const passed = applyOperator(opCode, 0n, length, operandData, typeCode);
|
|
287
|
-
return { passed, value:
|
|
309
|
+
return { passed, value: length };
|
|
288
310
|
}
|
|
289
311
|
|
|
290
|
-
if (classifyTypeCode(typeCode).typeClass !== "elementary") {
|
|
291
|
-
throw new CallciumError("OPERATOR_TARGET_MISMATCH", "Operator target does not carry a scalar word");
|
|
292
|
-
}
|
|
293
312
|
const word = loadWord(callData, target);
|
|
294
313
|
if (!(word instanceof Uint8Array)) return { error: word.code };
|
|
295
314
|
|
|
@@ -298,14 +317,13 @@ function evalTarget(
|
|
|
298
317
|
return { error: "NON_CANONICAL_VALUE", value };
|
|
299
318
|
}
|
|
300
319
|
|
|
301
|
-
if (
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return { passed, value, ctxOperand: operand };
|
|
320
|
+
if (ctxOperand !== undefined) {
|
|
321
|
+
if (typeof ctxOperand !== "bigint") return { error: "MISSING_CONTEXT", ctxTypeCode: ctxOperand.ctxTypeCode };
|
|
322
|
+
const passed = (value === ctxOperand) !== ((opCode & Op.NOT) !== 0);
|
|
323
|
+
return { passed, value, ctxOperand };
|
|
306
324
|
}
|
|
307
325
|
|
|
308
|
-
const passed = applyOperator(opCode, value,
|
|
326
|
+
const passed = applyOperator(opCode, value, 32n, operandData, typeCode);
|
|
309
327
|
return { passed, value };
|
|
310
328
|
}
|
|
311
329
|
|
|
@@ -323,17 +341,24 @@ type RuleFrame = {
|
|
|
323
341
|
typeCode: number;
|
|
324
342
|
};
|
|
325
343
|
|
|
326
|
-
/**
|
|
327
|
-
function
|
|
344
|
+
/** The coordinates, target and operator fields every violation of a calldata rule carries. */
|
|
345
|
+
function ruleFields(frame: RuleFrame) {
|
|
328
346
|
return {
|
|
329
347
|
group: frame.group,
|
|
330
348
|
rule: frame.rule,
|
|
331
|
-
code,
|
|
332
349
|
scope: Scope.CALLDATA,
|
|
333
350
|
path: frame.path,
|
|
334
351
|
opCode: frame.opCode,
|
|
335
352
|
operandData: bytesToHex(frame.operandData),
|
|
336
353
|
typeCode: frame.typeCode,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Build a navigation violation for a read the enforcer cannot perform. */
|
|
358
|
+
function navigationViolation(frame: RuleFrame, code: NavigationViolationCode, elementIndex?: number): Violation {
|
|
359
|
+
return {
|
|
360
|
+
code,
|
|
361
|
+
...ruleFields(frame),
|
|
337
362
|
...(elementIndex !== undefined && { elementIndex }),
|
|
338
363
|
};
|
|
339
364
|
}
|
|
@@ -342,27 +367,17 @@ function navigationViolation(frame: RuleFrame, code: NavigationViolationCode, el
|
|
|
342
367
|
function targetViolation(frame: RuleFrame, result: TargetResult, elementIndex?: number): Violation | null {
|
|
343
368
|
if ("error" in result) {
|
|
344
369
|
if (result.error === "MISSING_CONTEXT") {
|
|
370
|
+
// The type reported is the absent property's, not the target the operator reads.
|
|
345
371
|
return {
|
|
346
|
-
group: frame.group,
|
|
347
|
-
rule: frame.rule,
|
|
348
372
|
code: result.error,
|
|
349
|
-
|
|
350
|
-
path: frame.path,
|
|
351
|
-
opCode: frame.opCode,
|
|
352
|
-
operandData: bytesToHex(frame.operandData),
|
|
373
|
+
...ruleFields(frame),
|
|
353
374
|
typeCode: result.ctxTypeCode,
|
|
354
375
|
};
|
|
355
376
|
}
|
|
356
377
|
if (result.error === "NON_CANONICAL_VALUE") {
|
|
357
378
|
return {
|
|
358
|
-
group: frame.group,
|
|
359
|
-
rule: frame.rule,
|
|
360
379
|
code: result.error,
|
|
361
|
-
|
|
362
|
-
path: frame.path,
|
|
363
|
-
opCode: frame.opCode,
|
|
364
|
-
operandData: bytesToHex(frame.operandData),
|
|
365
|
-
typeCode: frame.typeCode,
|
|
380
|
+
...ruleFields(frame),
|
|
366
381
|
resolvedValue: bigintToHex(result.value),
|
|
367
382
|
...(elementIndex !== undefined && { elementIndex }),
|
|
368
383
|
};
|
|
@@ -372,14 +387,8 @@ function targetViolation(frame: RuleFrame, result: TargetResult, elementIndex?:
|
|
|
372
387
|
|
|
373
388
|
if (result.passed) return null;
|
|
374
389
|
return {
|
|
375
|
-
group: frame.group,
|
|
376
|
-
rule: frame.rule,
|
|
377
390
|
code: "VALUE_MISMATCH",
|
|
378
|
-
|
|
379
|
-
path: frame.path,
|
|
380
|
-
opCode: frame.opCode,
|
|
381
|
-
operandData: bytesToHex(frame.operandData),
|
|
382
|
-
typeCode: frame.typeCode,
|
|
391
|
+
...ruleFields(frame),
|
|
383
392
|
resolvedValue: bigintToHex(result.value),
|
|
384
393
|
...(result.ctxOperand !== undefined && { resolvedOperand: bigintToHex(result.ctxOperand) }),
|
|
385
394
|
...(elementIndex !== undefined && { elementIndex }),
|
|
@@ -435,7 +444,14 @@ function evaluateCalldataRule(
|
|
|
435
444
|
|
|
436
445
|
return targetViolation(
|
|
437
446
|
frame,
|
|
438
|
-
evalTarget(
|
|
447
|
+
evalTarget(
|
|
448
|
+
callDataBytes,
|
|
449
|
+
base + block.targetDelta,
|
|
450
|
+
block,
|
|
451
|
+
opCode,
|
|
452
|
+
operandData,
|
|
453
|
+
referenceOperand(opCode, operandData, context),
|
|
454
|
+
),
|
|
439
455
|
);
|
|
440
456
|
}
|
|
441
457
|
|
|
@@ -480,27 +496,27 @@ function evaluateQuantified(
|
|
|
480
496
|
let elems = chained + arrayDelta;
|
|
481
497
|
|
|
482
498
|
// 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 !== "
|
|
499
|
+
let count = BigInt(readU16(hint, frameOffset + PF.HINT_FRAME_COUNT_OFFSET));
|
|
500
|
+
if (count === 0n) {
|
|
501
|
+
const length = readLength(callDataBytes, elems);
|
|
502
|
+
if (typeof length !== "bigint") return navigationViolation(frame, length.code);
|
|
487
503
|
count = length;
|
|
488
504
|
elems += 32;
|
|
489
505
|
}
|
|
490
506
|
|
|
491
|
-
if (count > PF.MAX_QUANTIFIED_ARRAY_LENGTH) {
|
|
507
|
+
if (count > BigInt(PF.MAX_QUANTIFIED_ARRAY_LENGTH)) {
|
|
492
508
|
return {
|
|
493
509
|
group: groupIndex,
|
|
494
510
|
rule: ruleIndex,
|
|
495
511
|
code: "QUANTIFIER_LIMIT_EXCEEDED",
|
|
496
512
|
scope: Scope.CALLDATA,
|
|
497
513
|
path: pathHex,
|
|
498
|
-
resolvedValue: bigintToHex(
|
|
514
|
+
resolvedValue: bigintToHex(count),
|
|
499
515
|
};
|
|
500
516
|
}
|
|
501
517
|
|
|
502
518
|
const isUniversal = kind === PF.HINT_KIND_ALL;
|
|
503
|
-
if (count ===
|
|
519
|
+
if (count === 0n) {
|
|
504
520
|
if (isUniversal) return null;
|
|
505
521
|
return {
|
|
506
522
|
group: groupIndex,
|
|
@@ -515,8 +531,9 @@ function evaluateQuantified(
|
|
|
515
531
|
const elemStride = (meta & PF.HINT_META_STRIDE_MASK) * 32;
|
|
516
532
|
const elemIsDynamic = (meta & PF.HINT_META_ELEM_DYNAMIC) !== 0;
|
|
517
533
|
|
|
518
|
-
// A reference operand resolves identically for every element
|
|
519
|
-
|
|
534
|
+
// A reference operand resolves identically for every element, so it resolves before iterating.
|
|
535
|
+
const reference = referenceOperand(opCode, operandData, context);
|
|
536
|
+
|
|
520
537
|
for (let elemIndex = 0; elemIndex < count; elemIndex++) {
|
|
521
538
|
const slot = elems + elemIndex * elemStride;
|
|
522
539
|
let elem: number = slot;
|
|
@@ -535,7 +552,7 @@ function evaluateQuantified(
|
|
|
535
552
|
base = chainedElem;
|
|
536
553
|
}
|
|
537
554
|
|
|
538
|
-
const applied = evalTarget(callDataBytes, base + block.targetDelta, block, opCode, operandData,
|
|
555
|
+
const applied = evalTarget(callDataBytes, base + block.targetDelta, block, opCode, operandData, reference);
|
|
539
556
|
if ("error" in applied) {
|
|
540
557
|
// Error results end the rule whatever the quantifier: a later element cannot rescue
|
|
541
558
|
// calldata the enforcer cannot read (an abort effect), and a missing context
|
|
@@ -549,21 +566,14 @@ function evaluateQuantified(
|
|
|
549
566
|
} else if (isUniversal) {
|
|
550
567
|
return targetViolation(frame, applied, elemIndex);
|
|
551
568
|
}
|
|
552
|
-
ctxOperand = applied.ctxOperand;
|
|
553
569
|
}
|
|
554
570
|
|
|
555
571
|
if (isUniversal) return null;
|
|
556
572
|
// Existential (ANY) failure: every element rejected the constraint.
|
|
557
573
|
return {
|
|
558
|
-
group: groupIndex,
|
|
559
|
-
rule: ruleIndex,
|
|
560
574
|
code: "VALUE_MISMATCH",
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
opCode,
|
|
564
|
-
operandData: bytesToHex(operandData),
|
|
565
|
-
typeCode: block.typeCode,
|
|
566
|
-
...(ctxOperand !== undefined && { resolvedOperand: bigintToHex(ctxOperand) }),
|
|
575
|
+
...ruleFields(frame),
|
|
576
|
+
...(typeof reference === "bigint" && { resolvedOperand: bigintToHex(reference) }),
|
|
567
577
|
};
|
|
568
578
|
}
|
|
569
579
|
|
|
@@ -582,34 +592,15 @@ function evaluateContextRule(
|
|
|
582
592
|
context?: Context,
|
|
583
593
|
): Violation | null {
|
|
584
594
|
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
595
|
const propertyInfo = lookupContextProperty(propertyId);
|
|
594
596
|
const contextValue = context?.[propertyInfo.contextKey];
|
|
597
|
+
const site = { group: groupIndex, rule: ruleIndex, scope: Scope.CONTEXT, path: pathHex };
|
|
595
598
|
|
|
596
599
|
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
|
-
};
|
|
600
|
+
return { code: "MISSING_CONTEXT", ...site, typeCode: propertyInfo.typeCode };
|
|
605
601
|
}
|
|
606
602
|
|
|
607
|
-
|
|
608
|
-
if (typeof contextValue === "string") {
|
|
609
|
-
value = addressToBigInt(contextValue);
|
|
610
|
-
} else {
|
|
611
|
-
value = contextValue;
|
|
612
|
-
}
|
|
603
|
+
const value = contextValueToWord(contextValue);
|
|
613
604
|
|
|
614
605
|
let result: boolean;
|
|
615
606
|
let ctxOperand: bigint | undefined;
|
|
@@ -617,11 +608,8 @@ function evaluateContextRule(
|
|
|
617
608
|
const operand = resolveContextOperand(operandData, context);
|
|
618
609
|
if (typeof operand !== "bigint") {
|
|
619
610
|
return {
|
|
620
|
-
group: groupIndex,
|
|
621
|
-
rule: ruleIndex,
|
|
622
611
|
code: "MISSING_CONTEXT",
|
|
623
|
-
|
|
624
|
-
path: pathHex,
|
|
612
|
+
...site,
|
|
625
613
|
opCode,
|
|
626
614
|
operandData: bytesToHex(operandData),
|
|
627
615
|
typeCode: operand.ctxTypeCode,
|
|
@@ -630,16 +618,13 @@ function evaluateContextRule(
|
|
|
630
618
|
result = (value === operand) !== ((opCode & Op.NOT) !== 0);
|
|
631
619
|
ctxOperand = operand;
|
|
632
620
|
} else {
|
|
633
|
-
result = applyOperator(opCode, value,
|
|
621
|
+
result = applyOperator(opCode, value, 32n, operandData, propertyInfo.typeCode);
|
|
634
622
|
}
|
|
635
623
|
|
|
636
624
|
if (!result) {
|
|
637
625
|
return {
|
|
638
|
-
group: groupIndex,
|
|
639
|
-
rule: ruleIndex,
|
|
640
626
|
code: "VALUE_MISMATCH",
|
|
641
|
-
|
|
642
|
-
path: pathHex,
|
|
627
|
+
...site,
|
|
643
628
|
opCode,
|
|
644
629
|
operandData: bytesToHex(operandData),
|
|
645
630
|
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);
|