@crediolabs/policy-synth 0.5.4 → 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.
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;
@@ -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.4",
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",
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) {