@crediolabs/policy-synth 0.5.3 → 0.5.6

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.
@@ -107,12 +107,33 @@ function leftArgLabel(leaf) {
107
107
  }
108
108
  /** Render ONE constraint sentence for ONE interpreter predicate node. The
109
109
  * shape of the output is pinned by Task 7b so the test suite can assert
110
- * byte-for-byte equality. Returns `null` when the node is a structural
111
- * boolean (`and`) - that is not a constraint leaf. */
110
+ * byte-for-byte equality. Returns `null` only when a node's shape cannot be
111
+ * rendered at all. */
112
112
  function renderConstraint(node) {
113
113
  switch (node.op) {
114
- case 'and':
115
- return null;
114
+ case 'and': {
115
+ // Reached ONLY when an `and` sits BENEATH an `or`: `walkPredicate`
116
+ // descends into a top-level `and` and never calls this on one.
117
+ //
118
+ // This used to return null, on the reasoning that `and` is structural
119
+ // rather than a constraint leaf. That is true for the walk path and
120
+ // false for this one, and the combination silently dropped real
121
+ // policies: `or(and, and)` is the natural shape of a policy with
122
+ // alternative permitted forms - one conjunction of pins per branch -
123
+ // so the `or` case below always saw null children and withheld the
124
+ // entire disjunction. `describePredicate` then returned an EMPTY list
125
+ // for a restrictive policy, which any caller renders as "no
126
+ // constraints".
127
+ //
128
+ // That inverts the very guard it was protecting: the `or` case
129
+ // withholds to avoid reading STRICTER than reality, but withholding
130
+ // the only line reads as UNRESTRICTED, which is far worse. Compose
131
+ // instead, and keep the withhold for genuinely unrenderable children.
132
+ const parts = node.children.map(renderConstraint);
133
+ if (parts.some((p) => p === null))
134
+ return null;
135
+ return parts.join(' and ');
136
+ }
116
137
  case 'or': {
117
138
  // One line for the whole disjunction. If any branch is a shape the
118
139
  // card cannot render, the entire line is withheld rather than shown
package/dist/run/index.js CHANGED
@@ -323,6 +323,19 @@ export function runDeclarePolicy(raw) {
323
323
  ...(d.contract !== undefined ? { contract: d.contract } : {}),
324
324
  ...(d.maxAmount !== undefined ? { maxAmount: d.maxAmount } : {}),
325
325
  ...(d.amountArgIndex !== undefined ? { amountArgIndex: d.amountArgIndex } : {}),
326
+ ...(d.amountPath !== undefined
327
+ ? {
328
+ // Rebuilt like the rest: `exactOptionalPropertyTypes` treats the
329
+ // schema's `number | undefined` and the declaration's absent-or-T
330
+ // as different types, and the nested optional needs the same
331
+ // treatment as the top-level ones.
332
+ amountPath: {
333
+ argIndex: d.amountPath.argIndex,
334
+ field: d.amountPath.field,
335
+ ...(d.amountPath.elements !== undefined ? { elements: d.amountPath.elements } : {}),
336
+ },
337
+ }
338
+ : {}),
326
339
  ...(d.recipients !== undefined ? { recipients: d.recipients } : {}),
327
340
  ...(d.recipientArgIndex !== undefined ? { recipientArgIndex: d.recipientArgIndex } : {}),
328
341
  ...(d.allowZeroCap !== undefined ? { allowZeroCap: d.allowZeroCap } : {}),
@@ -1657,6 +1657,26 @@ export declare const DeclarePolicyInputSchema: z.ZodObject<{
1657
1657
  * Number.MAX_SAFE_INTEGER, so a number here would silently round. */
1658
1658
  maxAmount: z.ZodOptional<z.ZodString>;
1659
1659
  amountArgIndex: z.ZodOptional<z.ZodNumber>;
1660
+ /** Nested amount location, for a call whose amount is a field inside a
1661
+ * struct argument rather than an argument of its own. Every coordinate is
1662
+ * REQUIRED - `declarePredicate` defaults none of them, because a nested
1663
+ * path guessed wrong caps something the caller never named. */
1664
+ amountPath: z.ZodOptional<z.ZodObject<{
1665
+ argIndex: z.ZodNumber;
1666
+ field: z.ZodString;
1667
+ /** How many entries the vec may carry. EVERY one is capped; see
1668
+ * `declarePredicate`, which refuses a single-element bound because it
1669
+ * would leave the rest of the vec unconstrained. */
1670
+ elements: z.ZodOptional<z.ZodNumber>;
1671
+ }, "strip", z.ZodTypeAny, {
1672
+ field: string;
1673
+ argIndex: number;
1674
+ elements?: number | undefined;
1675
+ }, {
1676
+ field: string;
1677
+ argIndex: number;
1678
+ elements?: number | undefined;
1679
+ }>>;
1660
1680
  recipients: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
1661
1681
  recipientArgIndex: z.ZodOptional<z.ZodNumber>;
1662
1682
  allowZeroCap: z.ZodOptional<z.ZodBoolean>;
@@ -1684,6 +1704,11 @@ export declare const DeclarePolicyInputSchema: z.ZodObject<{
1684
1704
  contract?: string | undefined;
1685
1705
  maxAmount?: string | undefined;
1686
1706
  amountArgIndex?: number | undefined;
1707
+ amountPath?: {
1708
+ field: string;
1709
+ argIndex: number;
1710
+ elements?: number | undefined;
1711
+ } | undefined;
1687
1712
  recipients?: string[] | undefined;
1688
1713
  recipientArgIndex?: number | undefined;
1689
1714
  allowZeroCap?: boolean | undefined;
@@ -1698,6 +1723,11 @@ export declare const DeclarePolicyInputSchema: z.ZodObject<{
1698
1723
  contract?: string | undefined;
1699
1724
  maxAmount?: string | undefined;
1700
1725
  amountArgIndex?: number | undefined;
1726
+ amountPath?: {
1727
+ field: string;
1728
+ argIndex: number;
1729
+ elements?: number | undefined;
1730
+ } | undefined;
1701
1731
  recipients?: string[] | undefined;
1702
1732
  recipientArgIndex?: number | undefined;
1703
1733
  allowZeroCap?: boolean | undefined;
@@ -428,6 +428,20 @@ export const DeclarePolicyInputSchema = z
428
428
  .regex(/^[0-9]+$/, 'maxAmount must be an unsigned integer in the smallest unit')
429
429
  .optional(),
430
430
  amountArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
431
+ /** Nested amount location, for a call whose amount is a field inside a
432
+ * struct argument rather than an argument of its own. Every coordinate is
433
+ * REQUIRED - `declarePredicate` defaults none of them, because a nested
434
+ * path guessed wrong caps something the caller never named. */
435
+ amountPath: z
436
+ .object({
437
+ argIndex: z.number().int().nonnegative().max(U32_MAX),
438
+ field: z.string().min(1),
439
+ /** How many entries the vec may carry. EVERY one is capped; see
440
+ * `declarePredicate`, which refuses a single-element bound because it
441
+ * would leave the rest of the vec unconstrained. */
442
+ elements: z.number().int().min(1).max(U32_MAX).optional(),
443
+ })
444
+ .optional(),
431
445
  recipients: z.array(z.string()).min(1, 'recipients must not be empty').optional(),
432
446
  recipientArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
433
447
  allowZeroCap: z.boolean().optional(),
@@ -10,6 +10,31 @@ export interface PolicyDeclaration {
10
10
  maxAmount?: string;
11
11
  /** Which argument carries the amount. Defaults to the SEP-41 position. */
12
12
  amountArgIndex?: number;
13
+ /** Where the amount sits when it is NESTED inside a struct argument rather
14
+ * than being an argument of its own - Blend's `submit` is the motivating
15
+ * case: its amount is `requests[i].amount`, so no positional index names it
16
+ * and `amountArgIndex` has nothing to point at.
17
+ *
18
+ * Mutually exclusive with `amountArgIndex`.
19
+ *
20
+ * THIS NAMES A COUNT, NOT AN INDEX, and that is deliberate. `call_arg_field`
21
+ * binds ONE element of the vec and says nothing about the others, so a
22
+ * declaration that pointed at a single element would leave every other
23
+ * element unbounded - a caller puts the real spend in one of those and the
24
+ * cap is decorative. There is no safe way to bound "the amount" in a vec
25
+ * without bounding EVERY entry, so `elements` states how many entries the
26
+ * call may carry and all of them are capped.
27
+ *
28
+ * THE CAP IS PER ENTRY. With `elements: 3` and `maxAmount` 100, one call may
29
+ * carry three requests of 100 and move 300 in total. Declare the per-entry
30
+ * figure you mean, not the total you have in mind. */
31
+ amountPath?: {
32
+ argIndex: number;
33
+ field: string;
34
+ /** How many entries the vec may carry. Defaults to 1 - a single-request
35
+ * call, which is the shape a per-call cap is usually written for. */
36
+ elements?: number;
37
+ };
13
38
  /** Recipient allowlist. */
14
39
  recipients?: string[];
15
40
  /** Which argument carries the recipient. Defaults to the SEP-41 position. */
@@ -38,6 +63,6 @@ export interface DeclaredPredicate {
38
63
  * was defaulted rather than supplied. */
39
64
  warnings: string[];
40
65
  }
41
- /** Lower a declared constraint to a grammar-3 predicate. Pure and total:
66
+ /** Lower a declared constraint to a grammar-4 predicate. Pure and total:
42
67
  * the same declaration always produces the same predicate. */
43
68
  export declare function declarePredicate(d: PolicyDeclaration): DeclaredPredicate;
@@ -19,6 +19,9 @@
19
19
  // fn -> eq(call_fn, literal_symbol)
20
20
  // contract -> eq(call_contract, literal_address)
21
21
  // maxAmount -> lte(call_arg(i), literal_i128)
22
+ // maxAmount + amountPath
23
+ // -> lte(call_arg_field(i, e, field), literal_i128) for EVERY
24
+ // e in 0..elements-1, AND eq(call_arg_len(i), literal_u32(elements))
22
25
  // recipients -> in(call_arg(j), [literal_address, ...])
23
26
  // minOutputRatio-> gte(call_arg(out), call_arg_scaled(in, num, den))
24
27
  import { isStellarAddress } from "./address.js";
@@ -29,7 +32,13 @@ import { isStellarAddress } from "./address.js";
29
32
  * user did not mean and fails silently. */
30
33
  const SEP41_RECIPIENT_ARG = 1;
31
34
  const SEP41_AMOUNT_ARG = 2;
32
- /** Lower a declared constraint to a grammar-3 predicate. Pure and total:
35
+ /** `MAX_LEAVES` in the on-chain interpreter (policy-interpreter/src/dsl.rs). */
36
+ const INTERPRETER_MAX_LEAVES = 200;
37
+ /** Entries a nested amount bound may cover. Each costs one comparison (two
38
+ * leaves), so this sits well inside the interpreter's budget while being far
39
+ * more than any real call carries - a Blend `submit` is a handful. */
40
+ const MAX_BOUNDED_ELEMENTS = 16;
41
+ /** Lower a declared constraint to a grammar-4 predicate. Pure and total:
33
42
  * the same declaration always produces the same predicate. */
34
43
  export function declarePredicate(d) {
35
44
  if (!d.fn || d.fn.trim() === '') {
@@ -68,6 +77,12 @@ export function declarePredicate(d) {
68
77
  haystack: d.recipients.map((value) => ({ kind: 'literal_address', value })),
69
78
  });
70
79
  }
80
+ if (d.amountPath !== undefined && d.amountArgIndex !== undefined) {
81
+ throw declareError('SYNTHESIS_ERROR', 'amountArgIndex and amountPath both name where the amount lives, and they disagree by construction: one is a positional argument, the other a field inside one. Pass exactly one.');
82
+ }
83
+ if (d.amountPath !== undefined && d.maxAmount === undefined) {
84
+ throw declareError('SYNTHESIS_ERROR', 'amountPath says WHERE the amount is but not what bounds it. Pass maxAmount, or omit amountPath.');
85
+ }
71
86
  if (d.maxAmount !== undefined) {
72
87
  if (!/^[0-9]+$/.test(d.maxAmount)) {
73
88
  throw declareError('SYNTHESIS_ERROR', `maxAmount must be an unsigned integer in the token's smallest unit, got "${d.maxAmount}" (25 XLM = "250000000")`);
@@ -75,15 +90,56 @@ export function declarePredicate(d) {
75
90
  if (d.maxAmount === '0' && d.allowZeroCap !== true) {
76
91
  throw declareError('SYNTHESIS_ERROR', 'maxAmount "0" denies every call: no amount satisfies the bound. Set allowZeroCap to declare that deliberately.');
77
92
  }
78
- const idx = d.amountArgIndex ?? SEP41_AMOUNT_ARG;
79
- if (d.amountArgIndex === undefined) {
80
- warnings.push(`amount cap bound to call_arg(${idx}), the SEP-41 \`transfer\` position. If \`${d.fn}\` carries the amount elsewhere this caps the wrong argument - pass amountArgIndex.`);
93
+ if (d.amountPath !== undefined) {
94
+ const { argIndex, field } = d.amountPath;
95
+ const elements = d.amountPath.elements ?? 1;
96
+ if (!Number.isInteger(argIndex) || argIndex < 0) {
97
+ throw declareError('SYNTHESIS_ERROR', `amountPath.argIndex must be a non-negative integer, got ${String(argIndex)}`);
98
+ }
99
+ if (!Number.isInteger(elements) || elements < 1) {
100
+ throw declareError('SYNTHESIS_ERROR', `amountPath.elements is how many entries the vec may carry, so it must be at least 1, got ${String(elements)}`);
101
+ }
102
+ if (elements > MAX_BOUNDED_ELEMENTS) {
103
+ throw declareError('SYNTHESIS_ERROR', `amountPath.elements is ${elements}; each entry costs a comparison and the interpreter caps a predicate at ${INTERPRETER_MAX_LEAVES} leaves. ${MAX_BOUNDED_ELEMENTS} is already far past any real call.`);
104
+ }
105
+ if (typeof field !== 'string' || field.trim() === '') {
106
+ throw declareError('SYNTHESIS_ERROR', 'amountPath.field must name the map key holding the amount');
107
+ }
108
+ // EVERY entry, not one. Bounding a single element leaves the rest of the
109
+ // vec unconstrained and the caller simply puts the spend in an unbounded
110
+ // one - the cap then reads as enforced while permitting any amount.
111
+ for (let element = 0; element < elements; element += 1) {
112
+ children.push({
113
+ op: 'lte',
114
+ left: { kind: 'call_arg_field', index: argIndex, element, field },
115
+ right: { kind: 'literal_i128', value: d.maxAmount },
116
+ });
117
+ }
118
+ // And the count, or the caller appends an entry past the ones bounded
119
+ // above and spends through it. The two leaves are only safe together:
120
+ // the bounds cover entries 0..elements-1, this makes those the only
121
+ // entries there are. The recording front-end pairs them for the same
122
+ // reason.
123
+ children.push({
124
+ op: 'eq',
125
+ left: { kind: 'call_arg_len', index: argIndex },
126
+ right: { kind: 'literal_u32', value: elements },
127
+ });
128
+ warnings.push(elements === 1
129
+ ? `amount cap bound to call_arg_field(${argIndex}, 0, "${field}"), and argument ${argIndex} is pinned to exactly 1 entry. A call carrying more than one entry is DENIED - if this method is normally called with several, pass amountPath.elements.`
130
+ : `amount cap bound to every entry of argument ${argIndex} ("${field}"), which is pinned to exactly ${elements} entries. The cap is PER ENTRY: a single call may carry ${elements} of them and move up to ${elements} x ${d.maxAmount} in total. A call carrying a different number of entries is DENIED.`);
131
+ }
132
+ else {
133
+ const idx = d.amountArgIndex ?? SEP41_AMOUNT_ARG;
134
+ if (d.amountArgIndex === undefined) {
135
+ warnings.push(`amount cap bound to call_arg(${idx}), the SEP-41 \`transfer\` position. If \`${d.fn}\` carries the amount elsewhere this caps the wrong argument - pass amountArgIndex.`);
136
+ }
137
+ children.push({
138
+ op: 'lte',
139
+ left: { kind: 'call_arg', index: idx },
140
+ right: { kind: 'literal_i128', value: d.maxAmount },
141
+ });
81
142
  }
82
- children.push({
83
- op: 'lte',
84
- left: { kind: 'call_arg', index: idx },
85
- right: { kind: 'literal_i128', value: d.maxAmount },
86
- });
87
143
  }
88
144
  if (d.minOutputRatio !== undefined) {
89
145
  const { num, den, inputArgIndex, outputArgIndex } = d.minOutputRatio;
@@ -111,12 +111,33 @@ function leftArgLabel(leaf) {
111
111
  }
112
112
  /** Render ONE constraint sentence for ONE interpreter predicate node. The
113
113
  * shape of the output is pinned by Task 7b so the test suite can assert
114
- * byte-for-byte equality. Returns `null` when the node is a structural
115
- * boolean (`and`) - that is not a constraint leaf. */
114
+ * byte-for-byte equality. Returns `null` only when a node's shape cannot be
115
+ * rendered at all. */
116
116
  function renderConstraint(node) {
117
117
  switch (node.op) {
118
- case 'and':
119
- return null;
118
+ case 'and': {
119
+ // Reached ONLY when an `and` sits BENEATH an `or`: `walkPredicate`
120
+ // descends into a top-level `and` and never calls this on one.
121
+ //
122
+ // This used to return null, on the reasoning that `and` is structural
123
+ // rather than a constraint leaf. That is true for the walk path and
124
+ // false for this one, and the combination silently dropped real
125
+ // policies: `or(and, and)` is the natural shape of a policy with
126
+ // alternative permitted forms - one conjunction of pins per branch -
127
+ // so the `or` case below always saw null children and withheld the
128
+ // entire disjunction. `describePredicate` then returned an EMPTY list
129
+ // for a restrictive policy, which any caller renders as "no
130
+ // constraints".
131
+ //
132
+ // That inverts the very guard it was protecting: the `or` case
133
+ // withholds to avoid reading STRICTER than reality, but withholding
134
+ // the only line reads as UNRESTRICTED, which is far worse. Compose
135
+ // instead, and keep the withhold for genuinely unrenderable children.
136
+ const parts = node.children.map(renderConstraint);
137
+ if (parts.some((p) => p === null))
138
+ return null;
139
+ return parts.join(' and ');
140
+ }
120
141
  case 'or': {
121
142
  // One line for the whole disjunction. If any branch is a shape the
122
143
  // card cannot render, the entire line is withheld rather than shown
@@ -358,6 +358,19 @@ function runDeclarePolicy(raw) {
358
358
  ...(d.contract !== undefined ? { contract: d.contract } : {}),
359
359
  ...(d.maxAmount !== undefined ? { maxAmount: d.maxAmount } : {}),
360
360
  ...(d.amountArgIndex !== undefined ? { amountArgIndex: d.amountArgIndex } : {}),
361
+ ...(d.amountPath !== undefined
362
+ ? {
363
+ // Rebuilt like the rest: `exactOptionalPropertyTypes` treats the
364
+ // schema's `number | undefined` and the declaration's absent-or-T
365
+ // as different types, and the nested optional needs the same
366
+ // treatment as the top-level ones.
367
+ amountPath: {
368
+ argIndex: d.amountPath.argIndex,
369
+ field: d.amountPath.field,
370
+ ...(d.amountPath.elements !== undefined ? { elements: d.amountPath.elements } : {}),
371
+ },
372
+ }
373
+ : {}),
361
374
  ...(d.recipients !== undefined ? { recipients: d.recipients } : {}),
362
375
  ...(d.recipientArgIndex !== undefined ? { recipientArgIndex: d.recipientArgIndex } : {}),
363
376
  ...(d.allowZeroCap !== undefined ? { allowZeroCap: d.allowZeroCap } : {}),
@@ -1657,6 +1657,26 @@ export declare const DeclarePolicyInputSchema: z.ZodObject<{
1657
1657
  * Number.MAX_SAFE_INTEGER, so a number here would silently round. */
1658
1658
  maxAmount: z.ZodOptional<z.ZodString>;
1659
1659
  amountArgIndex: z.ZodOptional<z.ZodNumber>;
1660
+ /** Nested amount location, for a call whose amount is a field inside a
1661
+ * struct argument rather than an argument of its own. Every coordinate is
1662
+ * REQUIRED - `declarePredicate` defaults none of them, because a nested
1663
+ * path guessed wrong caps something the caller never named. */
1664
+ amountPath: z.ZodOptional<z.ZodObject<{
1665
+ argIndex: z.ZodNumber;
1666
+ field: z.ZodString;
1667
+ /** How many entries the vec may carry. EVERY one is capped; see
1668
+ * `declarePredicate`, which refuses a single-element bound because it
1669
+ * would leave the rest of the vec unconstrained. */
1670
+ elements: z.ZodOptional<z.ZodNumber>;
1671
+ }, "strip", z.ZodTypeAny, {
1672
+ field: string;
1673
+ argIndex: number;
1674
+ elements?: number | undefined;
1675
+ }, {
1676
+ field: string;
1677
+ argIndex: number;
1678
+ elements?: number | undefined;
1679
+ }>>;
1660
1680
  recipients: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
1661
1681
  recipientArgIndex: z.ZodOptional<z.ZodNumber>;
1662
1682
  allowZeroCap: z.ZodOptional<z.ZodBoolean>;
@@ -1684,6 +1704,11 @@ export declare const DeclarePolicyInputSchema: z.ZodObject<{
1684
1704
  contract?: string | undefined;
1685
1705
  maxAmount?: string | undefined;
1686
1706
  amountArgIndex?: number | undefined;
1707
+ amountPath?: {
1708
+ field: string;
1709
+ argIndex: number;
1710
+ elements?: number | undefined;
1711
+ } | undefined;
1687
1712
  recipients?: string[] | undefined;
1688
1713
  recipientArgIndex?: number | undefined;
1689
1714
  allowZeroCap?: boolean | undefined;
@@ -1698,6 +1723,11 @@ export declare const DeclarePolicyInputSchema: z.ZodObject<{
1698
1723
  contract?: string | undefined;
1699
1724
  maxAmount?: string | undefined;
1700
1725
  amountArgIndex?: number | undefined;
1726
+ amountPath?: {
1727
+ field: string;
1728
+ argIndex: number;
1729
+ elements?: number | undefined;
1730
+ } | undefined;
1701
1731
  recipients?: string[] | undefined;
1702
1732
  recipientArgIndex?: number | undefined;
1703
1733
  allowZeroCap?: boolean | undefined;
@@ -431,6 +431,20 @@ exports.DeclarePolicyInputSchema = zod_1.z
431
431
  .regex(/^[0-9]+$/, 'maxAmount must be an unsigned integer in the smallest unit')
432
432
  .optional(),
433
433
  amountArgIndex: zod_1.z.number().int().nonnegative().max(U32_MAX).optional(),
434
+ /** Nested amount location, for a call whose amount is a field inside a
435
+ * struct argument rather than an argument of its own. Every coordinate is
436
+ * REQUIRED - `declarePredicate` defaults none of them, because a nested
437
+ * path guessed wrong caps something the caller never named. */
438
+ amountPath: zod_1.z
439
+ .object({
440
+ argIndex: zod_1.z.number().int().nonnegative().max(U32_MAX),
441
+ field: zod_1.z.string().min(1),
442
+ /** How many entries the vec may carry. EVERY one is capped; see
443
+ * `declarePredicate`, which refuses a single-element bound because it
444
+ * would leave the rest of the vec unconstrained. */
445
+ elements: zod_1.z.number().int().min(1).max(U32_MAX).optional(),
446
+ })
447
+ .optional(),
434
448
  recipients: zod_1.z.array(zod_1.z.string()).min(1, 'recipients must not be empty').optional(),
435
449
  recipientArgIndex: zod_1.z.number().int().nonnegative().max(U32_MAX).optional(),
436
450
  allowZeroCap: zod_1.z.boolean().optional(),
@@ -10,6 +10,31 @@ export interface PolicyDeclaration {
10
10
  maxAmount?: string;
11
11
  /** Which argument carries the amount. Defaults to the SEP-41 position. */
12
12
  amountArgIndex?: number;
13
+ /** Where the amount sits when it is NESTED inside a struct argument rather
14
+ * than being an argument of its own - Blend's `submit` is the motivating
15
+ * case: its amount is `requests[i].amount`, so no positional index names it
16
+ * and `amountArgIndex` has nothing to point at.
17
+ *
18
+ * Mutually exclusive with `amountArgIndex`.
19
+ *
20
+ * THIS NAMES A COUNT, NOT AN INDEX, and that is deliberate. `call_arg_field`
21
+ * binds ONE element of the vec and says nothing about the others, so a
22
+ * declaration that pointed at a single element would leave every other
23
+ * element unbounded - a caller puts the real spend in one of those and the
24
+ * cap is decorative. There is no safe way to bound "the amount" in a vec
25
+ * without bounding EVERY entry, so `elements` states how many entries the
26
+ * call may carry and all of them are capped.
27
+ *
28
+ * THE CAP IS PER ENTRY. With `elements: 3` and `maxAmount` 100, one call may
29
+ * carry three requests of 100 and move 300 in total. Declare the per-entry
30
+ * figure you mean, not the total you have in mind. */
31
+ amountPath?: {
32
+ argIndex: number;
33
+ field: string;
34
+ /** How many entries the vec may carry. Defaults to 1 - a single-request
35
+ * call, which is the shape a per-call cap is usually written for. */
36
+ elements?: number;
37
+ };
13
38
  /** Recipient allowlist. */
14
39
  recipients?: string[];
15
40
  /** Which argument carries the recipient. Defaults to the SEP-41 position. */
@@ -38,6 +63,6 @@ export interface DeclaredPredicate {
38
63
  * was defaulted rather than supplied. */
39
64
  warnings: string[];
40
65
  }
41
- /** Lower a declared constraint to a grammar-3 predicate. Pure and total:
66
+ /** Lower a declared constraint to a grammar-4 predicate. Pure and total:
42
67
  * the same declaration always produces the same predicate. */
43
68
  export declare function declarePredicate(d: PolicyDeclaration): DeclaredPredicate;
@@ -20,6 +20,9 @@
20
20
  // fn -> eq(call_fn, literal_symbol)
21
21
  // contract -> eq(call_contract, literal_address)
22
22
  // maxAmount -> lte(call_arg(i), literal_i128)
23
+ // maxAmount + amountPath
24
+ // -> lte(call_arg_field(i, e, field), literal_i128) for EVERY
25
+ // e in 0..elements-1, AND eq(call_arg_len(i), literal_u32(elements))
23
26
  // recipients -> in(call_arg(j), [literal_address, ...])
24
27
  // minOutputRatio-> gte(call_arg(out), call_arg_scaled(in, num, den))
25
28
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -32,7 +35,13 @@ const address_ts_1 = require("./address.js");
32
35
  * user did not mean and fails silently. */
33
36
  const SEP41_RECIPIENT_ARG = 1;
34
37
  const SEP41_AMOUNT_ARG = 2;
35
- /** Lower a declared constraint to a grammar-3 predicate. Pure and total:
38
+ /** `MAX_LEAVES` in the on-chain interpreter (policy-interpreter/src/dsl.rs). */
39
+ const INTERPRETER_MAX_LEAVES = 200;
40
+ /** Entries a nested amount bound may cover. Each costs one comparison (two
41
+ * leaves), so this sits well inside the interpreter's budget while being far
42
+ * more than any real call carries - a Blend `submit` is a handful. */
43
+ const MAX_BOUNDED_ELEMENTS = 16;
44
+ /** Lower a declared constraint to a grammar-4 predicate. Pure and total:
36
45
  * the same declaration always produces the same predicate. */
37
46
  function declarePredicate(d) {
38
47
  if (!d.fn || d.fn.trim() === '') {
@@ -71,6 +80,12 @@ function declarePredicate(d) {
71
80
  haystack: d.recipients.map((value) => ({ kind: 'literal_address', value })),
72
81
  });
73
82
  }
83
+ if (d.amountPath !== undefined && d.amountArgIndex !== undefined) {
84
+ throw declareError('SYNTHESIS_ERROR', 'amountArgIndex and amountPath both name where the amount lives, and they disagree by construction: one is a positional argument, the other a field inside one. Pass exactly one.');
85
+ }
86
+ if (d.amountPath !== undefined && d.maxAmount === undefined) {
87
+ throw declareError('SYNTHESIS_ERROR', 'amountPath says WHERE the amount is but not what bounds it. Pass maxAmount, or omit amountPath.');
88
+ }
74
89
  if (d.maxAmount !== undefined) {
75
90
  if (!/^[0-9]+$/.test(d.maxAmount)) {
76
91
  throw declareError('SYNTHESIS_ERROR', `maxAmount must be an unsigned integer in the token's smallest unit, got "${d.maxAmount}" (25 XLM = "250000000")`);
@@ -78,15 +93,56 @@ function declarePredicate(d) {
78
93
  if (d.maxAmount === '0' && d.allowZeroCap !== true) {
79
94
  throw declareError('SYNTHESIS_ERROR', 'maxAmount "0" denies every call: no amount satisfies the bound. Set allowZeroCap to declare that deliberately.');
80
95
  }
81
- const idx = d.amountArgIndex ?? SEP41_AMOUNT_ARG;
82
- if (d.amountArgIndex === undefined) {
83
- warnings.push(`amount cap bound to call_arg(${idx}), the SEP-41 \`transfer\` position. If \`${d.fn}\` carries the amount elsewhere this caps the wrong argument - pass amountArgIndex.`);
96
+ if (d.amountPath !== undefined) {
97
+ const { argIndex, field } = d.amountPath;
98
+ const elements = d.amountPath.elements ?? 1;
99
+ if (!Number.isInteger(argIndex) || argIndex < 0) {
100
+ throw declareError('SYNTHESIS_ERROR', `amountPath.argIndex must be a non-negative integer, got ${String(argIndex)}`);
101
+ }
102
+ if (!Number.isInteger(elements) || elements < 1) {
103
+ throw declareError('SYNTHESIS_ERROR', `amountPath.elements is how many entries the vec may carry, so it must be at least 1, got ${String(elements)}`);
104
+ }
105
+ if (elements > MAX_BOUNDED_ELEMENTS) {
106
+ throw declareError('SYNTHESIS_ERROR', `amountPath.elements is ${elements}; each entry costs a comparison and the interpreter caps a predicate at ${INTERPRETER_MAX_LEAVES} leaves. ${MAX_BOUNDED_ELEMENTS} is already far past any real call.`);
107
+ }
108
+ if (typeof field !== 'string' || field.trim() === '') {
109
+ throw declareError('SYNTHESIS_ERROR', 'amountPath.field must name the map key holding the amount');
110
+ }
111
+ // EVERY entry, not one. Bounding a single element leaves the rest of the
112
+ // vec unconstrained and the caller simply puts the spend in an unbounded
113
+ // one - the cap then reads as enforced while permitting any amount.
114
+ for (let element = 0; element < elements; element += 1) {
115
+ children.push({
116
+ op: 'lte',
117
+ left: { kind: 'call_arg_field', index: argIndex, element, field },
118
+ right: { kind: 'literal_i128', value: d.maxAmount },
119
+ });
120
+ }
121
+ // And the count, or the caller appends an entry past the ones bounded
122
+ // above and spends through it. The two leaves are only safe together:
123
+ // the bounds cover entries 0..elements-1, this makes those the only
124
+ // entries there are. The recording front-end pairs them for the same
125
+ // reason.
126
+ children.push({
127
+ op: 'eq',
128
+ left: { kind: 'call_arg_len', index: argIndex },
129
+ right: { kind: 'literal_u32', value: elements },
130
+ });
131
+ warnings.push(elements === 1
132
+ ? `amount cap bound to call_arg_field(${argIndex}, 0, "${field}"), and argument ${argIndex} is pinned to exactly 1 entry. A call carrying more than one entry is DENIED - if this method is normally called with several, pass amountPath.elements.`
133
+ : `amount cap bound to every entry of argument ${argIndex} ("${field}"), which is pinned to exactly ${elements} entries. The cap is PER ENTRY: a single call may carry ${elements} of them and move up to ${elements} x ${d.maxAmount} in total. A call carrying a different number of entries is DENIED.`);
134
+ }
135
+ else {
136
+ const idx = d.amountArgIndex ?? SEP41_AMOUNT_ARG;
137
+ if (d.amountArgIndex === undefined) {
138
+ warnings.push(`amount cap bound to call_arg(${idx}), the SEP-41 \`transfer\` position. If \`${d.fn}\` carries the amount elsewhere this caps the wrong argument - pass amountArgIndex.`);
139
+ }
140
+ children.push({
141
+ op: 'lte',
142
+ left: { kind: 'call_arg', index: idx },
143
+ right: { kind: 'literal_i128', value: d.maxAmount },
144
+ });
84
145
  }
85
- children.push({
86
- op: 'lte',
87
- left: { kind: 'call_arg', index: idx },
88
- right: { kind: 'literal_i128', value: d.maxAmount },
89
- });
90
146
  }
91
147
  if (d.minOutputRatio !== undefined) {
92
148
  const { num, den, inputArgIndex, outputArgIndex } = d.minOutputRatio;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "0.5.3",
3
+ "version": "0.5.6",
4
4
  "license": "MIT",
5
5
  "description": "Off-chain TypeScript synthesis core for the OZ Accounts Policy Builder. Records Soroban transactions, synthesises the minimal policy that permits exactly that flow, verifies it, and returns an unsigned install transaction.",
6
6
  "type": "module",
@@ -134,12 +134,32 @@ function leftArgLabel(leaf: PredicateLeaf): string {
134
134
 
135
135
  /** Render ONE constraint sentence for ONE interpreter predicate node. The
136
136
  * shape of the output is pinned by Task 7b so the test suite can assert
137
- * byte-for-byte equality. Returns `null` when the node is a structural
138
- * boolean (`and`) - that is not a constraint leaf. */
137
+ * byte-for-byte equality. Returns `null` only when a node's shape cannot be
138
+ * rendered at all. */
139
139
  function renderConstraint(node: PredicateNode): string | null {
140
140
  switch (node.op) {
141
- case 'and':
142
- return null
141
+ case 'and': {
142
+ // Reached ONLY when an `and` sits BENEATH an `or`: `walkPredicate`
143
+ // descends into a top-level `and` and never calls this on one.
144
+ //
145
+ // This used to return null, on the reasoning that `and` is structural
146
+ // rather than a constraint leaf. That is true for the walk path and
147
+ // false for this one, and the combination silently dropped real
148
+ // policies: `or(and, and)` is the natural shape of a policy with
149
+ // alternative permitted forms - one conjunction of pins per branch -
150
+ // so the `or` case below always saw null children and withheld the
151
+ // entire disjunction. `describePredicate` then returned an EMPTY list
152
+ // for a restrictive policy, which any caller renders as "no
153
+ // constraints".
154
+ //
155
+ // That inverts the very guard it was protecting: the `or` case
156
+ // withholds to avoid reading STRICTER than reality, but withholding
157
+ // the only line reads as UNRESTRICTED, which is far worse. Compose
158
+ // instead, and keep the withhold for genuinely unrenderable children.
159
+ const parts = node.children.map(renderConstraint)
160
+ if (parts.some((p) => p === null)) return null
161
+ return parts.join(' and ')
162
+ }
143
163
  case 'or': {
144
164
  // One line for the whole disjunction. If any branch is a shape the
145
165
  // card cannot render, the entire line is withheld rather than shown
package/src/run/index.ts CHANGED
@@ -459,6 +459,19 @@ export function runDeclarePolicy(raw: unknown): ToolResponse<{
459
459
  ...(d.contract !== undefined ? { contract: d.contract } : {}),
460
460
  ...(d.maxAmount !== undefined ? { maxAmount: d.maxAmount } : {}),
461
461
  ...(d.amountArgIndex !== undefined ? { amountArgIndex: d.amountArgIndex } : {}),
462
+ ...(d.amountPath !== undefined
463
+ ? {
464
+ // Rebuilt like the rest: `exactOptionalPropertyTypes` treats the
465
+ // schema's `number | undefined` and the declaration's absent-or-T
466
+ // as different types, and the nested optional needs the same
467
+ // treatment as the top-level ones.
468
+ amountPath: {
469
+ argIndex: d.amountPath.argIndex,
470
+ field: d.amountPath.field,
471
+ ...(d.amountPath.elements !== undefined ? { elements: d.amountPath.elements } : {}),
472
+ },
473
+ }
474
+ : {}),
462
475
  ...(d.recipients !== undefined ? { recipients: d.recipients } : {}),
463
476
  ...(d.recipientArgIndex !== undefined ? { recipientArgIndex: d.recipientArgIndex } : {}),
464
477
  ...(d.allowZeroCap !== undefined ? { allowZeroCap: d.allowZeroCap } : {}),
@@ -500,6 +500,20 @@ export const DeclarePolicyInputSchema = z
500
500
  .regex(/^[0-9]+$/, 'maxAmount must be an unsigned integer in the smallest unit')
501
501
  .optional(),
502
502
  amountArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
503
+ /** Nested amount location, for a call whose amount is a field inside a
504
+ * struct argument rather than an argument of its own. Every coordinate is
505
+ * REQUIRED - `declarePredicate` defaults none of them, because a nested
506
+ * path guessed wrong caps something the caller never named. */
507
+ amountPath: z
508
+ .object({
509
+ argIndex: z.number().int().nonnegative().max(U32_MAX),
510
+ field: z.string().min(1),
511
+ /** How many entries the vec may carry. EVERY one is capped; see
512
+ * `declarePredicate`, which refuses a single-element bound because it
513
+ * would leave the rest of the vec unconstrained. */
514
+ elements: z.number().int().min(1).max(U32_MAX).optional(),
515
+ })
516
+ .optional(),
503
517
  recipients: z.array(z.string()).min(1, 'recipients must not be empty').optional(),
504
518
  recipientArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
505
519
  allowZeroCap: z.boolean().optional(),
@@ -19,6 +19,9 @@
19
19
  // fn -> eq(call_fn, literal_symbol)
20
20
  // contract -> eq(call_contract, literal_address)
21
21
  // maxAmount -> lte(call_arg(i), literal_i128)
22
+ // maxAmount + amountPath
23
+ // -> lte(call_arg_field(i, e, field), literal_i128) for EVERY
24
+ // e in 0..elements-1, AND eq(call_arg_len(i), literal_u32(elements))
22
25
  // recipients -> in(call_arg(j), [literal_address, ...])
23
26
  // minOutputRatio-> gte(call_arg(out), call_arg_scaled(in, num, den))
24
27
 
@@ -34,6 +37,13 @@ import { isStellarAddress } from './address.ts'
34
37
  const SEP41_RECIPIENT_ARG = 1
35
38
  const SEP41_AMOUNT_ARG = 2
36
39
 
40
+ /** `MAX_LEAVES` in the on-chain interpreter (policy-interpreter/src/dsl.rs). */
41
+ const INTERPRETER_MAX_LEAVES = 200
42
+ /** Entries a nested amount bound may cover. Each costs one comparison (two
43
+ * leaves), so this sits well inside the interpreter's budget while being far
44
+ * more than any real call carries - a Blend `submit` is a handful. */
45
+ const MAX_BOUNDED_ELEMENTS = 16
46
+
37
47
  export interface PolicyDeclaration {
38
48
  /** Method to pin. Required: a predicate with no selector leaf constrains
39
49
  * nothing and the contract refuses it at install. */
@@ -45,6 +55,31 @@ export interface PolicyDeclaration {
45
55
  maxAmount?: string
46
56
  /** Which argument carries the amount. Defaults to the SEP-41 position. */
47
57
  amountArgIndex?: number
58
+ /** Where the amount sits when it is NESTED inside a struct argument rather
59
+ * than being an argument of its own - Blend's `submit` is the motivating
60
+ * case: its amount is `requests[i].amount`, so no positional index names it
61
+ * and `amountArgIndex` has nothing to point at.
62
+ *
63
+ * Mutually exclusive with `amountArgIndex`.
64
+ *
65
+ * THIS NAMES A COUNT, NOT AN INDEX, and that is deliberate. `call_arg_field`
66
+ * binds ONE element of the vec and says nothing about the others, so a
67
+ * declaration that pointed at a single element would leave every other
68
+ * element unbounded - a caller puts the real spend in one of those and the
69
+ * cap is decorative. There is no safe way to bound "the amount" in a vec
70
+ * without bounding EVERY entry, so `elements` states how many entries the
71
+ * call may carry and all of them are capped.
72
+ *
73
+ * THE CAP IS PER ENTRY. With `elements: 3` and `maxAmount` 100, one call may
74
+ * carry three requests of 100 and move 300 in total. Declare the per-entry
75
+ * figure you mean, not the total you have in mind. */
76
+ amountPath?: {
77
+ argIndex: number
78
+ field: string
79
+ /** How many entries the vec may carry. Defaults to 1 - a single-request
80
+ * call, which is the shape a per-call cap is usually written for. */
81
+ elements?: number
82
+ }
48
83
  /** Recipient allowlist. */
49
84
  recipients?: string[]
50
85
  /** Which argument carries the recipient. Defaults to the SEP-41 position. */
@@ -70,7 +105,7 @@ export interface DeclaredPredicate {
70
105
  warnings: string[]
71
106
  }
72
107
 
73
- /** Lower a declared constraint to a grammar-3 predicate. Pure and total:
108
+ /** Lower a declared constraint to a grammar-4 predicate. Pure and total:
74
109
  * the same declaration always produces the same predicate. */
75
110
  export function declarePredicate(d: PolicyDeclaration): DeclaredPredicate {
76
111
  if (!d.fn || d.fn.trim() === '') {
@@ -120,6 +155,19 @@ export function declarePredicate(d: PolicyDeclaration): DeclaredPredicate {
120
155
  })
121
156
  }
122
157
 
158
+ if (d.amountPath !== undefined && d.amountArgIndex !== undefined) {
159
+ throw declareError(
160
+ 'SYNTHESIS_ERROR',
161
+ 'amountArgIndex and amountPath both name where the amount lives, and they disagree by construction: one is a positional argument, the other a field inside one. Pass exactly one.'
162
+ )
163
+ }
164
+ if (d.amountPath !== undefined && d.maxAmount === undefined) {
165
+ throw declareError(
166
+ 'SYNTHESIS_ERROR',
167
+ 'amountPath says WHERE the amount is but not what bounds it. Pass maxAmount, or omit amountPath.'
168
+ )
169
+ }
170
+
123
171
  if (d.maxAmount !== undefined) {
124
172
  if (!/^[0-9]+$/.test(d.maxAmount)) {
125
173
  throw declareError(
@@ -133,17 +181,71 @@ export function declarePredicate(d: PolicyDeclaration): DeclaredPredicate {
133
181
  'maxAmount "0" denies every call: no amount satisfies the bound. Set allowZeroCap to declare that deliberately.'
134
182
  )
135
183
  }
136
- const idx = d.amountArgIndex ?? SEP41_AMOUNT_ARG
137
- if (d.amountArgIndex === undefined) {
184
+ if (d.amountPath !== undefined) {
185
+ const { argIndex, field } = d.amountPath
186
+ const elements = d.amountPath.elements ?? 1
187
+ if (!Number.isInteger(argIndex) || argIndex < 0) {
188
+ throw declareError(
189
+ 'SYNTHESIS_ERROR',
190
+ `amountPath.argIndex must be a non-negative integer, got ${String(argIndex)}`
191
+ )
192
+ }
193
+ if (!Number.isInteger(elements) || elements < 1) {
194
+ throw declareError(
195
+ 'SYNTHESIS_ERROR',
196
+ `amountPath.elements is how many entries the vec may carry, so it must be at least 1, got ${String(elements)}`
197
+ )
198
+ }
199
+ if (elements > MAX_BOUNDED_ELEMENTS) {
200
+ throw declareError(
201
+ 'SYNTHESIS_ERROR',
202
+ `amountPath.elements is ${elements}; each entry costs a comparison and the interpreter caps a predicate at ${INTERPRETER_MAX_LEAVES} leaves. ${MAX_BOUNDED_ELEMENTS} is already far past any real call.`
203
+ )
204
+ }
205
+ if (typeof field !== 'string' || field.trim() === '') {
206
+ throw declareError(
207
+ 'SYNTHESIS_ERROR',
208
+ 'amountPath.field must name the map key holding the amount'
209
+ )
210
+ }
211
+ // EVERY entry, not one. Bounding a single element leaves the rest of the
212
+ // vec unconstrained and the caller simply puts the spend in an unbounded
213
+ // one - the cap then reads as enforced while permitting any amount.
214
+ for (let element = 0; element < elements; element += 1) {
215
+ children.push({
216
+ op: 'lte',
217
+ left: { kind: 'call_arg_field', index: argIndex, element, field },
218
+ right: { kind: 'literal_i128', value: d.maxAmount },
219
+ })
220
+ }
221
+ // And the count, or the caller appends an entry past the ones bounded
222
+ // above and spends through it. The two leaves are only safe together:
223
+ // the bounds cover entries 0..elements-1, this makes those the only
224
+ // entries there are. The recording front-end pairs them for the same
225
+ // reason.
226
+ children.push({
227
+ op: 'eq',
228
+ left: { kind: 'call_arg_len', index: argIndex },
229
+ right: { kind: 'literal_u32', value: elements },
230
+ })
138
231
  warnings.push(
139
- `amount cap bound to call_arg(${idx}), the SEP-41 \`transfer\` position. If \`${d.fn}\` carries the amount elsewhere this caps the wrong argument - pass amountArgIndex.`
232
+ elements === 1
233
+ ? `amount cap bound to call_arg_field(${argIndex}, 0, "${field}"), and argument ${argIndex} is pinned to exactly 1 entry. A call carrying more than one entry is DENIED - if this method is normally called with several, pass amountPath.elements.`
234
+ : `amount cap bound to every entry of argument ${argIndex} ("${field}"), which is pinned to exactly ${elements} entries. The cap is PER ENTRY: a single call may carry ${elements} of them and move up to ${elements} x ${d.maxAmount} in total. A call carrying a different number of entries is DENIED.`
140
235
  )
236
+ } else {
237
+ const idx = d.amountArgIndex ?? SEP41_AMOUNT_ARG
238
+ if (d.amountArgIndex === undefined) {
239
+ warnings.push(
240
+ `amount cap bound to call_arg(${idx}), the SEP-41 \`transfer\` position. If \`${d.fn}\` carries the amount elsewhere this caps the wrong argument - pass amountArgIndex.`
241
+ )
242
+ }
243
+ children.push({
244
+ op: 'lte',
245
+ left: { kind: 'call_arg', index: idx },
246
+ right: { kind: 'literal_i128', value: d.maxAmount },
247
+ })
141
248
  }
142
- children.push({
143
- op: 'lte',
144
- left: { kind: 'call_arg', index: idx },
145
- right: { kind: 'literal_i128', value: d.maxAmount },
146
- })
147
249
  }
148
250
 
149
251
  if (d.minOutputRatio !== undefined) {