@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.
- package/dist/review-card/builder.js +25 -4
- package/dist/run/index.js +13 -0
- package/dist/run/schemas.d.ts +30 -0
- package/dist/run/schemas.js +14 -0
- package/dist/synth/declare.d.ts +26 -1
- package/dist/synth/declare.js +65 -9
- package/dist-cjs/review-card/builder.js +25 -4
- package/dist-cjs/run/index.js +13 -0
- package/dist-cjs/run/schemas.d.ts +30 -0
- package/dist-cjs/run/schemas.js +14 -0
- package/dist-cjs/synth/declare.d.ts +26 -1
- package/dist-cjs/synth/declare.js +65 -9
- package/package.json +1 -1
- package/src/review-card/builder.ts +24 -4
- package/src/run/index.ts +13 -0
- package/src/run/schemas.ts +14 -0
- package/src/synth/declare.ts +111 -9
|
@@ -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
|
|
111
|
-
*
|
|
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
|
-
|
|
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 } : {}),
|
package/dist/run/schemas.d.ts
CHANGED
|
@@ -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;
|
package/dist/run/schemas.js
CHANGED
|
@@ -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(),
|
package/dist/synth/declare.d.ts
CHANGED
|
@@ -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-
|
|
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;
|
package/dist/synth/declare.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
115
|
-
*
|
|
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
|
-
|
|
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
|
package/dist-cjs/run/index.js
CHANGED
|
@@ -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;
|
package/dist-cjs/run/schemas.js
CHANGED
|
@@ -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-
|
|
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
|
-
/**
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
"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
|
|
138
|
-
*
|
|
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
|
-
|
|
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 } : {}),
|
package/src/run/schemas.ts
CHANGED
|
@@ -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(),
|
package/src/synth/declare.ts
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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) {
|