@callcium/sdk 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,28 @@
1
- import { bytesToHex } from "./bytes";
2
- import { Scope, TypeCode, lookupContextProperty } from "./constants";
1
+ import { bigintToHex, bytesToHex, hexToBytes } from "./bytes";
2
+ import {
3
+ comparisonOf,
4
+ formatCode,
5
+ lookupContextProperty,
6
+ LoopQuantifier,
7
+ Op,
8
+ PolicyFormat as PF,
9
+ Scope,
10
+ Source,
11
+ sourceOf,
12
+ TypeCode,
13
+ } from "./constants";
3
14
  import { readOperandExtremes } from "./constraint";
4
- import { Descriptor } from "./descriptor";
5
15
  import { DescriptorCoder } from "./descriptor-coder";
6
16
  import { CallciumError, ValidationError } from "./errors";
7
- import { isSigned } from "./operators";
8
- import { isQuantifier, parsePathSteps } from "./path";
17
+ import { Hint } from "./hint";
18
+ import { canonicalize, isSigned, loadOperand, operandTypeOf, operandWidth, toBigInt } from "./operators";
19
+ import { encodePath, parsePathSteps, Quantifier } from "./path";
9
20
  import { PolicyCoder } from "./policy-coder";
10
21
  import { PolicyValidator } from "./policy-validator";
11
22
  import { SignatureParser } from "./signature";
12
23
 
13
- import type { Constraint, Hex, Issue, PolicyData } from "./types";
24
+ import type { Hex } from "./types";
25
+ import type { BodyRule, Constraint, Issue, PolicyData, Rule } from "./types";
14
26
 
15
27
  ///////////////////////////////////////////////////////////////////////////
16
28
  // Internal types
@@ -20,80 +32,9 @@ type PolicyDraft = {
20
32
  isSelectorless: boolean;
21
33
  selector: Hex;
22
34
  descriptor: Uint8Array;
23
- groups: Constraint[][];
24
- /** Scope-qualified paths already taken in the group being built. */
25
- groupPathKeys: Set<string>;
35
+ groups: Rule[][];
26
36
  };
27
37
 
28
- ///////////////////////////////////////////////////////////////////////////
29
- // Path validation
30
- ///////////////////////////////////////////////////////////////////////////
31
-
32
- /** Validate a context-scope path and return the referenced property's type code. */
33
- function validateContextPath(steps: number[]): number {
34
- if (steps.length !== 1) {
35
- throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope path must be exactly one step");
36
- }
37
- return lookupContextProperty(steps[0]!).typeCode;
38
- }
39
-
40
- /** Validate a calldata-scope path against the descriptor and return the target's type code. */
41
- function validateCalldataPath(steps: number[], desc: Uint8Array): number {
42
- const argIndex = steps[0]!;
43
- const paramCount = Descriptor.paramCount(desc);
44
- if (argIndex >= paramCount) {
45
- throw new CallciumError(
46
- "PARAM_INDEX_OUT_OF_BOUNDS",
47
- `Param index ${argIndex} out of range (descriptor has ${paramCount} params)`,
48
- );
49
- }
50
-
51
- let offset = Descriptor.paramOffset(desc, argIndex);
52
- let hasQuantifier = false;
53
-
54
- for (let i = 1; i < steps.length; i++) {
55
- const step = steps[i]!;
56
- const info = Descriptor.inspect(desc, offset);
57
- const isArray = info.typeCode === TypeCode.STATIC_ARRAY || info.typeCode === TypeCode.DYNAMIC_ARRAY;
58
-
59
- if (isQuantifier(step)) {
60
- if (!isArray) {
61
- throw new CallciumError("QUANTIFIER_ON_NON_ARRAY", "Quantifier step is only valid on an array node");
62
- }
63
- if (hasQuantifier) {
64
- throw new CallciumError("NESTED_QUANTIFIER", "Nested quantifiers are not allowed");
65
- }
66
- hasQuantifier = true;
67
- }
68
-
69
- if (info.typeCode === TypeCode.TUPLE) {
70
- const fieldCount = Descriptor.tupleFieldCount(desc, offset);
71
- if (step >= fieldCount) {
72
- throw new CallciumError(
73
- "TUPLE_FIELD_OUT_OF_BOUNDS",
74
- `Tuple field index ${step} out of range (tuple has ${fieldCount} fields)`,
75
- );
76
- }
77
- offset = Descriptor.tupleFieldOffset(desc, offset, step);
78
- } else if (isArray) {
79
- if (!isQuantifier(step) && info.typeCode === TypeCode.STATIC_ARRAY) {
80
- const arrayLength = Descriptor.staticArrayLength(desc, offset);
81
- if (step >= arrayLength) {
82
- throw new CallciumError(
83
- "STATIC_ARRAY_INDEX_OUT_OF_BOUNDS",
84
- `Array index ${step} out of range (static array has ${arrayLength} elements)`,
85
- );
86
- }
87
- }
88
- offset = Descriptor.arrayElementOffset(offset);
89
- } else {
90
- throw new CallciumError("NOT_COMPOSITE", "Cannot descend into an elementary type");
91
- }
92
- }
93
-
94
- return Descriptor.inspect(desc, offset).typeCode;
95
- }
96
-
97
38
  ///////////////////////////////////////////////////////////////////////////
98
39
  // Operand domain
99
40
  ///////////////////////////////////////////////////////////////////////////
@@ -101,20 +42,6 @@ function validateCalldataPath(steps: number[], desc: Uint8Array): number {
101
42
  // Values a 32-byte word represents, which is the distance between an operand and its alias.
102
43
  const WORD_VALUES = 1n << 256n;
103
44
 
104
- /** Integers an integer target admits, or null for any other type. */
105
- function targetBounds(typeCode: number): { min: bigint; max: bigint } | null {
106
- if (typeCode >= TypeCode.UINT_MIN && typeCode <= TypeCode.UINT_MAX) {
107
- const bits = BigInt(typeCode - TypeCode.UINT_MIN + 1) * 8n;
108
- return { min: 0n, max: (1n << bits) - 1n };
109
- }
110
- if (typeCode >= TypeCode.INT_MIN && typeCode <= TypeCode.INT_MAX) {
111
- const bits = BigInt(typeCode - TypeCode.INT_MIN + 1) * 8n;
112
- const half = 1n << (bits - 1n);
113
- return { min: -half, max: half - 1n };
114
- }
115
- return null;
116
- }
117
-
118
45
  /** Reject an operand outside the target's range whose word carries a value inside it. */
119
46
  function reject(written: bigint, folded: bigint): never {
120
47
  throw new CallciumError(
@@ -125,14 +52,13 @@ function reject(written: bigint, folded: bigint): never {
125
52
 
126
53
  /**
127
54
  * Reject an operand that two's complement folds onto a value the target admits. Such an operand and the value it folds
128
- * onto encode to one word, so the operands as written are the only place the two stay distinct. An operand spans no
129
- * more than one word, which leaves two ways across: a negative's alias clears every target narrower than the whole
130
- * word, and a positive reaches a signed range only at that type's own minimum word.
55
+ * onto encode to one word. The operands as written are the only place the two stay distinct. An operand spans no
56
+ * more than one word, which leaves two ways across. A negative's alias clears every target narrower than the whole
57
+ * word. A positive reaches a signed range only at that type's own minimum word.
131
58
  */
132
59
  function checkOperandDomain(constraint: Constraint, typeCode: number): void {
133
- const bounds = targetBounds(typeCode);
134
60
  const extremes = readOperandExtremes(constraint);
135
- if (bounds === null || extremes === undefined) return;
61
+ if (extremes === undefined) return;
136
62
 
137
63
  // A negative operand encodes as its unsigned alias's word, which only a whole-word target can hold too.
138
64
  if (extremes.leastNegative < 0n && typeCode === TypeCode.UINT_MAX) {
@@ -142,16 +68,68 @@ function checkOperandDomain(constraint: Constraint, typeCode: number): void {
142
68
  if (!isSigned(typeCode)) return;
143
69
  // An operand at or above the signed minimum's own word denotes that negative, and every such operand already exceeds
144
70
  // the signed maximum.
145
- if (extremes.greatest >= WORD_VALUES + bounds.min) {
71
+ const signedMinimumWord = WORD_VALUES - (1n << BigInt(8 * operandWidth(typeCode)! - 1));
72
+ if (extremes.greatest >= signedMinimumWord) {
146
73
  reject(extremes.greatest, extremes.greatest - WORD_VALUES);
147
74
  }
148
75
  }
149
76
 
77
+ /** Reject an operand word its operand type cannot represent. */
78
+ function outOfRange(word: bigint): never {
79
+ throw new CallciumError(
80
+ "OUT_OF_PHYSICAL_BOUNDS",
81
+ `Operand ${bigintToHex(word)} is outside the range of its operand type`,
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Require every comparison of `constraint` to apply to a target of `typeCode` and every constant to be a value its
87
+ * operand type can represent. Return the operators with each mask as its operand type's word.
88
+ */
89
+ function checkOperands(constraint: Constraint, typeCode: number): Hex[] {
90
+ checkOperandDomain(constraint, typeCode);
91
+
92
+ return constraint.operators.map((operatorHex) => {
93
+ const operator = hexToBytes(operatorHex);
94
+ // Only an opcode followed by whole words carries constants to check.
95
+ if (operator.length <= 1 || (operator.length - 1) % 32 !== 0) return operatorHex;
96
+ const opCode = operator[0]!;
97
+ const comparison = comparisonOf(opCode);
98
+ const operandType = operandTypeOf(comparison, typeCode);
99
+ if (operandType === undefined) {
100
+ throw new CallciumError("OPERATOR_TARGET_MISMATCH", "Comparison does not apply to the target type");
101
+ }
102
+ if (sourceOf(opCode) !== Source.CONSTANT) return operatorHex;
103
+
104
+ // The members of a set fill at most what the data length field holds.
105
+ const width = operandWidth(operandType)!;
106
+ const limit = Math.floor((2 ** (8 * PF.RULE_DATALENGTH_SIZE) - 1) / width);
107
+ if ((operator.length - 1) / 32 > limit) {
108
+ throw new CallciumError("SET_TOO_LARGE", `Set must contain at most ${limit} values`);
109
+ }
110
+
111
+ const isMask = comparison >= Op.BITMASK_ALL && comparison <= Op.BITMASK_NONE;
112
+ const checked = operator.slice();
113
+ for (let offset = 1; offset < operator.length; offset += 32) {
114
+ const word = toBigInt(operator, offset);
115
+ if (!isMask) {
116
+ if (canonicalize(word, operandType) !== word) outOfRange(word);
117
+ continue;
118
+ }
119
+ // A mask is a bit pattern of the type's width, or -1 for every bit.
120
+ if (word !== WORD_VALUES - 1n && word >> BigInt(8 * width) !== 0n) outOfRange(word);
121
+ const pattern = hexToBytes(bigintToHex(word)).subarray(32 - width);
122
+ checked.set(hexToBytes(bigintToHex(loadOperand(pattern, 0, operandType))), offset);
123
+ }
124
+ return bytesToHex(checked);
125
+ });
126
+ }
127
+
150
128
  ///////////////////////////////////////////////////////////////////////////
151
129
  // PolicyBuilder
152
130
  ///////////////////////////////////////////////////////////////////////////
153
131
 
154
- /** Fluent builder for constructing Callcium policies. */
132
+ /** Fluent API for drafting policies from constraints. */
155
133
  export class PolicyBuilder {
156
134
  private draft: PolicyDraft;
157
135
 
@@ -161,142 +139,160 @@ export class PolicyBuilder {
161
139
 
162
140
  /**
163
141
  * Create a builder from a function signature.
164
- * @param signature - ABI function signature, e.g. `"transfer(address,uint256)"`.
142
+ * @param signature - The function signature (for example "transfer(address,uint256)").
143
+ * @returns A new builder.
144
+ * @throws {CallciumError} If the signature is malformed.
165
145
  */
166
146
  static create(signature: string): PolicyBuilder {
167
147
  const parsed = SignatureParser.parse(signature);
168
148
  const descriptor = DescriptorCoder.fromTypes(parsed.types);
169
- return new PolicyBuilder({
170
- isSelectorless: false,
171
- selector: parsed.selector,
172
- descriptor,
173
- groups: [[]],
174
- groupPathKeys: new Set(),
175
- });
149
+ return new PolicyBuilder({ isSelectorless: false, selector: parsed.selector, descriptor, groups: [[]] });
176
150
  }
177
151
 
178
152
  /**
179
- * Create a selectorless builder from a raw type string.
180
- * @param typesCsv - Comma-separated ABI type strings, e.g. `"address,uint256"`.
153
+ * Create a selectorless builder from a comma-separated type list.
154
+ * @param typesCsv - The comma-separated ABI types (for example "uint256,address").
155
+ * @returns A new builder.
156
+ * @throws {CallciumError} If a type is malformed.
181
157
  */
182
158
  static createRaw(typesCsv: string): PolicyBuilder {
183
159
  const descriptor = DescriptorCoder.fromTypes(typesCsv);
184
- return new PolicyBuilder({
185
- isSelectorless: true,
186
- selector: "0x00000000",
187
- descriptor,
188
- groups: [[]],
189
- groupPathKeys: new Set(),
190
- });
160
+ return new PolicyBuilder({ isSelectorless: true, selector: "0x00000000", descriptor, groups: [[]] });
191
161
  }
192
162
 
193
163
  /**
194
- * Add a constraint to the current group.
195
- * @param constraint - A `Constraint` object or a `ConstraintBuilder` instance.
196
- * @throws {CallciumError} With code `OUT_OF_PHYSICAL_BOUNDS` when a numeric operand lies outside the target type's
197
- * range and encodes as a value inside it. Operands are read as written, so a `Constraint` carrying encoded operator
198
- * bytes is left to {@link validate}.
164
+ * Add a constraint to the active group. A path through `Quantifier.ALL` or `Quantifier.ANY` adds its operators to the
165
+ * body of the group's loop over that array with that quantifier.
166
+ * @param constraint - The constraint to add.
167
+ * @returns This builder.
168
+ * @throws {CallciumError} If the constraint has no operator, or its path does not reach a target in the signature.
169
+ * If a comparison does not apply to its target, or an operand is a value its target's type cannot represent.
199
170
  */
200
171
  add(constraint: Constraint): this {
201
- // A builder compiles no hint; one arrives only on a constraint that came already encoded.
202
- const added: Constraint = {
203
- scope: constraint.scope,
204
- path: constraint.path,
205
- operators: [...constraint.operators],
206
- ...("hint" in constraint && constraint.hint !== undefined && { hint: constraint.hint }),
207
- };
208
-
209
- if (added.operators.length === 0) {
172
+ if (constraint.operators.length === 0) {
210
173
  throw new CallciumError("NO_CONSTRAINT_OPERATORS", "Constraint must have at least one operator");
211
174
  }
212
175
 
213
176
  // Path shape is established before the scope decides how to navigate it.
214
- const steps = parsePathSteps(added.path);
177
+ const steps = parsePathSteps(constraint.path);
215
178
  if (steps.length === 0) {
216
179
  throw new CallciumError("EMPTY_PATH", "Path must have at least one step");
217
180
  }
218
181
 
219
- let targetTypeCode: number;
220
- if (added.scope === Scope.CONTEXT) {
221
- targetTypeCode = validateContextPath(steps);
222
- } else if (added.scope === Scope.CALLDATA) {
223
- targetTypeCode = validateCalldataPath(steps, this.draft.descriptor);
182
+ // A caller from JavaScript can pass any scope, so the value is read as a number.
183
+ const scope: number = constraint.scope;
184
+ if (scope === Scope.CONTEXT) {
185
+ this.addContextTests(constraint, steps);
186
+ } else if (scope === Scope.CALLDATA) {
187
+ this.addCalldataTests(constraint, steps);
224
188
  } else {
225
- throw new CallciumError("INVALID_SCOPE", `Unknown scope value ${added.scope}`);
189
+ throw new CallciumError("UNKNOWN_SCOPE", `Unknown scope value ${scope}`);
226
190
  }
227
-
228
- checkOperandDomain(constraint, targetTypeCode);
229
-
230
- const key = `${added.scope}:${added.path.toLowerCase()}`;
231
- if (this.draft.groupPathKeys.has(key)) {
232
- throw new CallciumError("DUPLICATE_PATH_IN_GROUP", `Duplicate path ${added.path} in the same group`);
233
- }
234
-
235
- this.draft.groupPathKeys.add(key);
236
- this.draft.groups[this.draft.groups.length - 1]!.push(added);
237
191
  return this;
238
192
  }
239
193
 
240
- /** Start a new constraint group (OR branch). */
194
+ /**
195
+ * Start a new constraint group (OR semantics between groups).
196
+ * @returns This builder.
197
+ * @throws {CallciumError} If the current group is empty.
198
+ */
241
199
  or(): this {
242
200
  const lastGroup = this.draft.groups[this.draft.groups.length - 1]!;
243
201
  if (lastGroup.length === 0) {
244
202
  throw new CallciumError("EMPTY_GROUP", "Cannot start a new group when the current group is empty");
245
203
  }
246
204
  this.draft.groups.push([]);
247
- this.draft.groupPathKeys.clear();
248
205
  return this;
249
206
  }
250
207
 
251
208
  /**
252
- * Build the policy into an encoded binary blob with strict validation. Throws on any issue, regardless of severity.
253
- * Use {@link validate} to inspect issues, or {@link buildUnsafe} to encode without validation.
254
- * @returns The policy as a 0x-prefixed hex string.
255
- * @throws {CallciumError} If any group is empty.
256
- * @throws {ValidationError} If validation finds any issue.
257
- * @throws {CallciumError} With code `RULE_SIZE_OVERFLOW` when a rule's encoded bytes exceed the rule size field.
209
+ * Build the policy blob and validate it.
210
+ * @returns The encoded policy.
211
+ * @throws {ValidationError} If validation reports any issue.
212
+ * @throws {CallciumError} If the encoder refuses the policy, or the blob fails storage validation.
258
213
  */
259
214
  build(): Hex {
260
- this.checkGroups();
261
- const policyData = this.toPolicyData();
262
- const issues = PolicyValidator.validate(policyData);
263
- if (issues.length > 0) {
264
- throw new ValidationError(issues);
265
- }
266
- return PolicyCoder.encode(policyData);
215
+ const blob = this.buildUnsafe();
216
+ const issues = PolicyValidator.validate(blob);
217
+ if (issues.length > 0) throw new ValidationError(issues);
218
+ return blob;
267
219
  }
268
220
 
269
221
  /**
270
- * Build the policy into an encoded binary blob without validation. The resulting policy may be invalid. Prefer {@link
271
- * build}; use this only to knowingly bypass a reported issue.
272
- * @returns The policy as a 0x-prefixed hex string.
273
- * @throws {CallciumError} If any group is empty.
222
+ * Validate the policy the builder contains.
223
+ * @returns Every issue validation reports on the encoded policy.
224
+ * @throws {CallciumError} If the encoder refuses the policy, or the blob fails storage validation.
274
225
  */
275
- buildUnsafe(): Hex {
276
- this.checkGroups();
277
- return PolicyCoder.encode(this.toPolicyData());
226
+ validate(): Issue[] {
227
+ return PolicyValidator.validate(this.buildUnsafe());
278
228
  }
279
229
 
280
230
  /**
281
- * Validate the policy without encoding.
282
- * @returns All validation issues found.
231
+ * Build the policy blob without validation. The resulting policy can be invalid.
232
+ * @returns The encoded policy.
233
+ * @throws {CallciumError} If the encoder refuses the policy.
283
234
  */
284
- validate(): Issue[] {
285
- this.checkGroups();
286
- const policyData = this.toPolicyData();
287
- return PolicyValidator.validate(policyData);
235
+ buildUnsafe(): Hex {
236
+ return PolicyCoder.encode(this.toPolicyData());
288
237
  }
289
238
 
290
- /** Throw if any group is empty. */
291
- private checkGroups(): void {
292
- for (let i = 0; i < this.draft.groups.length; i++) {
293
- if (this.draft.groups[i]!.length === 0) {
294
- throw new CallciumError("EMPTY_GROUP", `Group ${i} is empty`);
239
+ /** Append a context test per operator of `constraint`, whose path names a context property. */
240
+ private addContextTests(constraint: Constraint, steps: number[]): void {
241
+ if (steps.length !== 1) {
242
+ throw new CallciumError("INVALID_CONTEXT_PATH", "Context-scope path must be exactly one step");
243
+ }
244
+ const { typeCode } = lookupContextProperty(steps[0]!);
245
+ for (const operator of checkOperands(constraint, typeCode)) {
246
+ this.currentGroup().push({ scope: Scope.CONTEXT, path: constraint.path, operator });
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Append a calldata test per operator of `constraint`. If the path of `constraint` passes a quantifier step, append a
252
+ * body test per operator to the group's loop over that array instead.
253
+ */
254
+ private addCalldataTests(constraint: Constraint, steps: number[]): void {
255
+ const desc = this.draft.descriptor;
256
+ const quantifierIndex = findQuantifierIndex(steps);
257
+
258
+ if (quantifierIndex === undefined) {
259
+ const target = Hint.requireCompiled(Hint.compileTest(desc, steps), steps, 0);
260
+ for (const operator of checkOperands(constraint, desc[target.node]!)) {
261
+ this.currentGroup().push({ scope: Scope.CALLDATA, path: constraint.path, operator });
295
262
  }
263
+ return;
264
+ }
265
+
266
+ // The quantifier splits the path into the array's path and the body test's path from the element.
267
+ const arraySteps = steps.slice(0, quantifierIndex);
268
+ const bodySteps = steps.slice(quantifierIndex + 1);
269
+ const loop = Hint.requireCompiled(Hint.compileLoop(desc, arraySteps), steps, 0);
270
+ const target = Hint.requireCompiled(Hint.compileTest(desc, bodySteps, loop.node), steps, quantifierIndex + 1);
271
+
272
+ const bodyPath = encodePath(bodySteps);
273
+ const tests: BodyRule[] = checkOperands(constraint, desc[target.node]!).map((operator) => ({
274
+ scope: Scope.CALLDATA,
275
+ path: bodyPath,
276
+ operator,
277
+ }));
278
+ const quantifier = steps[quantifierIndex] === Quantifier.ALL ? LoopQuantifier.ALL : LoopQuantifier.ANY;
279
+ const arrayPath = encodePath(arraySteps);
280
+ const existing = this.currentGroup().find(
281
+ (rule) => rule.scope === Scope.LOOP && rule.quantifier === quantifier && rule.path === arrayPath,
282
+ );
283
+ if (existing !== undefined && existing.scope === Scope.LOOP) {
284
+ existing.body.push(...tests);
285
+ } else {
286
+ this.currentGroup().push({ scope: Scope.LOOP, quantifier, path: arrayPath, body: tests });
296
287
  }
297
288
  }
298
289
 
299
- /** Convert the draft to a PolicyData structure. */
290
+ /** The active group. */
291
+ private currentGroup(): Rule[] {
292
+ return this.draft.groups[this.draft.groups.length - 1]!;
293
+ }
294
+
295
+ /** The draft as the encoder takes it. */
300
296
  private toPolicyData(): PolicyData {
301
297
  return {
302
298
  isSelectorless: this.draft.isSelectorless,
@@ -306,3 +302,27 @@ export class PolicyBuilder {
306
302
  };
307
303
  }
308
304
  }
305
+
306
+ /**
307
+ * Return the index of the path's quantifier step, or undefined when the path has none.
308
+ * The first step is a parameter index.
309
+ * @throws {CallciumError} On a second quantifier step and on any other step in the reserved marker range.
310
+ */
311
+ function findQuantifierIndex(steps: readonly number[]): number | undefined {
312
+ let quantifierIndex: number | undefined;
313
+ for (let stepIndex = 1; stepIndex < steps.length; stepIndex++) {
314
+ const step = steps[stepIndex]!;
315
+ if (step < PF.PATH_MARKER_BASE) continue;
316
+ if (step !== Quantifier.ALL && step !== Quantifier.ANY) {
317
+ throw new CallciumError(
318
+ "UNKNOWN_PATH_STEP",
319
+ `Path step ${formatCode(step, PF.PATH_STEP_SIZE)} is in the reserved marker range`,
320
+ );
321
+ }
322
+ if (quantifierIndex !== undefined) {
323
+ throw new CallciumError("INVALID_NESTED_LOOP", "Loop body holds a loop");
324
+ }
325
+ quantifierIndex = stepIndex;
326
+ }
327
+ return quantifierIndex;
328
+ }