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