@callcium/sdk 0.9.0 → 0.11.0

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