@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.
- package/README.md +2 -2
- package/dist/index.d.ts +659 -611
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2285 -2071
- package/dist/index.js.map +1 -1
- package/package.json +13 -10
- package/src/bytes.ts +1 -0
- package/src/calldata-reader.ts +3 -3
- package/src/constants.ts +156 -86
- package/src/constraint.ts +34 -24
- package/src/descriptor-coder.ts +126 -103
- package/src/descriptor.ts +79 -288
- package/src/errors.ts +30 -19
- package/src/hint.ts +237 -0
- package/src/index.ts +29 -10
- package/src/operators.ts +168 -267
- package/src/path.ts +7 -31
- package/src/policy-builder.ts +201 -181
- package/src/policy-coder.ts +1156 -523
- package/src/policy-enforcer.ts +499 -486
- package/src/policy-validator.ts +557 -1046
- package/src/signature.ts +7 -11
- package/src/type-desc.ts +6 -6
- package/src/types.ts +230 -129
- package/src/validation-issue.ts +78 -465
package/src/policy-enforcer.ts
CHANGED
|
@@ -1,41 +1,95 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { bigintToHex, hexToBytes, readU16, readU32, toAddress, toHex } from "./bytes";
|
|
2
2
|
import { loadWord, readLength, readPointer } from "./calldata-reader";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
comparisonOf,
|
|
5
|
+
findContextProperty,
|
|
6
|
+
formatCode,
|
|
7
|
+
isComparisonCode,
|
|
8
|
+
isQuantifierCode,
|
|
9
|
+
isScopeCode,
|
|
10
|
+
isSourceCode,
|
|
11
|
+
LoopQuantifier,
|
|
12
|
+
MAX_CONTEXT_PROPERTY_ID,
|
|
13
|
+
Op,
|
|
14
|
+
PolicyFormat as PF,
|
|
15
|
+
Scope,
|
|
16
|
+
Source,
|
|
17
|
+
sourceOf,
|
|
18
|
+
TypeCode,
|
|
19
|
+
type ScopeCode,
|
|
20
|
+
} from "./constants";
|
|
4
21
|
import { CallciumError, PolicyViolationError } from "./errors";
|
|
5
|
-
import {
|
|
6
|
-
|
|
22
|
+
import {
|
|
23
|
+
canonicalize,
|
|
24
|
+
contextPropertyType,
|
|
25
|
+
hasCalldataLength,
|
|
26
|
+
isSigned,
|
|
27
|
+
LENGTH_TYPE,
|
|
28
|
+
loadOperand,
|
|
29
|
+
operandWidth,
|
|
30
|
+
readOperator,
|
|
31
|
+
toBigInt,
|
|
32
|
+
} from "./operators";
|
|
7
33
|
|
|
8
34
|
import type { ReadResult } from "./calldata-reader";
|
|
9
|
-
import type {
|
|
10
|
-
|
|
11
|
-
Context,
|
|
12
|
-
DecodedRule,
|
|
13
|
-
EnforceResult,
|
|
14
|
-
Hex,
|
|
15
|
-
NavigationViolationCode,
|
|
16
|
-
Span,
|
|
17
|
-
Violation,
|
|
18
|
-
ViolationCode,
|
|
19
|
-
} from "./types";
|
|
35
|
+
import type { Context, Hex } from "./types";
|
|
36
|
+
import type { DecodedOperator, EnforceResult, Violation, ViolationCode } from "./types";
|
|
20
37
|
|
|
21
38
|
///////////////////////////////////////////////////////////////////////////
|
|
22
39
|
// Helpers
|
|
23
40
|
///////////////////////////////////////////////////////////////////////////
|
|
24
41
|
|
|
25
|
-
/**
|
|
26
|
-
const
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
|
|
42
|
+
/** Whether a violation ends evaluation or fails only its group (a loop body at the element it read). */
|
|
43
|
+
export const VIOLATION_EFFECT = {
|
|
44
|
+
VALUE_MISMATCH: "groupLocal",
|
|
45
|
+
NON_CANONICAL_VALUE: "abort",
|
|
46
|
+
SELECTOR_MISMATCH: "abort",
|
|
47
|
+
MISSING_SELECTOR: "abort",
|
|
48
|
+
CALLDATA_OUT_OF_BOUNDS: "abort",
|
|
49
|
+
ARRAY_INDEX_OUT_OF_BOUNDS: "groupLocal",
|
|
50
|
+
MISSING_CONTEXT: "groupLocal",
|
|
51
|
+
QUANTIFIER_LIMIT_EXCEEDED: "abort",
|
|
52
|
+
QUANTIFIER_NO_MATCH: "groupLocal",
|
|
53
|
+
} as const satisfies Record<ViolationCode, "abort" | "groupLocal">;
|
|
54
|
+
|
|
55
|
+
/** The value of each context property by ID, or undefined where the context does not supply it. */
|
|
56
|
+
type ContextWords = readonly (bigint | undefined)[];
|
|
57
|
+
|
|
58
|
+
/** What every evaluation step reads: the blob, the calldata and the context, and the operators parsed so far. */
|
|
59
|
+
type Enforcement = {
|
|
60
|
+
policy: Uint8Array;
|
|
61
|
+
callData: Uint8Array;
|
|
62
|
+
context: ContextWords;
|
|
63
|
+
/** Each operator a violation has named, by the offset of its code. */
|
|
64
|
+
operators: Map<number, DecodedOperator>;
|
|
65
|
+
};
|
|
32
66
|
|
|
33
67
|
/**
|
|
34
|
-
* Read
|
|
35
|
-
* @throws {CallciumError} When
|
|
68
|
+
* Read the supplied context as the 32-byte words that carry its properties, by property ID.
|
|
69
|
+
* @throws {CallciumError} When a property is not of its declared type, or an address is not 20 bytes of hex. When an
|
|
70
|
+
* integer does not fit a 32-byte word.
|
|
36
71
|
*/
|
|
37
|
-
function
|
|
38
|
-
|
|
72
|
+
function contextWords(context: Context | undefined): ContextWords {
|
|
73
|
+
const words: (bigint | undefined)[] = [];
|
|
74
|
+
for (let propertyId = 0; propertyId <= MAX_CONTEXT_PROPERTY_ID; propertyId++) {
|
|
75
|
+
const info = findContextProperty(propertyId)!;
|
|
76
|
+
const value: unknown = context?.[info.contextKey];
|
|
77
|
+
words.push(value === undefined ? undefined : contextValueToWord(info.contextKey, info.typeCode, value));
|
|
78
|
+
}
|
|
79
|
+
return words;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Read a supplied context value as the 32-byte word that carries it. */
|
|
83
|
+
function contextValueToWord(contextKey: string, typeCode: number, value: unknown): bigint {
|
|
84
|
+
if (typeCode === TypeCode.ADDRESS) {
|
|
85
|
+
if (typeof value !== "string") {
|
|
86
|
+
throw new CallciumError("MALFORMED_CONTEXT_VALUE", `Context property ${contextKey} is not an address string`);
|
|
87
|
+
}
|
|
88
|
+
return BigInt(toAddress(value));
|
|
89
|
+
}
|
|
90
|
+
if (typeof value !== "bigint") {
|
|
91
|
+
throw new CallciumError("MALFORMED_CONTEXT_VALUE", `Context property ${contextKey} is not a bigint`);
|
|
92
|
+
}
|
|
39
93
|
if (BigInt.asUintN(256, value) !== value) {
|
|
40
94
|
throw new CallciumError(
|
|
41
95
|
"CONTEXT_VALUE_OVERFLOW",
|
|
@@ -50,81 +104,87 @@ function contextValueToWord(value: bigint | Address): bigint {
|
|
|
50
104
|
///////////////////////////////////////////////////////////////////////////
|
|
51
105
|
|
|
52
106
|
/**
|
|
53
|
-
* Check a policy against ABI-encoded call data without throwing on violations.
|
|
107
|
+
* Check a policy against ABI-encoded call data without throwing on violations. Requires a policy blob that passes
|
|
108
|
+
* storage validation. The outcome on any other blob past its header is undefined.
|
|
54
109
|
* @param policy - Binary policy blob as 0x-prefixed hex string.
|
|
55
110
|
* @param callData - ABI-encoded call data as 0x-prefixed hex string.
|
|
56
111
|
* @param context - Optional execution context for context-scoped rules.
|
|
57
112
|
* @returns Pass with matched group index, or fail with one violation per evaluated failing group.
|
|
58
|
-
* @throws {CallciumError} If the policy blob is
|
|
59
|
-
*
|
|
113
|
+
* @throws {CallciumError} If the policy blob is shorter than its fixed prefix or of an unsupported format version.
|
|
114
|
+
* If the policy or call data is not valid hex.
|
|
115
|
+
* If a context value is not of its property's type or does not fit a 32-byte word.
|
|
60
116
|
*/
|
|
61
117
|
function check(policy: Hex, callData: Hex, context?: Context): EnforceResult {
|
|
62
|
-
const
|
|
63
|
-
|
|
118
|
+
const data = hexToBytes(policy);
|
|
119
|
+
if (data.length < PF.DESC_OFFSET) {
|
|
120
|
+
throw new CallciumError("MALFORMED_HEADER", "Policy blob is too short");
|
|
121
|
+
}
|
|
122
|
+
const headerByte = data[0]!;
|
|
123
|
+
const major = headerByte >> PF.MAJOR_SHIFT;
|
|
124
|
+
if (major !== PF.MAJOR) {
|
|
125
|
+
throw new CallciumError("UNSUPPORTED_VERSION", `Major version ${major} is not supported (expected ${PF.MAJOR})`);
|
|
126
|
+
}
|
|
127
|
+
const minor = (headerByte >> PF.MINOR_SHIFT) & PF.MINOR_MASK;
|
|
128
|
+
if (minor > PF.SUPPORTED_MINOR) {
|
|
129
|
+
throw new CallciumError(
|
|
130
|
+
"UNSUPPORTED_MINOR",
|
|
131
|
+
`Minor version ${minor} is above the highest supported (${PF.SUPPORTED_MINOR})`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const enforcement: Enforcement = {
|
|
136
|
+
policy: data,
|
|
137
|
+
callData: hexToBytes(callData),
|
|
138
|
+
context: contextWords(context),
|
|
139
|
+
operators: new Map(),
|
|
140
|
+
};
|
|
64
141
|
|
|
65
142
|
// Selector check.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
violations: [{ code: "MISSING_SELECTOR" }],
|
|
71
|
-
};
|
|
143
|
+
const isSelectorless = (headerByte & PF.FLAG_NO_SELECTOR) !== 0;
|
|
144
|
+
if (!isSelectorless) {
|
|
145
|
+
if (enforcement.callData.length < PF.SELECTOR_SIZE) {
|
|
146
|
+
return { ok: false, violations: [{ code: "MISSING_SELECTOR" }] };
|
|
72
147
|
}
|
|
73
|
-
const expectedSelector =
|
|
74
|
-
const actualSelector =
|
|
148
|
+
const expectedSelector = toHex(data, PF.SELECTOR_OFFSET, PF.SELECTOR_OFFSET + PF.SELECTOR_SIZE);
|
|
149
|
+
const actualSelector = toHex(enforcement.callData, 0, PF.SELECTOR_SIZE);
|
|
75
150
|
if (actualSelector !== expectedSelector) {
|
|
76
151
|
return {
|
|
77
152
|
ok: false,
|
|
78
|
-
violations: [
|
|
79
|
-
{
|
|
80
|
-
code: "SELECTOR_MISMATCH",
|
|
81
|
-
resolvedValue: actualSelector,
|
|
82
|
-
expectedValue: expectedSelector,
|
|
83
|
-
},
|
|
84
|
-
],
|
|
153
|
+
violations: [{ code: "SELECTOR_MISMATCH", resolvedValue: actualSelector, expectedValue: expectedSelector }],
|
|
85
154
|
};
|
|
86
155
|
}
|
|
87
156
|
}
|
|
157
|
+
const baseOffset = isSelectorless ? 0 : PF.SELECTOR_SIZE;
|
|
88
158
|
|
|
89
|
-
|
|
159
|
+
// Groups sit past the embedded descriptor, whose bytes evaluation never reads.
|
|
160
|
+
const groupCountOffset = PF.DESC_OFFSET + readU16(data, PF.DESC_LENGTH_OFFSET);
|
|
161
|
+
const groupCount = data[groupCountOffset]!;
|
|
90
162
|
|
|
91
163
|
// Evaluate groups with OR semantics.
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
for (let
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (violation !== null) {
|
|
102
|
-
allViolations.push(violation);
|
|
103
|
-
if (abortViolationCodes.has(violation.code)) {
|
|
104
|
-
// Abort violations reject the policy outright; later groups are not consulted.
|
|
105
|
-
return { ok: false, violations: allViolations };
|
|
106
|
-
}
|
|
107
|
-
groupFailed = true;
|
|
108
|
-
break;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (!groupFailed) {
|
|
113
|
-
return { ok: true, matchedGroup: groupIndex };
|
|
114
|
-
}
|
|
164
|
+
const violations: Violation[] = [];
|
|
165
|
+
let groupOffset = groupCountOffset + PF.GROUP_COUNT_SIZE;
|
|
166
|
+
for (let group = 0; group < groupCount; group++) {
|
|
167
|
+
const violation = evaluateGroup(enforcement, baseOffset, groupOffset, group);
|
|
168
|
+
if (violation === null) return { ok: true, matchedGroup: group };
|
|
169
|
+
violations.push(violation);
|
|
170
|
+
// Abort violations reject the policy outright; later groups are not consulted.
|
|
171
|
+
if (VIOLATION_EFFECT[violation.code] === "abort") return { ok: false, violations };
|
|
172
|
+
groupOffset += PF.GROUP_HEADER_SIZE + readU32(data, groupOffset + PF.GROUP_RULECOUNT_SIZE);
|
|
115
173
|
}
|
|
116
174
|
|
|
117
|
-
return { ok: false, violations
|
|
175
|
+
return { ok: false, violations };
|
|
118
176
|
}
|
|
119
177
|
|
|
120
178
|
/**
|
|
121
|
-
* Enforce a policy against ABI-encoded call data, throwing on violation.
|
|
179
|
+
* Enforce a policy against ABI-encoded call data, throwing on violation. Requires a policy blob that passes storage
|
|
180
|
+
* validation. The outcome on any other blob past its header is undefined.
|
|
122
181
|
* @param policy - Binary policy blob as 0x-prefixed hex string.
|
|
123
182
|
* @param callData - ABI-encoded call data as 0x-prefixed hex string.
|
|
124
183
|
* @param context - Optional execution context for context-scoped rules.
|
|
125
184
|
* @throws {PolicyViolationError} If the policy rejects the call data.
|
|
126
|
-
* @throws {CallciumError} If the policy blob is
|
|
127
|
-
*
|
|
185
|
+
* @throws {CallciumError} If the policy blob is shorter than its fixed prefix or of an unsupported format version.
|
|
186
|
+
* If the policy or call data is not valid hex.
|
|
187
|
+
* If a context value is not of its property's type or does not fit a 32-byte word.
|
|
128
188
|
*/
|
|
129
189
|
function enforce(policy: Hex, callData: Hex, context?: Context): void {
|
|
130
190
|
const result = check(policy, callData, context);
|
|
@@ -134,103 +194,291 @@ function enforce(policy: Hex, callData: Hex, context?: Context): void {
|
|
|
134
194
|
}
|
|
135
195
|
|
|
136
196
|
///////////////////////////////////////////////////////////////////////////
|
|
137
|
-
//
|
|
197
|
+
// Group and rule evaluation
|
|
138
198
|
///////////////////////////////////////////////////////////////////////////
|
|
139
199
|
|
|
140
|
-
/**
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
200
|
+
/** A violation of a test, before its root location is known. */
|
|
201
|
+
type TestViolation =
|
|
202
|
+
Exclude<
|
|
203
|
+
Violation,
|
|
204
|
+
{ code: "MISSING_SELECTOR" | "SELECTOR_MISMATCH" | "QUANTIFIER_LIMIT_EXCEEDED" | "QUANTIFIER_NO_MATCH" }
|
|
205
|
+
> extends infer V
|
|
206
|
+
? V extends Violation
|
|
207
|
+
? Omit<V, "group" | "rule">
|
|
208
|
+
: never
|
|
209
|
+
: never;
|
|
210
|
+
|
|
211
|
+
/** A violation of a loop rule, before its root location is known. */
|
|
212
|
+
type LoopViolation =
|
|
213
|
+
Exclude<Violation, { code: "MISSING_SELECTOR" | "SELECTOR_MISMATCH" }> extends infer V
|
|
214
|
+
? V extends Violation
|
|
215
|
+
? Omit<V, "group" | "rule">
|
|
216
|
+
: never
|
|
217
|
+
: never;
|
|
218
|
+
|
|
219
|
+
/** Evaluate the root group at `groupOffset`, returning its first violation, or null when every rule passes. */
|
|
220
|
+
function evaluateGroup(
|
|
221
|
+
enforcement: Enforcement,
|
|
150
222
|
baseOffset: number,
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
context?: Context,
|
|
223
|
+
groupOffset: number,
|
|
224
|
+
group: number,
|
|
154
225
|
): Violation | null {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return evaluateContextRule(pathBytes, opCode, operandData, groupIndex, ruleIndex, rule.path.value, context);
|
|
226
|
+
const ruleCount = readU16(enforcement.policy, groupOffset);
|
|
227
|
+
let ruleOffset = groupOffset + PF.GROUP_HEADER_SIZE;
|
|
228
|
+
for (let rule = 0; rule < ruleCount; rule++) {
|
|
229
|
+
const violation = evaluateRule(enforcement, baseOffset, ruleOffset);
|
|
230
|
+
if (violation !== null) return { ...violation, group, rule };
|
|
231
|
+
ruleOffset += readU16(enforcement.policy, ruleOffset);
|
|
162
232
|
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
163
235
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
236
|
+
/** Evaluate a root rule, returning its violation, or null when it passes. */
|
|
237
|
+
function evaluateRule(enforcement: Enforcement, baseOffset: number, ruleOffset: number): LoopViolation | null {
|
|
238
|
+
const scope = scopeAt(enforcement.policy, ruleOffset);
|
|
239
|
+
switch (scope) {
|
|
240
|
+
case Scope.CALLDATA:
|
|
241
|
+
return evaluateTest(enforcement, baseOffset, ruleOffset);
|
|
242
|
+
case Scope.CONTEXT:
|
|
243
|
+
return evaluateContextTest(enforcement, ruleOffset);
|
|
244
|
+
case Scope.LOOP:
|
|
245
|
+
return evaluateLoop(enforcement, baseOffset, ruleOffset);
|
|
246
|
+
default:
|
|
247
|
+
return scope satisfies never;
|
|
167
248
|
}
|
|
168
|
-
return evaluateCalldataRule(
|
|
169
|
-
fieldBytes(policyBytes, rule.hint),
|
|
170
|
-
callDataBytes,
|
|
171
|
-
baseOffset,
|
|
172
|
-
opCode,
|
|
173
|
-
operandData,
|
|
174
|
-
groupIndex,
|
|
175
|
-
ruleIndex,
|
|
176
|
-
rule.path.value,
|
|
177
|
-
context,
|
|
178
|
-
);
|
|
179
249
|
}
|
|
180
250
|
|
|
181
|
-
/**
|
|
182
|
-
|
|
251
|
+
/** Read the scope of the rule at `ruleOffset`. */
|
|
252
|
+
function scopeAt(policy: Uint8Array, ruleOffset: number): ScopeCode {
|
|
253
|
+
const scope = policy[ruleOffset + PF.RULE_SCOPE_OFFSET]!;
|
|
254
|
+
if (!isScopeCode(scope)) throw new CallciumError("UNKNOWN_SCOPE", `Unknown scope value ${scope}`, ruleOffset);
|
|
255
|
+
return scope;
|
|
256
|
+
}
|
|
183
257
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
258
|
+
///////////////////////////////////////////////////////////////////////////
|
|
259
|
+
// Loop evaluation
|
|
260
|
+
///////////////////////////////////////////////////////////////////////////
|
|
261
|
+
|
|
262
|
+
/** Evaluate the loop rule at `ruleOffset`, its body against each element of the array its frame resolves. */
|
|
263
|
+
function evaluateLoop(enforcement: Enforcement, baseOffset: number, ruleOffset: number): LoopViolation | null {
|
|
264
|
+
const { policy, callData } = enforcement;
|
|
265
|
+
const quantifier = policy[ruleOffset + PF.RULE_QUANTIFIER_OFFSET]!;
|
|
266
|
+
if (!isQuantifierCode(quantifier)) {
|
|
267
|
+
throw new CallciumError("UNKNOWN_QUANTIFIER", `Unknown quantifier value ${quantifier}`, ruleOffset);
|
|
268
|
+
}
|
|
269
|
+
const depth = policy[ruleOffset + PF.LOOP_DEPTH_OFFSET]!;
|
|
270
|
+
const pathStart = ruleOffset + PF.LOOP_DEPTH_OFFSET + PF.RULE_DEPTH_SIZE;
|
|
271
|
+
const hintOffset = pathStart + depth * PF.PATH_STEP_SIZE;
|
|
272
|
+
const site = { scope: Scope.LOOP, path: toHex(policy, pathStart, hintOffset) };
|
|
273
|
+
const hopCount = policy[hintOffset]!;
|
|
274
|
+
const hopsOffset = hintOffset + PF.HINT_HEADER_SIZE;
|
|
275
|
+
const frameOffset = hopsOffset + hopCount * PF.HINT_HOP_SIZE;
|
|
276
|
+
|
|
277
|
+
const array = chain(policy, callData, baseOffset, hopsOffset, hopCount);
|
|
278
|
+
if (typeof array !== "number") return { code: array.code, ...site };
|
|
279
|
+
let elems = array + readU32(policy, frameOffset);
|
|
280
|
+
|
|
281
|
+
// A frame declaring no count spans a dynamic array, whose length word precedes its elements.
|
|
282
|
+
let count = BigInt(readU16(policy, frameOffset + PF.HINT_FRAME_COUNT_OFFSET));
|
|
283
|
+
if (count === 0n) {
|
|
284
|
+
const length = readLength(callData, elems);
|
|
285
|
+
if (typeof length !== "bigint") return { code: length.code, ...site };
|
|
286
|
+
count = length;
|
|
287
|
+
elems += 32;
|
|
288
|
+
}
|
|
289
|
+
if (count > BigInt(PF.MAX_QUANTIFIED_ARRAY_LENGTH)) {
|
|
290
|
+
return { code: "QUANTIFIER_LIMIT_EXCEEDED", ...site, resolvedValue: bigintToHex(count) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const bodyOffset = frameOffset + PF.HINT_FRAME_PREFIX_SIZE;
|
|
294
|
+
const meta = readU16(policy, frameOffset + PF.HINT_FRAME_META_OFFSET);
|
|
295
|
+
const elemStride = (meta & PF.HINT_META_STRIDE_MASK) * 32;
|
|
296
|
+
const elemIsDynamic = (meta & PF.HINT_META_ELEM_DYNAMIC) !== 0;
|
|
297
|
+
|
|
298
|
+
for (let elementIndex = 0; elementIndex < count; elementIndex++) {
|
|
299
|
+
const slot = elems + elementIndex * elemStride;
|
|
300
|
+
let elem = slot;
|
|
301
|
+
if (elemIsDynamic) {
|
|
302
|
+
const followed = follow(callData, elems, slot);
|
|
303
|
+
if (typeof followed !== "number") return { code: followed.code, ...site, elementIndex };
|
|
304
|
+
elem = followed;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const violation = evaluateBody(enforcement, bodyOffset, elem);
|
|
308
|
+
switch (quantifier) {
|
|
309
|
+
case LoopQuantifier.ALL:
|
|
310
|
+
// A body violation fails the loop at its element, an abort included.
|
|
311
|
+
if (violation !== null) return { ...violation, elementIndex };
|
|
312
|
+
break;
|
|
313
|
+
case LoopQuantifier.ANY:
|
|
314
|
+
// A group-local body violation moves on to the next element; an abort ends evaluation.
|
|
315
|
+
if (violation === null) return null;
|
|
316
|
+
if (VIOLATION_EFFECT[violation.code] === "abort") return { ...violation, elementIndex };
|
|
317
|
+
break;
|
|
318
|
+
default:
|
|
319
|
+
return quantifier satisfies never;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (quantifier === LoopQuantifier.ALL) return null;
|
|
324
|
+
return { code: "QUANTIFIER_NO_MATCH", ...site };
|
|
193
325
|
}
|
|
194
326
|
|
|
195
|
-
/**
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
327
|
+
/**
|
|
328
|
+
* Evaluate the loop body at `bodyOffset` against the element whose encoding starts at `elem`. Return the first
|
|
329
|
+
* violation, with the failing test's position, or null when every test passes.
|
|
330
|
+
*/
|
|
331
|
+
function evaluateBody(enforcement: Enforcement, bodyOffset: number, elem: number): TestViolation | null {
|
|
332
|
+
const ruleCount = readU16(enforcement.policy, bodyOffset);
|
|
333
|
+
let ruleOffset = bodyOffset + PF.GROUP_RULECOUNT_SIZE;
|
|
334
|
+
for (let bodyRule = 0; bodyRule < ruleCount; bodyRule++) {
|
|
335
|
+
const scope = scopeAt(enforcement.policy, ruleOffset);
|
|
336
|
+
let violation: TestViolation | null;
|
|
337
|
+
switch (scope) {
|
|
338
|
+
case Scope.CALLDATA:
|
|
339
|
+
violation = evaluateTest(enforcement, elem, ruleOffset);
|
|
340
|
+
break;
|
|
341
|
+
case Scope.CONTEXT:
|
|
342
|
+
violation = evaluateContextTest(enforcement, ruleOffset);
|
|
343
|
+
break;
|
|
344
|
+
case Scope.LOOP:
|
|
345
|
+
throw new CallciumError("INVALID_NESTED_LOOP", "Loop body holds a loop", ruleOffset);
|
|
346
|
+
default:
|
|
347
|
+
return scope satisfies never;
|
|
348
|
+
}
|
|
349
|
+
if (violation !== null) return { ...violation, bodyRule };
|
|
350
|
+
ruleOffset += readU16(enforcement.policy, ruleOffset);
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
199
353
|
}
|
|
200
354
|
|
|
201
355
|
///////////////////////////////////////////////////////////////////////////
|
|
202
|
-
//
|
|
356
|
+
// Test evaluation
|
|
203
357
|
///////////////////////////////////////////////////////////////////////////
|
|
204
358
|
|
|
205
|
-
/**
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
359
|
+
/** Evaluate the calldata test at `ruleOffset` from the starting point whose encoding starts at `base`. */
|
|
360
|
+
function evaluateTest(enforcement: Enforcement, base: number, ruleOffset: number): TestViolation | null {
|
|
361
|
+
const { policy, callData } = enforcement;
|
|
362
|
+
// The hint addresses the target on its own; path bytes carry no evaluation role.
|
|
363
|
+
const depth = policy[ruleOffset + PF.RULE_DEPTH_OFFSET]!;
|
|
364
|
+
const pathStart = ruleOffset + PF.RULE_PATH_OFFSET;
|
|
365
|
+
const hintOffset = pathStart + depth * PF.PATH_STEP_SIZE;
|
|
366
|
+
const hopCount = policy[hintOffset]!;
|
|
367
|
+
const hopsOffset = hintOffset + PF.HINT_HEADER_SIZE;
|
|
368
|
+
const targetOffset = hopsOffset + hopCount * PF.HINT_HOP_SIZE;
|
|
369
|
+
const typeCode = policy[targetOffset + PF.HINT_TARGET_TYPECODE_OFFSET]!;
|
|
370
|
+
const opOffset = targetOffset + PF.HINT_TARGET_SIZE;
|
|
371
|
+
const opCode = policy[opOffset]!;
|
|
372
|
+
const dataLength = readU16(policy, opOffset + PF.RULE_OPCODE_SIZE);
|
|
373
|
+
const dataOffset = opOffset + PF.RULE_OPCODE_SIZE + PF.RULE_DATALENGTH_SIZE;
|
|
374
|
+
// The fields every violation of the test carries, built only once one occurs.
|
|
375
|
+
const site = () => ({
|
|
376
|
+
scope: Scope.CALLDATA,
|
|
377
|
+
path: toHex(policy, pathStart, hintOffset),
|
|
378
|
+
operator: operatorAt(enforcement, opOffset, typeCode),
|
|
379
|
+
typeCode,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const chained = chain(policy, callData, base, hopsOffset, hopCount);
|
|
383
|
+
if (typeof chained !== "number") return { code: chained.code, ...site() };
|
|
384
|
+
const target = chained + readU32(policy, targetOffset);
|
|
385
|
+
|
|
386
|
+
const word = loadWord(callData, target);
|
|
387
|
+
if (!(word instanceof Uint8Array)) return { code: word.code, ...site() };
|
|
388
|
+
const value = toBigInt(word, 0);
|
|
389
|
+
|
|
390
|
+
// A dynamic target's chain ends at its payload, so the word there is the declared length.
|
|
391
|
+
let operandType = typeCode;
|
|
392
|
+
if (hasCalldataLength(typeCode)) {
|
|
393
|
+
// The declared payload of `value` items must lie within calldata past the length word.
|
|
394
|
+
const targetMeta = readU16(policy, targetOffset + PF.HINT_TARGET_META_OFFSET);
|
|
395
|
+
const stride = typeCode === TypeCode.DYNAMIC_ARRAY ? (targetMeta & PF.HINT_META_STRIDE_MASK) * 32 : 1;
|
|
396
|
+
const room = callData.length - (target + 32);
|
|
397
|
+
if (stride !== 0 && value > BigInt(Math.floor(room / stride))) return { code: "CALLDATA_OUT_OF_BOUNDS", ...site() };
|
|
398
|
+
operandType = LENGTH_TYPE;
|
|
399
|
+
} else if (canonicalize(value, typeCode) !== value) {
|
|
400
|
+
return { code: "NON_CANONICAL_VALUE", ...site(), resolvedValue: bigintToHex(value) };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const operand = resolveOperand(enforcement, opCode, dataOffset, ruleOffset);
|
|
404
|
+
if (operand === null) {
|
|
405
|
+
return { code: "MISSING_CONTEXT", ...site(), typeCode: contextPropertyType(readU16(policy, dataOffset)) };
|
|
406
|
+
}
|
|
407
|
+
if (applyOperator(opCode, value, operandType, policy, dataOffset, dataLength, operand, ruleOffset)) return null;
|
|
408
|
+
return {
|
|
409
|
+
code: "VALUE_MISMATCH",
|
|
410
|
+
...site(),
|
|
411
|
+
resolvedValue: bigintToHex(value),
|
|
412
|
+
...(operand !== undefined && { resolvedOperand: bigintToHex(operand) }),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
211
415
|
|
|
212
|
-
/**
|
|
213
|
-
|
|
416
|
+
/**
|
|
417
|
+
* Return the operator at `opOffset` against a target of `typeCode`, parsed once per check. An `ANY` loop discards the
|
|
418
|
+
* violation of every element whose body fails.
|
|
419
|
+
*/
|
|
420
|
+
function operatorAt(enforcement: Enforcement, opOffset: number, typeCode: number): DecodedOperator {
|
|
421
|
+
let operator = enforcement.operators.get(opOffset);
|
|
422
|
+
if (operator === undefined) {
|
|
423
|
+
operator = readOperator(enforcement.policy, opOffset, typeCode);
|
|
424
|
+
enforcement.operators.set(opOffset, operator);
|
|
425
|
+
}
|
|
426
|
+
return operator;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Evaluate the context test at `ruleOffset`. */
|
|
430
|
+
function evaluateContextTest(enforcement: Enforcement, ruleOffset: number): TestViolation | null {
|
|
431
|
+
const { policy, context } = enforcement;
|
|
432
|
+
const pathStart = ruleOffset + PF.RULE_PATH_OFFSET;
|
|
433
|
+
const contextPropertyId = readU16(policy, pathStart);
|
|
434
|
+
const typeCode = contextPropertyType(contextPropertyId);
|
|
435
|
+
const opOffset = pathStart + PF.PROPERTY_ID_SIZE;
|
|
436
|
+
const site = () => ({ scope: Scope.CONTEXT, path: toHex(policy, pathStart, opOffset) });
|
|
437
|
+
|
|
438
|
+
const value = context[contextPropertyId];
|
|
439
|
+
if (value === undefined) return { code: "MISSING_CONTEXT", ...site(), typeCode };
|
|
440
|
+
|
|
441
|
+
const opCode = policy[opOffset]!;
|
|
442
|
+
const dataLength = readU16(policy, opOffset + PF.RULE_OPCODE_SIZE);
|
|
443
|
+
const dataOffset = opOffset + PF.RULE_OPCODE_SIZE + PF.RULE_DATALENGTH_SIZE;
|
|
444
|
+
|
|
445
|
+
const operand = resolveOperand(enforcement, opCode, dataOffset, ruleOffset);
|
|
446
|
+
if (operand === null) {
|
|
447
|
+
return {
|
|
448
|
+
code: "MISSING_CONTEXT",
|
|
449
|
+
...site(),
|
|
450
|
+
operator: operatorAt(enforcement, opOffset, typeCode),
|
|
451
|
+
typeCode: contextPropertyType(readU16(policy, dataOffset)),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
if (applyOperator(opCode, value, typeCode, policy, dataOffset, dataLength, operand, ruleOffset)) return null;
|
|
214
455
|
return {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
456
|
+
code: "VALUE_MISMATCH",
|
|
457
|
+
...site(),
|
|
458
|
+
operator: operatorAt(enforcement, opOffset, typeCode),
|
|
459
|
+
typeCode,
|
|
460
|
+
resolvedValue: bigintToHex(value),
|
|
461
|
+
...(operand !== undefined && { resolvedOperand: bigintToHex(operand) }),
|
|
218
462
|
};
|
|
219
463
|
}
|
|
220
464
|
|
|
465
|
+
///////////////////////////////////////////////////////////////////////////
|
|
466
|
+
// Hint chain resolution
|
|
467
|
+
///////////////////////////////////////////////////////////////////////////
|
|
468
|
+
|
|
221
469
|
/** Advance `from` by the offset word at `at`. */
|
|
222
470
|
function follow(callData: Uint8Array, from: number, at: number): ReadResult<number> {
|
|
223
471
|
const word = readPointer(callData, at);
|
|
224
472
|
if (typeof word !== "number") return word;
|
|
225
|
-
if (
|
|
473
|
+
if (word > callData.length - from) {
|
|
226
474
|
return { code: "CALLDATA_OUT_OF_BOUNDS" };
|
|
227
475
|
}
|
|
228
476
|
return from + word;
|
|
229
477
|
}
|
|
230
478
|
|
|
231
479
|
/** Follow `hopCount` hop entries from `base` and return the calldata offset they reach. */
|
|
232
|
-
function
|
|
233
|
-
|
|
480
|
+
function chain(
|
|
481
|
+
policy: Uint8Array,
|
|
234
482
|
callData: Uint8Array,
|
|
235
483
|
base: number,
|
|
236
484
|
hopsOffset: number,
|
|
@@ -238,16 +486,16 @@ function chainResolve(
|
|
|
238
486
|
): ReadResult<number> {
|
|
239
487
|
for (let i = 0; i < hopCount; i++) {
|
|
240
488
|
const hop = hopsOffset + i * PF.HINT_HOP_SIZE;
|
|
241
|
-
const index = readU16(
|
|
489
|
+
const index = readU16(policy, hop + PF.HINT_HOP_INDEX_OFFSET);
|
|
242
490
|
|
|
243
491
|
if (index === PF.HINT_NO_INDEX) {
|
|
244
|
-
const next = follow(callData, base, base + readU32(
|
|
492
|
+
const next = follow(callData, base, base + readU32(policy, hop));
|
|
245
493
|
if (typeof next !== "number") return next;
|
|
246
494
|
base = next;
|
|
247
495
|
continue;
|
|
248
496
|
}
|
|
249
497
|
|
|
250
|
-
const meta = readU16(
|
|
498
|
+
const meta = readU16(policy, hop + PF.HINT_HOP_META_OFFSET);
|
|
251
499
|
let elems = base;
|
|
252
500
|
if ((meta & PF.HINT_META_DYNAMIC_ARRAY) !== 0) {
|
|
253
501
|
const length = readLength(callData, elems);
|
|
@@ -269,369 +517,134 @@ function chainResolve(
|
|
|
269
517
|
}
|
|
270
518
|
|
|
271
519
|
///////////////////////////////////////////////////////////////////////////
|
|
272
|
-
//
|
|
520
|
+
// Operator application
|
|
273
521
|
///////////////////////////////////////////////////////////////////////////
|
|
274
522
|
|
|
275
|
-
type TargetResult =
|
|
276
|
-
| { passed: boolean; value: bigint; ctxOperand?: bigint }
|
|
277
|
-
| { error: NavigationViolationCode }
|
|
278
|
-
| { error: "NON_CANONICAL_VALUE"; value: bigint }
|
|
279
|
-
| { error: "MISSING_CONTEXT"; ctxTypeCode: number };
|
|
280
|
-
|
|
281
523
|
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
* `value` is normalised at the decision point: length operators yield the byte/element count; scalar operators preserve
|
|
285
|
-
* the full 32-byte ABI word, keeping left-aligned `bytesN` decodable. A passing target allocates no string.
|
|
524
|
+
* Resolve the operand of a single-operand comparison with a context source: the property's word, or null when the
|
|
525
|
+
* context does not supply it. Every other operand reads from the operator data, so it resolves to undefined here.
|
|
286
526
|
*/
|
|
287
|
-
function
|
|
288
|
-
|
|
289
|
-
target: number,
|
|
290
|
-
block: TargetBlock,
|
|
527
|
+
function resolveOperand(
|
|
528
|
+
enforcement: Enforcement,
|
|
291
529
|
opCode: number,
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
):
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
// The declared payload of `length` items must lie within calldata past the length word.
|
|
303
|
-
const stride = typeCode === TypeCode.DYNAMIC_ARRAY ? (block.targetMeta & PF.HINT_META_STRIDE_MASK) * 32 : 1;
|
|
304
|
-
const room = callData.length - (target + 32);
|
|
305
|
-
if (stride !== 0 && length > BigInt(Math.floor(room / stride))) return { error: "CALLDATA_OUT_OF_BOUNDS" };
|
|
306
|
-
|
|
307
|
-
const passed = applyOperator(opCode, 0n, length, operandData, typeCode);
|
|
308
|
-
return { passed, value: length };
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const word = loadWord(callData, target);
|
|
312
|
-
if (!(word instanceof Uint8Array)) return { error: word.code };
|
|
313
|
-
|
|
314
|
-
const value = toBigInt(word, 0);
|
|
315
|
-
if (canonicalize(value, typeCode) !== value) {
|
|
316
|
-
return { error: "NON_CANONICAL_VALUE", value };
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
if (ctxOperand !== undefined) {
|
|
320
|
-
if (typeof ctxOperand !== "bigint") return { error: "MISSING_CONTEXT", ctxTypeCode: ctxOperand.ctxTypeCode };
|
|
321
|
-
const passed = applyOperator(opCode, value, 32n, hexToBytes(bigintToHex(ctxOperand)), typeCode);
|
|
322
|
-
return { passed, value, ctxOperand };
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
const passed = applyOperator(opCode, value, 32n, operandData, typeCode);
|
|
326
|
-
return { passed, value };
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
///////////////////////////////////////////////////////////////////////////
|
|
330
|
-
// Calldata rule evaluation
|
|
331
|
-
///////////////////////////////////////////////////////////////////////////
|
|
332
|
-
|
|
333
|
-
/** Coordinates and rule metadata shared by every violation a calldata rule can produce. */
|
|
334
|
-
type RuleFrame = {
|
|
335
|
-
group: number;
|
|
336
|
-
rule: number;
|
|
337
|
-
path: Hex;
|
|
338
|
-
opCode: number;
|
|
339
|
-
operandData: Uint8Array;
|
|
340
|
-
typeCode: number;
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
/** The coordinates, target and operator fields every violation of a calldata rule carries. */
|
|
344
|
-
function ruleFields(frame: RuleFrame) {
|
|
345
|
-
return {
|
|
346
|
-
group: frame.group,
|
|
347
|
-
rule: frame.rule,
|
|
348
|
-
scope: Scope.CALLDATA,
|
|
349
|
-
path: frame.path,
|
|
350
|
-
opCode: frame.opCode,
|
|
351
|
-
operandData: bytesToHex(frame.operandData),
|
|
352
|
-
typeCode: frame.typeCode,
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/** Build a navigation violation for a read the enforcer cannot perform. */
|
|
357
|
-
function navigationViolation(frame: RuleFrame, code: NavigationViolationCode, elementIndex?: number): Violation {
|
|
358
|
-
return {
|
|
359
|
-
code,
|
|
360
|
-
...ruleFields(frame),
|
|
361
|
-
...(elementIndex !== undefined && { elementIndex }),
|
|
362
|
-
};
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/** Convert a failed or erroring target result into its violation. Passing results map to null. */
|
|
366
|
-
function targetViolation(frame: RuleFrame, result: TargetResult, elementIndex?: number): Violation | null {
|
|
367
|
-
if ("error" in result) {
|
|
368
|
-
if (result.error === "MISSING_CONTEXT") {
|
|
369
|
-
// The type reported is the absent property's, not the target the operator reads.
|
|
370
|
-
return {
|
|
371
|
-
code: result.error,
|
|
372
|
-
...ruleFields(frame),
|
|
373
|
-
typeCode: result.ctxTypeCode,
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
if (result.error === "NON_CANONICAL_VALUE") {
|
|
377
|
-
return {
|
|
378
|
-
code: result.error,
|
|
379
|
-
...ruleFields(frame),
|
|
380
|
-
resolvedValue: bigintToHex(result.value),
|
|
381
|
-
...(elementIndex !== undefined && { elementIndex }),
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
|
-
return navigationViolation(frame, result.error, elementIndex);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (result.passed) return null;
|
|
388
|
-
return {
|
|
389
|
-
code: "VALUE_MISMATCH",
|
|
390
|
-
...ruleFields(frame),
|
|
391
|
-
resolvedValue: bigintToHex(result.value),
|
|
392
|
-
...(result.ctxOperand !== undefined && { resolvedOperand: bigintToHex(result.ctxOperand) }),
|
|
393
|
-
...(elementIndex !== undefined && { elementIndex }),
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/** Evaluate a calldata rule by resolving its target through the hint block. */
|
|
398
|
-
function evaluateCalldataRule(
|
|
399
|
-
hint: Uint8Array,
|
|
400
|
-
callDataBytes: Uint8Array,
|
|
401
|
-
baseOffset: number,
|
|
402
|
-
opCode: number,
|
|
403
|
-
operandData: Uint8Array,
|
|
404
|
-
groupIndex: number,
|
|
405
|
-
ruleIndex: number,
|
|
406
|
-
pathHex: Hex,
|
|
407
|
-
context?: Context,
|
|
408
|
-
): Violation | null {
|
|
409
|
-
const header = hint[0]!;
|
|
410
|
-
const hopCount = header & PF.HINT_HOP_COUNT_MASK;
|
|
411
|
-
const kind = header >> PF.HINT_KIND_SHIFT;
|
|
412
|
-
const hopsOffset = PF.HINT_HEADER_SIZE;
|
|
413
|
-
|
|
414
|
-
if (kind !== PF.HINT_KIND_NONE) {
|
|
415
|
-
return evaluateQuantified(
|
|
416
|
-
hint,
|
|
417
|
-
callDataBytes,
|
|
418
|
-
baseOffset,
|
|
419
|
-
kind,
|
|
420
|
-
hopsOffset,
|
|
421
|
-
hopCount,
|
|
422
|
-
opCode,
|
|
423
|
-
operandData,
|
|
424
|
-
groupIndex,
|
|
425
|
-
ruleIndex,
|
|
426
|
-
pathHex,
|
|
427
|
-
context,
|
|
530
|
+
dataOffset: number,
|
|
531
|
+
ruleOffset: number,
|
|
532
|
+
): bigint | null | undefined {
|
|
533
|
+
const source = sourceOf(opCode);
|
|
534
|
+
if (!isSourceCode(source))
|
|
535
|
+
throw new CallciumError(
|
|
536
|
+
"UNKNOWN_OPERATOR",
|
|
537
|
+
`Unknown operator code ${formatCode(opCode & ~Op.NOT, PF.RULE_OPCODE_SIZE)}`,
|
|
538
|
+
ruleOffset,
|
|
428
539
|
);
|
|
540
|
+
switch (source) {
|
|
541
|
+
case Source.CONSTANT:
|
|
542
|
+
return undefined;
|
|
543
|
+
case Source.CONTEXT:
|
|
544
|
+
return enforcement.context[readU16(enforcement.policy, dataOffset)] ?? null;
|
|
545
|
+
default:
|
|
546
|
+
return source satisfies never;
|
|
429
547
|
}
|
|
430
|
-
|
|
431
|
-
const block = readTargetBlock(hint, hopsOffset + hopCount * PF.HINT_HOP_SIZE);
|
|
432
|
-
const frame: RuleFrame = {
|
|
433
|
-
group: groupIndex,
|
|
434
|
-
rule: ruleIndex,
|
|
435
|
-
path: pathHex,
|
|
436
|
-
opCode,
|
|
437
|
-
operandData,
|
|
438
|
-
typeCode: block.typeCode,
|
|
439
|
-
};
|
|
440
|
-
|
|
441
|
-
const base = chainResolve(hint, callDataBytes, baseOffset, hopsOffset, hopCount);
|
|
442
|
-
if (typeof base !== "number") return navigationViolation(frame, base.code);
|
|
443
|
-
|
|
444
|
-
return targetViolation(
|
|
445
|
-
frame,
|
|
446
|
-
evalTarget(
|
|
447
|
-
callDataBytes,
|
|
448
|
-
base + block.targetDelta,
|
|
449
|
-
block,
|
|
450
|
-
opCode,
|
|
451
|
-
operandData,
|
|
452
|
-
referenceOperand(opCode, operandData, context),
|
|
453
|
-
),
|
|
454
|
-
);
|
|
455
548
|
}
|
|
456
549
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
function
|
|
463
|
-
hint: Uint8Array,
|
|
464
|
-
callDataBytes: Uint8Array,
|
|
465
|
-
baseOffset: number,
|
|
466
|
-
kind: number,
|
|
467
|
-
hopsOffset: number,
|
|
468
|
-
hopCount: number,
|
|
550
|
+
/**
|
|
551
|
+
* Apply an operator to `value`, a word of type `typeCode`, using the operator data in
|
|
552
|
+
* `policy[dataOffset : dataOffset+dataLength)` or the context operand already resolved. A length compares as
|
|
553
|
+
* `LENGTH_TYPE`. Handles the NOT flag by inverting the base result.
|
|
554
|
+
*/
|
|
555
|
+
function applyOperator(
|
|
469
556
|
opCode: number,
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
typeCode: block.typeCode,
|
|
489
|
-
};
|
|
490
|
-
|
|
491
|
-
const chained = chainResolve(hint, callDataBytes, baseOffset, hopsOffset, hopCount);
|
|
492
|
-
if (typeof chained !== "number") return navigationViolation(frame, chained.code);
|
|
493
|
-
|
|
494
|
-
const arrayDelta = readU32(hint, frameOffset);
|
|
495
|
-
let elems = chained + arrayDelta;
|
|
496
|
-
|
|
497
|
-
// A frame declaring no count spans a dynamic array, whose length word precedes its elements.
|
|
498
|
-
let count = BigInt(readU16(hint, frameOffset + PF.HINT_FRAME_COUNT_OFFSET));
|
|
499
|
-
if (count === 0n) {
|
|
500
|
-
const length = readLength(callDataBytes, elems);
|
|
501
|
-
if (typeof length !== "bigint") return navigationViolation(frame, length.code);
|
|
502
|
-
count = length;
|
|
503
|
-
elems += 32;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
if (count > BigInt(PF.MAX_QUANTIFIED_ARRAY_LENGTH)) {
|
|
507
|
-
return {
|
|
508
|
-
group: groupIndex,
|
|
509
|
-
rule: ruleIndex,
|
|
510
|
-
code: "QUANTIFIER_LIMIT_EXCEEDED",
|
|
511
|
-
scope: Scope.CALLDATA,
|
|
512
|
-
path: pathHex,
|
|
513
|
-
resolvedValue: bigintToHex(count),
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
const isUniversal = kind === PF.HINT_KIND_ALL;
|
|
518
|
-
if (count === 0n) {
|
|
519
|
-
if (isUniversal) return null;
|
|
520
|
-
return {
|
|
521
|
-
group: groupIndex,
|
|
522
|
-
rule: ruleIndex,
|
|
523
|
-
code: "QUANTIFIER_EMPTY_ARRAY",
|
|
524
|
-
scope: Scope.CALLDATA,
|
|
525
|
-
path: pathHex,
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const meta = readU16(hint, frameOffset + PF.HINT_FRAME_META_OFFSET);
|
|
530
|
-
const elemStride = (meta & PF.HINT_META_STRIDE_MASK) * 32;
|
|
531
|
-
const elemIsDynamic = (meta & PF.HINT_META_ELEM_DYNAMIC) !== 0;
|
|
532
|
-
|
|
533
|
-
// A reference operand resolves identically for every element, so it resolves before iterating.
|
|
534
|
-
const reference = referenceOperand(opCode, operandData, context);
|
|
535
|
-
|
|
536
|
-
for (let elemIndex = 0; elemIndex < count; elemIndex++) {
|
|
537
|
-
const slot = elems + elemIndex * elemStride;
|
|
538
|
-
let elem: number = slot;
|
|
539
|
-
if (elemIsDynamic) {
|
|
540
|
-
const followed = follow(callDataBytes, elems, slot);
|
|
541
|
-
// Navigation failures abort under every quantifier: calldata the enforcer cannot read is not an element that
|
|
542
|
-
// merely fails the operator.
|
|
543
|
-
if (typeof followed !== "number") return navigationViolation(frame, followed.code, elemIndex);
|
|
544
|
-
elem = followed;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
let base: number = elem;
|
|
548
|
-
if (suffixHopCount > 0) {
|
|
549
|
-
const chainedElem = chainResolve(hint, callDataBytes, elem, suffixHopsOffset, suffixHopCount);
|
|
550
|
-
if (typeof chainedElem !== "number") return navigationViolation(frame, chainedElem.code, elemIndex);
|
|
551
|
-
base = chainedElem;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
const applied = evalTarget(callDataBytes, base + block.targetDelta, block, opCode, operandData, reference);
|
|
555
|
-
if ("error" in applied) {
|
|
556
|
-
// Error results end the rule whatever the quantifier: a later element cannot rescue calldata the enforcer cannot
|
|
557
|
-
// read (an abort effect), and a missing context property is missing for every element alike (group-local).
|
|
558
|
-
return targetViolation(frame, applied, elemIndex);
|
|
559
|
-
}
|
|
557
|
+
value: bigint,
|
|
558
|
+
typeCode: number,
|
|
559
|
+
policy: Uint8Array,
|
|
560
|
+
dataOffset: number,
|
|
561
|
+
dataLength: number,
|
|
562
|
+
contextOperand: bigint | undefined,
|
|
563
|
+
ruleOffset: number,
|
|
564
|
+
): boolean {
|
|
565
|
+
const comparison = comparisonOf(opCode);
|
|
566
|
+
if (!isComparisonCode(comparison))
|
|
567
|
+
throw new CallciumError(
|
|
568
|
+
"UNKNOWN_OPERATOR",
|
|
569
|
+
`Unknown operator code ${formatCode(opCode & ~Op.NOT, PF.RULE_OPCODE_SIZE)}`,
|
|
570
|
+
ruleOffset,
|
|
571
|
+
);
|
|
572
|
+
const operand = () => contextOperand ?? loadOperand(policy, dataOffset, typeCode);
|
|
573
|
+
const upper = () => loadOperand(policy, dataOffset + operandWidth(typeCode)!, typeCode);
|
|
574
|
+
let result: boolean;
|
|
560
575
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
576
|
+
switch (comparison) {
|
|
577
|
+
case Op.EQ:
|
|
578
|
+
case Op.LENGTH_EQ:
|
|
579
|
+
result = value === operand();
|
|
580
|
+
break;
|
|
581
|
+
case Op.GT:
|
|
582
|
+
case Op.LENGTH_GT:
|
|
583
|
+
result = compareTyped(value, operand(), typeCode) > 0;
|
|
584
|
+
break;
|
|
585
|
+
case Op.LT:
|
|
586
|
+
case Op.LENGTH_LT:
|
|
587
|
+
result = compareTyped(value, operand(), typeCode) < 0;
|
|
588
|
+
break;
|
|
589
|
+
case Op.GTE:
|
|
590
|
+
case Op.LENGTH_GTE:
|
|
591
|
+
result = compareTyped(value, operand(), typeCode) >= 0;
|
|
592
|
+
break;
|
|
593
|
+
case Op.LTE:
|
|
594
|
+
case Op.LENGTH_LTE:
|
|
595
|
+
result = compareTyped(value, operand(), typeCode) <= 0;
|
|
596
|
+
break;
|
|
597
|
+
case Op.BETWEEN:
|
|
598
|
+
case Op.LENGTH_BETWEEN:
|
|
599
|
+
result = compareTyped(value, operand(), typeCode) >= 0 && compareTyped(value, upper(), typeCode) <= 0;
|
|
600
|
+
break;
|
|
601
|
+
case Op.IN:
|
|
602
|
+
result = isIn(value, typeCode, policy, dataOffset, dataLength);
|
|
603
|
+
break;
|
|
604
|
+
case Op.BITMASK_ALL: {
|
|
605
|
+
const mask = operand();
|
|
606
|
+
result = (value & mask) === mask;
|
|
607
|
+
break;
|
|
566
608
|
}
|
|
609
|
+
case Op.BITMASK_ANY:
|
|
610
|
+
result = (value & operand()) !== 0n;
|
|
611
|
+
break;
|
|
612
|
+
case Op.BITMASK_NONE:
|
|
613
|
+
result = (value & operand()) === 0n;
|
|
614
|
+
break;
|
|
615
|
+
default:
|
|
616
|
+
return comparison satisfies never;
|
|
567
617
|
}
|
|
568
618
|
|
|
569
|
-
|
|
570
|
-
// Existential (ANY) failure: every element rejected the constraint.
|
|
571
|
-
return {
|
|
572
|
-
code: "VALUE_MISMATCH",
|
|
573
|
-
...ruleFields(frame),
|
|
574
|
-
...(typeof reference === "bigint" && { resolvedOperand: bigintToHex(reference) }),
|
|
575
|
-
};
|
|
619
|
+
return (opCode & Op.NOT) !== 0 ? !result : result;
|
|
576
620
|
}
|
|
577
621
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
pathBytes: Uint8Array,
|
|
585
|
-
opCode: number,
|
|
586
|
-
operandData: Uint8Array,
|
|
587
|
-
groupIndex: number,
|
|
588
|
-
ruleIndex: number,
|
|
589
|
-
pathHex: Hex,
|
|
590
|
-
context?: Context,
|
|
591
|
-
): Violation | null {
|
|
592
|
-
const propertyId = readU16(pathBytes, 0);
|
|
593
|
-
const propertyInfo = lookupContextProperty(propertyId);
|
|
594
|
-
const contextValue = context?.[propertyInfo.contextKey];
|
|
595
|
-
const site = { group: groupIndex, rule: ruleIndex, scope: Scope.CONTEXT, path: pathHex };
|
|
596
|
-
|
|
597
|
-
if (contextValue === undefined) {
|
|
598
|
-
return { code: "MISSING_CONTEXT", ...site, typeCode: propertyInfo.typeCode };
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
const value = contextValueToWord(contextValue);
|
|
602
|
-
|
|
603
|
-
let result: boolean;
|
|
604
|
-
let ctxOperand: bigint | undefined;
|
|
605
|
-
if (isContextOp(opCode)) {
|
|
606
|
-
const operand = resolveContextOperand(operandData, context);
|
|
607
|
-
if (typeof operand !== "bigint") {
|
|
608
|
-
return {
|
|
609
|
-
code: "MISSING_CONTEXT",
|
|
610
|
-
...site,
|
|
611
|
-
opCode,
|
|
612
|
-
operandData: bytesToHex(operandData),
|
|
613
|
-
typeCode: operand.ctxTypeCode,
|
|
614
|
-
};
|
|
615
|
-
}
|
|
616
|
-
result = applyOperator(opCode, value, 32n, hexToBytes(bigintToHex(operand)), propertyInfo.typeCode);
|
|
617
|
-
ctxOperand = operand;
|
|
618
|
-
} else {
|
|
619
|
-
result = applyOperator(opCode, value, 32n, operandData, propertyInfo.typeCode);
|
|
622
|
+
/** Compare two 256-bit values, using two's complement interpretation for signed type codes. */
|
|
623
|
+
function compareTyped(value: bigint, operand: bigint, typeCode: number): number {
|
|
624
|
+
if (isSigned(typeCode)) {
|
|
625
|
+
const signedValue = BigInt.asIntN(256, value);
|
|
626
|
+
const signedOperand = BigInt.asIntN(256, operand);
|
|
627
|
+
return signedValue < signedOperand ? -1 : signedValue > signedOperand ? 1 : 0;
|
|
620
628
|
}
|
|
629
|
+
return value < operand ? -1 : value > operand ? 1 : 0;
|
|
630
|
+
}
|
|
621
631
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
+
/**
|
|
633
|
+
* Binary search for `value` among the members of an IN operand. The members are packed as consecutive operands of the
|
|
634
|
+
* width of `typeCode` and strictly ascending as loaded words compared unsigned.
|
|
635
|
+
*/
|
|
636
|
+
function isIn(value: bigint, typeCode: number, policy: Uint8Array, dataOffset: number, dataLength: number): boolean {
|
|
637
|
+
const width = operandWidth(typeCode)!;
|
|
638
|
+
let low = 0;
|
|
639
|
+
let high = dataLength / width - 1;
|
|
640
|
+
while (low <= high) {
|
|
641
|
+
const mid = (low + high) >>> 1;
|
|
642
|
+
const member = loadOperand(policy, dataOffset + mid * width, typeCode);
|
|
643
|
+
if (value === member) return true;
|
|
644
|
+
if (value < member) high = mid - 1;
|
|
645
|
+
else low = mid + 1;
|
|
632
646
|
}
|
|
633
|
-
|
|
634
|
-
return null;
|
|
647
|
+
return false;
|
|
635
648
|
}
|
|
636
649
|
|
|
637
650
|
/** Check and enforce policies against ABI-encoded call data. */
|