@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/src/constraint.ts CHANGED
@@ -1,16 +1,31 @@
1
1
  import { bytesToHex, hexToBytes, toAddress } from "./bytes";
2
- import { PolicyFormat, Op, Scope, ContextProperty, MAX_CONTEXT_PROPERTY_ID } from "./constants";
2
+ import { PolicyFormat, Op, Scope, ContextProperty, lookupContextProperty } from "./constants";
3
3
  import { CallciumError } from "./errors";
4
4
  import { encodePath } from "./path";
5
5
 
6
- import type { Hex, Constraint } from "./types";
6
+ import type { Address, Hex, Constraint } from "./types";
7
7
 
8
8
  ///////////////////////////////////////////////////////////////////////////
9
9
  // Value encoding helpers
10
10
  ///////////////////////////////////////////////////////////////////////////
11
11
 
12
12
  /** Accepted scalar value types for operator arguments. */
13
- export type ScalarValue = bigint | number | boolean | string;
13
+ export type ScalarValue = bigint | boolean | string;
14
+
15
+ // Bounds of the integers a 32-byte operand word represents, spanning signed and unsigned targets.
16
+ const OPERAND_MIN = -(2n ** 255n);
17
+ const OPERAND_MAX = 2n ** 256n - 1n;
18
+
19
+ /**
20
+ * Require an operand within the integers a 32-byte word represents and return it.
21
+ * @throws {CallciumError} When the operand lies outside the word's range.
22
+ */
23
+ function checkOperandRange(value: bigint): bigint {
24
+ if (value < OPERAND_MIN || value > OPERAND_MAX) {
25
+ throw new CallciumError("OPERAND_OVERFLOW", `Operand ${value} is outside the range a 32-byte word represents`);
26
+ }
27
+ return value;
28
+ }
14
29
 
15
30
  /** Strip an optional 0x prefix and validate a 40-hex-char (20-byte) address body. */
16
31
  function addressBody(value: string): string {
@@ -32,8 +47,8 @@ function encodeWord(value: ScalarValue): Uint8Array {
32
47
  return word;
33
48
  }
34
49
 
35
- // bigint | number — unsigned 256-bit big-endian.
36
- let bigValue = typeof value === "number" ? BigInt(value) : value;
50
+ // A negative operand occupies the word in two's complement, which is the signed target's encoding.
51
+ let bigValue = checkOperandRange(value);
37
52
  for (let i = 31; i >= 0; i--) {
38
53
  word[i] = Number(bigValue & 0xffn);
39
54
  bigValue >>= 8n;
@@ -51,35 +66,41 @@ function singleOp(opCode: number, value: ScalarValue): Hex {
51
66
 
52
67
  /** Pack a range operator (opCode byte + min word + max word). */
53
68
  function rangeOp(opCode: number, min: bigint, max: bigint): Hex {
54
- if (min > max) {
55
- throw new CallciumError("INVALID_RANGE", `Range min (${min}) must not exceed max (${max})`);
69
+ // Order compares the operands as written; the encoded words of a signed range run the other way.
70
+ const minValue = checkOperandRange(min);
71
+ const maxValue = checkOperandRange(max);
72
+ if (minValue > maxValue) {
73
+ throw new CallciumError("INVALID_RANGE", `Range min (${minValue}) must not exceed max (${maxValue})`);
56
74
  }
57
75
  const buffer = new Uint8Array(65);
58
76
  buffer[0] = opCode;
59
- buffer.set(encodeWord(min), 1);
60
- buffer.set(encodeWord(max), 33);
77
+ buffer.set(encodeWord(minValue), 1);
78
+ buffer.set(encodeWord(maxValue), 33);
61
79
  return bytesToHex(buffer);
62
80
  }
63
81
 
64
- /** Require a defined context property ID and return it. */
65
- function checkContextPropertyId(contextPropertyId: number): number {
66
- if (!Number.isInteger(contextPropertyId) || contextPropertyId < 0 || contextPropertyId > MAX_CONTEXT_PROPERTY_ID) {
67
- throw new CallciumError("UNKNOWN_CONTEXT_PROPERTY", `Unknown context property ID ${contextPropertyId}`);
68
- }
69
- return contextPropertyId;
82
+ /**
83
+ * Require an assigned context property ID and return it as an operand word.
84
+ * @throws {CallciumError} When the ID names no assigned property.
85
+ */
86
+ function contextOperand(contextPropertyId: number): bigint {
87
+ lookupContextProperty(contextPropertyId);
88
+ return BigInt(contextPropertyId);
70
89
  }
71
90
 
72
91
  /** Convert values to bigint, sort ascending (unsigned), deduplicate, and pack as set payload. */
73
92
  function setOp(opCode: number, values: readonly ScalarValue[]): Hex {
74
- const bigs = values.map((v) => {
75
- if (typeof v === "bigint") return v;
76
- if (typeof v === "number") return BigInt(v);
77
- if (typeof v === "boolean") return v ? 1n : 0n;
93
+ // PWF-21 orders members by their 32-byte encodings, so a negative operand and its unsigned
94
+ // alias are one member at one position. Normalising after the domain check keeps an
95
+ // out-of-range value an error rather than folding it into the word.
96
+ const words = values.map((value) => {
97
+ if (typeof value === "bigint") return BigInt.asUintN(256, checkOperandRange(value));
98
+ if (typeof value === "boolean") return value ? 1n : 0n;
78
99
  // String address.
79
- return BigInt("0x" + addressBody(v));
100
+ return BigInt("0x" + addressBody(value));
80
101
  });
81
102
 
82
- const deduped = [...new Set(bigs)].toSorted((a, b) => (a < b ? -1 : a > b ? 1 : 0));
103
+ const deduped = [...new Set(words)].toSorted((a, b) => (a < b ? -1 : a > b ? 1 : 0));
83
104
 
84
105
  if (deduped.length === 0) {
85
106
  throw new CallciumError("EMPTY_SET", "Set must contain at least one value");
@@ -96,6 +117,31 @@ function setOp(opCode: number, values: readonly ScalarValue[]): Hex {
96
117
  return bytesToHex(buffer);
97
118
  }
98
119
 
120
+ ///////////////////////////////////////////////////////////////////////////
121
+ // Operand provenance
122
+ ///////////////////////////////////////////////////////////////////////////
123
+
124
+ /**
125
+ * Extremes of the numeric operands a builder's value operators received, as written. Encoding folds
126
+ * a negative operand onto its unsigned alias, so the sign survives nowhere else.
127
+ */
128
+ export type OperandExtremes = {
129
+ /** Most negative operand written, or zero when none was negative. */
130
+ leastNegative: bigint;
131
+ /** Greatest operand written, or zero when none exceeded it. */
132
+ greatest: bigint;
133
+ };
134
+
135
+ /**
136
+ * Extremes of the numeric operands the value operators of `constraint` received, as written.
137
+ * They outlive edits to `operators`: removing an operator does not remove what was written to
138
+ * produce it, and a fresh builder starts at zero. Undefined for a plain `Constraint`, whose
139
+ * operators arrive already encoded.
140
+ */
141
+ export function readOperandExtremes(constraint: Constraint | ConstraintBuilder): OperandExtremes | undefined {
142
+ return "operandExtremes" in constraint ? constraint.operandExtremes : undefined;
143
+ }
144
+
99
145
  ///////////////////////////////////////////////////////////////////////////
100
146
  // ConstraintBuilder
101
147
  ///////////////////////////////////////////////////////////////////////////
@@ -104,20 +150,36 @@ function setOp(opCode: number, values: readonly ScalarValue[]): Hex {
104
150
  * Mutable builder that accumulates operators targeting a single path.
105
151
  * Implements the `Constraint` interface so it can be passed directly to policy builders.
106
152
  */
107
- export class ConstraintBuilder implements Constraint {
153
+ export class ConstraintBuilder<Operand extends ScalarValue = ScalarValue> implements Constraint {
108
154
  readonly scope: number;
109
155
  readonly path: Hex;
110
156
  readonly operators: Hex[];
157
+ /**
158
+ * Extremes of the numeric operands the value operators received, as written. Only the extremes
159
+ * can offend: an operand folds into a target's range from below it or from above it, and
160
+ * whichever reaches furthest arrives first. Read it through {@link readOperandExtremes}.
161
+ * @internal
162
+ */
163
+ readonly operandExtremes: OperandExtremes;
111
164
 
112
165
  /** @internal */
113
166
  constructor(scope: number, path: Hex) {
114
167
  this.scope = scope;
115
168
  this.path = path;
116
169
  this.operators = [];
170
+ this.operandExtremes = { leastNegative: 0n, greatest: 0n };
117
171
  }
118
172
 
119
- /** Push a pre-encoded operator hex string and return this for chaining. */
120
- private push(opHex: Hex): this {
173
+ /**
174
+ * Push a pre-encoded operator hex string and return this for chaining.
175
+ * `values` are the operands as written; the numeric ones join the record.
176
+ */
177
+ private push(opHex: Hex, values: readonly ScalarValue[] = []): this {
178
+ for (const value of values) {
179
+ if (typeof value !== "bigint") continue;
180
+ if (value < this.operandExtremes.leastNegative) this.operandExtremes.leastNegative = value;
181
+ if (value > this.operandExtremes.greatest) this.operandExtremes.greatest = value;
182
+ }
121
183
  this.operators.push(opHex);
122
184
  return this;
123
185
  }
@@ -127,51 +189,51 @@ export class ConstraintBuilder implements Constraint {
127
189
  ///////////////////////////////////////////////////////////////////////////
128
190
 
129
191
  /** Assert the value equals `value`. */
130
- eq(value: ScalarValue): this {
131
- return this.push(singleOp(Op.EQ, value));
192
+ eq(value: Operand): this {
193
+ return this.push(singleOp(Op.EQ, value), [value]);
132
194
  }
133
195
 
134
196
  /** Assert the value does not equal `value`. */
135
- neq(value: ScalarValue): this {
136
- return this.push(singleOp(Op.EQ | Op.NOT, value));
197
+ neq(value: Operand): this {
198
+ return this.push(singleOp(Op.EQ | Op.NOT, value), [value]);
137
199
  }
138
200
 
139
201
  /** Assert the value equals the context property `contextPropertyId`. */
140
202
  eqCtx(contextPropertyId: number): this {
141
- return this.push(singleOp(Op.EQ_CTX, checkContextPropertyId(contextPropertyId)));
203
+ return this.push(singleOp(Op.EQ_CTX, contextOperand(contextPropertyId)));
142
204
  }
143
205
 
144
206
  /** Assert the value does not equal the context property `contextPropertyId`. */
145
207
  neqCtx(contextPropertyId: number): this {
146
- return this.push(singleOp(Op.EQ_CTX | Op.NOT, checkContextPropertyId(contextPropertyId)));
208
+ return this.push(singleOp(Op.EQ_CTX | Op.NOT, contextOperand(contextPropertyId)));
147
209
  }
148
210
 
149
211
  /** Assert the value is greater than `bound`. */
150
- gt(bound: bigint | number): this {
151
- return this.push(singleOp(Op.GT, bound));
212
+ gt(bound: bigint): this {
213
+ return this.push(singleOp(Op.GT, bound), [bound]);
152
214
  }
153
215
 
154
216
  /** Assert the value is less than `bound`. */
155
- lt(bound: bigint | number): this {
156
- return this.push(singleOp(Op.LT, bound));
217
+ lt(bound: bigint): this {
218
+ return this.push(singleOp(Op.LT, bound), [bound]);
157
219
  }
158
220
 
159
221
  /** Assert the value is greater than or equal to `bound`. */
160
- gte(bound: bigint | number): this {
161
- return this.push(singleOp(Op.GTE, bound));
222
+ gte(bound: bigint): this {
223
+ return this.push(singleOp(Op.GTE, bound), [bound]);
162
224
  }
163
225
 
164
226
  /** Assert the value is less than or equal to `bound`. */
165
- lte(bound: bigint | number): this {
166
- return this.push(singleOp(Op.LTE, bound));
227
+ lte(bound: bigint): this {
228
+ return this.push(singleOp(Op.LTE, bound), [bound]);
167
229
  }
168
230
 
169
231
  /**
170
232
  * Assert the value is within [min, max] inclusive.
171
233
  * @throws {CallciumError} If min > max.
172
234
  */
173
- between(min: bigint | number, max: bigint | number): this {
174
- return this.push(rangeOp(Op.BETWEEN, BigInt(min), BigInt(max)));
235
+ between(min: bigint, max: bigint): this {
236
+ return this.push(rangeOp(Op.BETWEEN, min, max), [min, max]);
175
237
  }
176
238
 
177
239
  ///////////////////////////////////////////////////////////////////////////
@@ -183,16 +245,16 @@ export class ConstraintBuilder implements Constraint {
183
245
  * Values are sorted and deduplicated before encoding.
184
246
  * @throws {CallciumError} If the set is empty after deduplication.
185
247
  */
186
- isIn(values: readonly ScalarValue[]): this {
187
- return this.push(setOp(Op.IN, values));
248
+ isIn(values: readonly Operand[]): this {
249
+ return this.push(setOp(Op.IN, values), values);
188
250
  }
189
251
 
190
252
  /**
191
253
  * Assert the value is not a member of the set.
192
254
  * @throws {CallciumError} If the set is empty after deduplication.
193
255
  */
194
- notIn(values: readonly ScalarValue[]): this {
195
- return this.push(setOp(Op.IN | Op.NOT, values));
256
+ notIn(values: readonly Operand[]): this {
257
+ return this.push(setOp(Op.IN | Op.NOT, values), values);
196
258
  }
197
259
 
198
260
  ///////////////////////////////////////////////////////////////////////////
@@ -218,37 +280,37 @@ export class ConstraintBuilder implements Constraint {
218
280
  // Length operators
219
281
  ///////////////////////////////////////////////////////////////////////////
220
282
 
221
- /** Assert the runtime length equals `n`. */
222
- lengthEq(n: bigint | number): this {
223
- return this.push(singleOp(Op.LENGTH_EQ, n));
283
+ /** Assert the runtime length equals `length`. */
284
+ lengthEq(length: bigint): this {
285
+ return this.push(singleOp(Op.LENGTH_EQ, length));
224
286
  }
225
287
 
226
- /** Assert the runtime length is greater than `n`. */
227
- lengthGt(n: bigint | number): this {
228
- return this.push(singleOp(Op.LENGTH_GT, n));
288
+ /** Assert the runtime length is greater than `length`. */
289
+ lengthGt(length: bigint): this {
290
+ return this.push(singleOp(Op.LENGTH_GT, length));
229
291
  }
230
292
 
231
- /** Assert the runtime length is less than `n`. */
232
- lengthLt(n: bigint | number): this {
233
- return this.push(singleOp(Op.LENGTH_LT, n));
293
+ /** Assert the runtime length is less than `length`. */
294
+ lengthLt(length: bigint): this {
295
+ return this.push(singleOp(Op.LENGTH_LT, length));
234
296
  }
235
297
 
236
- /** Assert the runtime length is greater than or equal to `n`. */
237
- lengthGte(n: bigint | number): this {
238
- return this.push(singleOp(Op.LENGTH_GTE, n));
298
+ /** Assert the runtime length is greater than or equal to `length`. */
299
+ lengthGte(length: bigint): this {
300
+ return this.push(singleOp(Op.LENGTH_GTE, length));
239
301
  }
240
302
 
241
- /** Assert the runtime length is less than or equal to `n`. */
242
- lengthLte(n: bigint | number): this {
243
- return this.push(singleOp(Op.LENGTH_LTE, n));
303
+ /** Assert the runtime length is less than or equal to `length`. */
304
+ lengthLte(length: bigint): this {
305
+ return this.push(singleOp(Op.LENGTH_LTE, length));
244
306
  }
245
307
 
246
308
  /**
247
309
  * Assert the runtime length is within [min, max] inclusive.
248
310
  * @throws {CallciumError} If min > max.
249
311
  */
250
- lengthBetween(min: bigint | number, max: bigint | number): this {
251
- return this.push(rangeOp(Op.LENGTH_BETWEEN, BigInt(min), BigInt(max)));
312
+ lengthBetween(min: bigint, max: bigint): this {
313
+ return this.push(rangeOp(Op.LENGTH_BETWEEN, min, max));
252
314
  }
253
315
  }
254
316
 
@@ -266,41 +328,41 @@ export function arg(...steps: number[]): ConstraintBuilder {
266
328
  }
267
329
 
268
330
  /** Target the `msg.sender` context property. */
269
- export function msgSender(): ConstraintBuilder {
331
+ export function msgSender(): ConstraintBuilder<Address> {
270
332
  return new ConstraintBuilder(Scope.CONTEXT, encodePath([ContextProperty.MSG_SENDER]));
271
333
  }
272
334
 
273
335
  /** Target the `msg.value` context property. */
274
- export function msgValue(): ConstraintBuilder {
336
+ export function msgValue(): ConstraintBuilder<bigint> {
275
337
  return new ConstraintBuilder(Scope.CONTEXT, encodePath([ContextProperty.MSG_VALUE]));
276
338
  }
277
339
 
278
340
  /** Target the `block.timestamp` context property. */
279
- export function blockTimestamp(): ConstraintBuilder {
341
+ export function blockTimestamp(): ConstraintBuilder<bigint> {
280
342
  return new ConstraintBuilder(Scope.CONTEXT, encodePath([ContextProperty.BLOCK_TIMESTAMP]));
281
343
  }
282
344
 
283
345
  /** Target the `block.number` context property. */
284
- export function blockNumber(): ConstraintBuilder {
346
+ export function blockNumber(): ConstraintBuilder<bigint> {
285
347
  return new ConstraintBuilder(Scope.CONTEXT, encodePath([ContextProperty.BLOCK_NUMBER]));
286
348
  }
287
349
 
288
350
  /** Target the `block.chainid` context property. */
289
- export function chainId(): ConstraintBuilder {
351
+ export function chainId(): ConstraintBuilder<bigint> {
290
352
  return new ConstraintBuilder(Scope.CONTEXT, encodePath([ContextProperty.CHAIN_ID]));
291
353
  }
292
354
 
293
355
  /** Target the `tx.origin` context property. */
294
- export function txOrigin(): ConstraintBuilder {
356
+ export function txOrigin(): ConstraintBuilder<Address> {
295
357
  return new ConstraintBuilder(Scope.CONTEXT, encodePath([ContextProperty.TX_ORIGIN]));
296
358
  }
297
359
 
298
360
  /** Target the `block.basefee` context property. */
299
- export function baseFee(): ConstraintBuilder {
361
+ export function baseFee(): ConstraintBuilder<bigint> {
300
362
  return new ConstraintBuilder(Scope.CONTEXT, encodePath([ContextProperty.BASE_FEE]));
301
363
  }
302
364
 
303
365
  /** Target the `tx.gasprice` context property. */
304
- export function gasPrice(): ConstraintBuilder {
366
+ export function gasPrice(): ConstraintBuilder<bigint> {
305
367
  return new ConstraintBuilder(Scope.CONTEXT, encodePath([ContextProperty.GAS_PRICE]));
306
368
  }
@@ -1,6 +1,6 @@
1
- import { readU16, readU24 } from "./bytes";
1
+ import { readU16 } from "./bytes";
2
2
  import { DescriptorFormat as DF, TypeCode } from "./constants";
3
- import { Descriptor } from "./descriptor";
3
+ import { Descriptor, readNode } from "./descriptor";
4
4
  import { CallciumError } from "./errors";
5
5
  import { classifyTypeCode, lookupTypeCode } from "./operators";
6
6
  import { address, array, bool, bytes, bytesN, function_, intN, string_, tuple, uintN } from "./type-desc";
@@ -23,11 +23,17 @@ function commaPositions(input: string, start: number, end: number): number[] {
23
23
  if (char === "(" || char === "[") {
24
24
  depth++;
25
25
  } else if (char === ")" || char === "]") {
26
+ if (depth === 0) {
27
+ throw new CallciumError("MALFORMED_TYPE_STRING", `Unmatched '${char}' at position ${i}`);
28
+ }
26
29
  depth--;
27
30
  } else if (char === "," && depth === 0) {
28
31
  positions.push(i);
29
32
  }
30
33
  }
34
+ if (depth !== 0) {
35
+ throw new CallciumError("MALFORMED_TYPE_STRING", `Unclosed '${input.slice(start, end)}'`);
36
+ }
31
37
  return positions;
32
38
  }
33
39
 
@@ -102,12 +108,12 @@ function parseTuple(input: string, start: number, end: number): Uint8Array {
102
108
  const commas = commaPositions(input, innerStart, innerEnd);
103
109
 
104
110
  const segments: Array<[number, number]> = [];
105
- let segStart = innerStart;
111
+ let segmentStart = innerStart;
106
112
  for (const comma of commas) {
107
- segments.push([segStart, comma]);
108
- segStart = comma + 1;
113
+ segments.push([segmentStart, comma]);
114
+ segmentStart = comma + 1;
109
115
  }
110
- segments.push([segStart, innerEnd]);
116
+ segments.push([segmentStart, innerEnd]);
111
117
 
112
118
  // An empty tuple has no fields to parse; the tuple builder rejects the count.
113
119
  if (segments.length === 1 && segments[0]![0] === segments[0]![1]) return tuple([]);
@@ -131,9 +137,11 @@ function parseBaseType(input: string, start: number, end: number): Uint8Array {
131
137
  if (segment === "string") return string_();
132
138
  if (segment.startsWith("(")) return parseTuple(input, start, end);
133
139
 
134
- if (segment.startsWith("uint")) return uintN(parseUint(input, start + 4, end));
135
- if (segment.startsWith("int")) return intN(parseUint(input, start + 3, end));
136
- if (segment.startsWith("bytes")) return bytesN(parseUint(input, start + 5, end));
140
+ // A prefix with no width after it spells no type, so it falls through to the unknown-type report
141
+ // rather than being read as a width that is not there.
142
+ if (segment.length > 4 && segment.startsWith("uint")) return uintN(parseUint(input, start + 4, end));
143
+ if (segment.length > 3 && segment.startsWith("int")) return intN(parseUint(input, start + 3, end));
144
+ if (segment.length > 5 && segment.startsWith("bytes")) return bytesN(parseUint(input, start + 5, end));
137
145
 
138
146
  throw new CallciumError("UNKNOWN_TYPE", `Unrecognised type '${segment}'`);
139
147
  }
@@ -153,9 +161,9 @@ function parseType(input: string, start: number, end: number): Uint8Array {
153
161
  throw new CallciumError("MALFORMED_TYPE_STRING", "Empty type segment");
154
162
  }
155
163
 
156
- // Collect array suffixes by scanning backward.
157
- // Each suffix is either `[]` (dynamic) or `[N]` (static with length N).
158
- const suffixes: Array<number | undefined> = [];
164
+ // Locate array suffixes by scanning backward, reading no length yet: the base names the type, so
165
+ // an unrecognised one is reported before a suffix it carries is parsed.
166
+ const spans: Array<[number, number]> = [];
159
167
  let baseEnd = end;
160
168
 
161
169
  while (baseEnd > start && input[baseEnd - 1] === "]") {
@@ -168,24 +176,16 @@ function parseType(input: string, start: number, end: number): Uint8Array {
168
176
  if (input[openIndex] !== "[") {
169
177
  throw new CallciumError("MALFORMED_TYPE_STRING", `Unmatched ']' at position ${closeIndex}`);
170
178
  }
171
- const innerStart = openIndex + 1;
172
- const innerEnd = closeIndex;
173
- // A dynamic array spells no length at all.
174
- suffixes.unshift(innerStart === innerEnd ? undefined : parseUint(input, innerStart, innerEnd));
179
+ spans.unshift([openIndex + 1, closeIndex]);
175
180
  baseEnd = openIndex;
176
181
  }
177
182
 
178
- // Every `[` a base carries belongs to a suffix the scan above already took; one left behind is
179
- // unclosed. A tuple base is exempt: the brackets inside it belong to its fields.
180
- if (input[start] !== "(" && input.slice(start, baseEnd).includes("[")) {
181
- throw new CallciumError("MALFORMED_TYPE_STRING", `Unmatched '[' in '${input.slice(start, baseEnd)}'`);
182
- }
183
-
184
183
  let desc = parseBaseType(input, start, baseEnd);
185
184
 
186
185
  // Apply suffixes left-to-right: the leftmost suffix is the outermost array.
187
- for (const length of suffixes) {
188
- desc = array(desc, length);
186
+ // A dynamic array spells no length at all.
187
+ for (const [innerStart, innerEnd] of spans) {
188
+ desc = array(desc, innerStart === innerEnd ? undefined : parseUint(input, innerStart, innerEnd));
189
189
  }
190
190
 
191
191
  return desc;
@@ -202,6 +202,7 @@ function parseType(input: string, start: number, end: number): Uint8Array {
202
202
  * @returns Binary descriptor bytes starting with the version+paramCount header.
203
203
  * @throws {CallciumError} With code `MALFORMED_TYPE_STRING` for malformed input.
204
204
  * @throws {CallciumError} With code `UNKNOWN_TYPE` for unrecognised type names.
205
+ * @throws {CallciumError} With code `NESTING_TOO_DEEP` when composite nesting exceeds `MAX_NESTING_DEPTH`.
205
206
  */
206
207
  function fromTypes(typesCsv: string): Uint8Array {
207
208
  if (typesCsv === "") {
@@ -231,6 +232,8 @@ function fromTypes(typesCsv: string): Uint8Array {
231
232
  result.set(desc, offset);
232
233
  offset += desc.length;
233
234
  }
235
+ // Nesting depth is a property of the assembled descriptor, not of any single node.
236
+ decodeDescriptor(result);
234
237
  return result;
235
238
  }
236
239
 
@@ -240,7 +243,7 @@ function fromTypes(typesCsv: string): Uint8Array {
240
243
 
241
244
  /** Reconstruct an ABI type string from a descriptor node at the given offset. */
242
245
  function nodeToTypeString(desc: Uint8Array, offset: number): string {
243
- const typeCode = desc[offset]!;
246
+ const { typeCode } = Descriptor.inspect(desc, offset);
244
247
 
245
248
  if (typeCode === TypeCode.TUPLE) {
246
249
  const fieldCount = Descriptor.tupleFieldCount(desc, offset);
@@ -296,21 +299,14 @@ type ParseResult = { typeCode: number; isDynamic: boolean; staticSize: number; n
296
299
 
297
300
  /** Recursively parse a single descriptor node starting at offset. */
298
301
  function parseNode(data: Uint8Array, offset: number, depth: number): ParseResult {
299
- if (offset >= data.length) {
300
- throw new CallciumError("UNEXPECTED_END", "Unexpected end of descriptor", offset);
301
- }
302
-
303
- const code = data[offset]!;
302
+ const { typeCode: code, staticWords, nodeLength } = readNode(data, offset);
304
303
  const info = classifyTypeCode(code);
305
- const metaOffset = offset + DF.TYPECODE_SIZE;
304
+ const isDynamic = staticWords === 0;
305
+ const staticSize = isDynamic ? 0 : staticWords * 32;
306
+ const nodeEnd = offset + nodeLength;
306
307
 
307
308
  if (info.typeClass === "elementary") {
308
- return {
309
- typeCode: code,
310
- isDynamic: info.isDynamic,
311
- staticSize: info.isDynamic ? 0 : 32,
312
- next: metaOffset,
313
- };
309
+ return { typeCode: code, isDynamic, staticSize, next: nodeEnd };
314
310
  }
315
311
 
316
312
  // Only composites nest; a leaf below the deepest allowed composite is fine.
@@ -322,33 +318,8 @@ function parseNode(data: Uint8Array, offset: number, depth: number): ParseResult
322
318
  );
323
319
  }
324
320
 
325
- const metaEnd = metaOffset + DF.COMPOSITE_META_SIZE;
326
- if (metaEnd > data.length) {
327
- throw new CallciumError("UNEXPECTED_END", "Incomplete composite metadata", offset);
328
- }
329
-
330
- const meta = readU24(data, metaOffset);
331
- const staticWords = meta >> DF.META_STATIC_WORDS_SHIFT;
332
- const nodeLength = meta & DF.META_NODE_LENGTH_MASK;
333
-
334
- const minHeader = info.typeClass === "tuple" ? DF.TUPLE_HEADER_SIZE : DF.ARRAY_HEADER_SIZE;
335
- if (nodeLength < minHeader) {
336
- throw new CallciumError(
337
- "NODE_LENGTH_TOO_SMALL",
338
- `Composite node length ${nodeLength} is smaller than minimum header ${minHeader}`,
339
- offset,
340
- );
341
- }
342
- if (offset + nodeLength > data.length) {
343
- throw new CallciumError("NODE_OVERFLOW", "Composite node extends beyond descriptor", offset);
344
- }
345
-
346
- const isDynamic = staticWords === 0;
347
- const staticSize = isDynamic ? 0 : staticWords * 32;
348
- const nodeEnd = offset + nodeLength;
349
-
350
321
  if (info.typeClass === "tuple") {
351
- const fieldCountOffset = metaEnd;
322
+ const fieldCountOffset = offset + DF.TYPECODE_SIZE + DF.COMPOSITE_META_SIZE;
352
323
  if (fieldCountOffset + 2 > data.length) {
353
324
  throw new CallciumError("UNEXPECTED_END", "Incomplete tuple header", offset);
354
325
  }
@@ -426,12 +397,12 @@ export function decodeDescriptor(data: Uint8Array): { descriptor: DecodedDescrip
426
397
  let cursor: number = DF.HEADER_SIZE;
427
398
 
428
399
  while (cursor < data.length) {
429
- if (params.length >= DF.MAX_PARAMS) {
400
+ const { typeCode, isDynamic, staticSize, next } = parseNode(data, cursor, 1);
401
+
402
+ if (params.length === DF.MAX_PARAMS) {
430
403
  throw new CallciumError("TOO_MANY_PARAMS", `Descriptor exceeds the maximum of ${DF.MAX_PARAMS} top-level params`);
431
404
  }
432
405
 
433
- const { typeCode, isDynamic, staticSize, next } = parseNode(data, cursor, 1);
434
-
435
406
  params.push({
436
407
  index: params.length,
437
408
  typeCode,