@crediolabs/policy-synth 0.1.11 → 0.1.12
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/adapters/interpreter/adapter.js +21 -0
- package/dist/adapters/oz/adapter.js +4 -0
- package/dist/ir/types.d.ts +19 -0
- package/dist/predicate/index.d.ts +1 -1
- package/dist/predicate/index.js +1 -1
- package/dist/review-card/builder.js +32 -0
- package/dist/review-card/cross-check.js +20 -0
- package/dist/synth/compose-from-recording.d.ts +14 -0
- package/dist/synth/compose-from-recording.js +41 -2
- package/dist-cjs/adapters/interpreter/adapter.js +21 -0
- package/dist-cjs/adapters/oz/adapter.js +4 -0
- package/dist-cjs/ir/types.d.ts +19 -0
- package/dist-cjs/predicate/index.d.ts +1 -1
- package/dist-cjs/predicate/index.js +3 -3
- package/dist-cjs/review-card/builder.js +32 -0
- package/dist-cjs/review-card/cross-check.js +20 -0
- package/dist-cjs/synth/compose-from-recording.d.ts +14 -0
- package/dist-cjs/synth/compose-from-recording.js +41 -2
- package/package.json +1 -1
- package/src/adapters/interpreter/adapter.ts +21 -0
- package/src/adapters/oz/adapter.ts +4 -0
- package/src/ir/types.ts +19 -0
- package/src/predicate/index.ts +1 -1
- package/src/review-card/builder.ts +27 -0
- package/src/review-card/cross-check.ts +25 -1
- package/src/synth/compose-from-recording.ts +55 -0
- package/src/synth/evaluate.ts +0 -1
- package/src/synth/permit-context.ts +0 -1
|
@@ -218,6 +218,9 @@ function lowerRule(rule, config) {
|
|
|
218
218
|
* by `lowerRule` to populate `uncovered` before lowering. */
|
|
219
219
|
function unsupportedConstruct(cond) {
|
|
220
220
|
switch (cond.op) {
|
|
221
|
+
// Expressible: `call_arg_scaled` is exactly this construct.
|
|
222
|
+
case 'slippage_floor':
|
|
223
|
+
return null;
|
|
221
224
|
case 'in':
|
|
222
225
|
// The oracle position rule is a hard error and must be raised before a
|
|
223
226
|
// construct can be written off as uncovered - otherwise a subtree that
|
|
@@ -281,6 +284,20 @@ function unsourceableSelector(s) {
|
|
|
281
284
|
* address -> SCOPE_SELF_CALL). */
|
|
282
285
|
function lowerCondition(cond, config) {
|
|
283
286
|
switch (cond.op) {
|
|
287
|
+
// `out >= in * num/den`. The contract refuses den == 0 or a non-positive
|
|
288
|
+
// ratio at install, so a malformed floor fails loudly rather than
|
|
289
|
+
// silently inverting the comparison.
|
|
290
|
+
case 'slippage_floor':
|
|
291
|
+
return {
|
|
292
|
+
op: 'gte',
|
|
293
|
+
left: { kind: 'call_arg', index: cond.outArgIndex },
|
|
294
|
+
right: {
|
|
295
|
+
kind: 'call_arg_scaled',
|
|
296
|
+
index: cond.inArgIndex,
|
|
297
|
+
num: cond.num,
|
|
298
|
+
den: cond.den,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
284
301
|
case 'and':
|
|
285
302
|
return { op: 'and', children: cond.children.map((c) => lowerCondition(c, config)) };
|
|
286
303
|
case 'or':
|
|
@@ -458,6 +475,8 @@ function assertNoOracleDescendants(node) {
|
|
|
458
475
|
}
|
|
459
476
|
function containsOracle(node) {
|
|
460
477
|
switch (node.op) {
|
|
478
|
+
case 'slippage_floor':
|
|
479
|
+
return false;
|
|
461
480
|
case 'compare':
|
|
462
481
|
return node.compare.selector.kind === 'oracle_price';
|
|
463
482
|
case 'in':
|
|
@@ -496,6 +515,8 @@ function assertOracleParamsTighten(params) {
|
|
|
496
515
|
* express. Mirrors the OZ adapter's `describeCondition` for parity. */
|
|
497
516
|
function describeCondition(cond) {
|
|
498
517
|
switch (cond.op) {
|
|
518
|
+
case 'slippage_floor':
|
|
519
|
+
return `slippage floor: arg[${cond.outArgIndex}] >= arg[${cond.inArgIndex}] * ${cond.num}/${cond.den}`;
|
|
499
520
|
case 'in':
|
|
500
521
|
return `value allowlist on ${describeSelector(cond.selector)} (predicate DSL)`;
|
|
501
522
|
case 'eq_seq':
|
|
@@ -207,6 +207,10 @@ function matchSpendingLimit(c) {
|
|
|
207
207
|
* cannot express, used to populate `uncovered`. */
|
|
208
208
|
function describeCondition(cond) {
|
|
209
209
|
switch (cond.op) {
|
|
210
|
+
case 'slippage_floor':
|
|
211
|
+
// The OZ primitives bound a value against a constant; this bounds one
|
|
212
|
+
// call argument against another, which none of them can express.
|
|
213
|
+
return `slippage floor on arg[${cond.outArgIndex}] (OZ built-ins cannot bound one argument against another)`;
|
|
210
214
|
case 'in':
|
|
211
215
|
return `value allowlist on ${describeSelector(cond.selector)} (arg allowlist)`;
|
|
212
216
|
case 'eq_seq':
|
package/dist/ir/types.d.ts
CHANGED
|
@@ -85,6 +85,25 @@ export type IRCondition = {
|
|
|
85
85
|
op: 'eq_seq';
|
|
86
86
|
selector: IRSelector;
|
|
87
87
|
values: string[];
|
|
88
|
+
}
|
|
89
|
+
/** A swap's output floor, expressed against its own input: the output arg
|
|
90
|
+
* must be at least `inArgIndex * num / den`.
|
|
91
|
+
*
|
|
92
|
+
* It is its own construct because `compare` bounds a selector against a
|
|
93
|
+
* CONSTANT, and a slippage floor has no constant to bound against - the
|
|
94
|
+
* acceptable output depends on the input of the same call. Encoding the
|
|
95
|
+
* recorded output as a constant would pin a policy to one trade size.
|
|
96
|
+
*
|
|
97
|
+
* `num/den` is the caller's minimum acceptable output per unit of input.
|
|
98
|
+
* It is never derived from the recording: the recorded rate is a price at
|
|
99
|
+
* one moment, and freezing it as policy would deny normal trades later.
|
|
100
|
+
* Adapters that cannot express it (the OZ built-ins) flag it `uncovered`. */
|
|
101
|
+
| {
|
|
102
|
+
op: 'slippage_floor';
|
|
103
|
+
outArgIndex: number;
|
|
104
|
+
inArgIndex: number;
|
|
105
|
+
num: string;
|
|
106
|
+
den: string;
|
|
88
107
|
};
|
|
89
108
|
export interface IRPolicyRule {
|
|
90
109
|
/** NEAR-V2 roles whitelist (empty = any; owner exempt). */
|
package/dist/predicate/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/predicate/index.ts - re-export the canonical predicate encoder and the
|
|
2
2
|
// untrusted-JSON parser that feeds it. A consumer accepting a hand-written
|
|
3
3
|
// policy needs both halves.
|
|
4
|
-
export { encodePredicate } from "./encode.js";
|
|
5
4
|
export { decodeLeaf, decodeNode, decodePredicate } from "./decode.js";
|
|
5
|
+
export { encodePredicate } from "./encode.js";
|
|
6
6
|
export { jsonToAst } from "./from-json.js";
|
|
@@ -192,6 +192,38 @@ function renderComparison(node) {
|
|
|
192
192
|
const allowed = node.op === 'lt' ? right.value : right.value + 1;
|
|
193
193
|
return `At most ${allowed} calls per ${left.windowSecs} seconds`;
|
|
194
194
|
}
|
|
195
|
+
// OP(call_arg[i], <scalar literal>) -> arg[i] OP <value>
|
|
196
|
+
//
|
|
197
|
+
// The per-call cap. It has to be here because the `amount` template below
|
|
198
|
+
// is unreachable for an interpreter policy: `amount` is deliberately not in
|
|
199
|
+
// the contract's grammar (dsl.rs - the interpreter sees one authorized
|
|
200
|
+
// call, not the transaction's token movements), so a predicate using it is
|
|
201
|
+
// refused at install. A bound on the call's own amount ARGUMENT is how a
|
|
202
|
+
// cap is actually written, and it was rendering nothing at all - the card
|
|
203
|
+
// silently understated the policy.
|
|
204
|
+
//
|
|
205
|
+
// Placed after the literal_vec case above so an exact-sequence `eq` still
|
|
206
|
+
// reads as a path rather than as a comparison.
|
|
207
|
+
if (left.kind === 'call_arg') {
|
|
208
|
+
const head = `arg[${left.index}]`;
|
|
209
|
+
const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op);
|
|
210
|
+
if (right.kind === 'literal_i128')
|
|
211
|
+
return `${head} ${sep} ${right.value}`;
|
|
212
|
+
if (right.kind === 'literal_u64')
|
|
213
|
+
return `${head} ${sep} ${right.value}`;
|
|
214
|
+
if (right.kind === 'literal_u32')
|
|
215
|
+
return `${head} ${sep} ${right.value}`;
|
|
216
|
+
if (right.kind === 'literal_address')
|
|
217
|
+
return `${head} ${sep} ${right.value}`;
|
|
218
|
+
if (right.kind === 'literal_symbol')
|
|
219
|
+
return `${head} ${sep} ${right.value}`;
|
|
220
|
+
if (right.kind === 'literal_bytes')
|
|
221
|
+
return `${head} ${sep} ${right.value}`;
|
|
222
|
+
// call_arg_scaled is the slippage floor and reads as itself.
|
|
223
|
+
if (right.kind === 'call_arg_scaled') {
|
|
224
|
+
return `${head} >= arg[${right.index}] * ${right.num}/${right.den}`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
195
227
|
// amount <= v -> Amount <= v
|
|
196
228
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
197
229
|
return `Amount <= ${right.value}`;
|
|
@@ -112,6 +112,26 @@ function pushComparison(left, right, op, out) {
|
|
|
112
112
|
out.push(`At most ${allowed} calls per ${left.windowSecs} seconds`);
|
|
113
113
|
return;
|
|
114
114
|
}
|
|
115
|
+
// Mirrors the builder's per-call cap line. Both sides must render the same
|
|
116
|
+
// string: cross-check compares the summary against the predicate, so a
|
|
117
|
+
// shape only ONE of them renders would read as a dropped constraint.
|
|
118
|
+
if (left.kind === 'call_arg') {
|
|
119
|
+
const head = `arg[${left.index}]`;
|
|
120
|
+
const sep = op === 'eq' ? '=' : comparisonOpText(op);
|
|
121
|
+
if (right.kind === 'literal_i128' ||
|
|
122
|
+
right.kind === 'literal_u64' ||
|
|
123
|
+
right.kind === 'literal_u32' ||
|
|
124
|
+
right.kind === 'literal_address' ||
|
|
125
|
+
right.kind === 'literal_symbol' ||
|
|
126
|
+
right.kind === 'literal_bytes') {
|
|
127
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (right.kind === 'call_arg_scaled') {
|
|
131
|
+
out.push(`${head} >= arg[${right.index}] * ${right.num}/${right.den}`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
115
135
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
116
136
|
out.push(`Amount <= ${right.value}`);
|
|
117
137
|
return;
|
|
@@ -31,6 +31,20 @@ export interface ComposeUserResponses {
|
|
|
31
31
|
* `oracle_price(asset) OP value` compare in the interpreter IR. Multiple
|
|
32
32
|
* entries on the same asset emit multiple leaves. */
|
|
33
33
|
oraclePriceBound?: OraclePriceBound[];
|
|
34
|
+
/** Minimum acceptable swap output per unit of input, as `num/den` (e.g.
|
|
35
|
+
* `{num:'95',den:'100'}` = accept losing at most 5%). Emitted as a
|
|
36
|
+
* slippage floor bounding the output arg against the input arg of the same
|
|
37
|
+
* call.
|
|
38
|
+
*
|
|
39
|
+
* REQUIRED to be supplied by the caller: it is never derived from the
|
|
40
|
+
* recording. The recorded in/out pair is a price at one moment, and
|
|
41
|
+
* freezing it as policy would deny ordinary trades as soon as the rate
|
|
42
|
+
* moves. Absent, no floor is emitted and the existing unbounded-output
|
|
43
|
+
* warning stands. */
|
|
44
|
+
swapMinOutRatio?: {
|
|
45
|
+
num: string;
|
|
46
|
+
den: string;
|
|
47
|
+
};
|
|
34
48
|
/** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
|
|
35
49
|
* swap_exact_tokens_for_tokens). When supplied, it REPLACES the default
|
|
36
50
|
* pin. Absent -> the recipient is pinned to the recorded value (mirroring
|
|
@@ -197,7 +197,7 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
197
197
|
// the window_spent path above already consumed the limit, so the per-call
|
|
198
198
|
// arg cap is skipped to avoid binding one limit to two different semantics.
|
|
199
199
|
const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined;
|
|
200
|
-
appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, interpreterEnabled);
|
|
200
|
+
appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, opts.userResponses?.swapMinOutRatio, interpreterEnabled);
|
|
201
201
|
}
|
|
202
202
|
// `scope.method` is carried on BOTH IRs so each adapter produces a
|
|
203
203
|
// self-consistent rule. The interpreter adapter lowers scope.method into a
|
|
@@ -239,7 +239,7 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
239
239
|
* SoroSwap's slippage / oracle / exact-path needs come from `userResponses`
|
|
240
240
|
* (oraclePriceBound + limitAmount) + the recorded path (eq_seq on
|
|
241
241
|
* call_arg[2]). */
|
|
242
|
-
function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, interpreterEnabled) {
|
|
242
|
+
function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, swapMinOutRatio, interpreterEnabled) {
|
|
243
243
|
// SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit it as
|
|
244
244
|
// a single-element allowlist; the interpreter adapter lowers it to `in`.
|
|
245
245
|
// When interpreter is not enabled, route to OZ so the caller sees today's
|
|
@@ -447,6 +447,30 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
447
447
|
ozConstraints.push(cond);
|
|
448
448
|
}
|
|
449
449
|
}
|
|
450
|
+
// Slippage floor: `out >= in * num/den`. Only when the caller supplied the
|
|
451
|
+
// ratio - see `swapMinOutRatio`. Without it the output arg stays free,
|
|
452
|
+
// which is the case the unbounded-swap warning above describes.
|
|
453
|
+
const outMinArgIndex = soroswapMinOutArgIndex(protocol.fn);
|
|
454
|
+
const minOutRatio = swapMinOutRatio;
|
|
455
|
+
if (minOutRatio !== undefined &&
|
|
456
|
+
inputArgIndex !== undefined &&
|
|
457
|
+
outMinArgIndex !== undefined &&
|
|
458
|
+
inputAmountArg &&
|
|
459
|
+
inputAmountArg.type === 'i128') {
|
|
460
|
+
const floor = {
|
|
461
|
+
op: 'slippage_floor',
|
|
462
|
+
outArgIndex: outMinArgIndex,
|
|
463
|
+
inArgIndex: inputArgIndex,
|
|
464
|
+
num: minOutRatio.num,
|
|
465
|
+
den: minOutRatio.den,
|
|
466
|
+
};
|
|
467
|
+
if (interpreterEnabled) {
|
|
468
|
+
interpreterConstraints.push(floor);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
ozConstraints.push(floor);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
450
474
|
// Swap recipient (call_arg[3]): when the caller supplies
|
|
451
475
|
// swapRecipientAllowlist, emit it as an `in` constraint on the recipient
|
|
452
476
|
// arg. When absent, PIN the recipient to the recorded value - mirroring the
|
|
@@ -491,6 +515,21 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
491
515
|
* the exact input as arg[0]; `swap_tokens_for_exact_tokens` takes the maximum
|
|
492
516
|
* input (`amount_in_max`) as arg[1] - its arg[0] is the exact OUTPUT. Any other
|
|
493
517
|
* function has no positional input-amount argument -> undefined (no cap bound). */
|
|
518
|
+
/** The argument carrying the swap's MINIMUM ACCEPTABLE OUTPUT.
|
|
519
|
+
*
|
|
520
|
+
* Only the exact-input entrypoints have one: `swap_tokens_for_exact_tokens`
|
|
521
|
+
* fixes the output and varies the input, so its output needs no floor (its
|
|
522
|
+
* arg[1] is `amount_in_max`, already bounded by the input cap). Returning
|
|
523
|
+
* undefined there keeps a floor from being pinned to the wrong argument. */
|
|
524
|
+
function soroswapMinOutArgIndex(fn) {
|
|
525
|
+
switch (fn) {
|
|
526
|
+
case 'swap_exact_tokens_for_tokens':
|
|
527
|
+
case 'swap_exact_in_for_tokens':
|
|
528
|
+
return 1;
|
|
529
|
+
default:
|
|
530
|
+
return undefined;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
494
533
|
function soroswapInputAmountArgIndex(fn) {
|
|
495
534
|
switch (fn) {
|
|
496
535
|
case 'swap_exact_tokens_for_tokens':
|
|
@@ -223,6 +223,9 @@ function lowerRule(rule, config) {
|
|
|
223
223
|
* by `lowerRule` to populate `uncovered` before lowering. */
|
|
224
224
|
function unsupportedConstruct(cond) {
|
|
225
225
|
switch (cond.op) {
|
|
226
|
+
// Expressible: `call_arg_scaled` is exactly this construct.
|
|
227
|
+
case 'slippage_floor':
|
|
228
|
+
return null;
|
|
226
229
|
case 'in':
|
|
227
230
|
// The oracle position rule is a hard error and must be raised before a
|
|
228
231
|
// construct can be written off as uncovered - otherwise a subtree that
|
|
@@ -286,6 +289,20 @@ function unsourceableSelector(s) {
|
|
|
286
289
|
* address -> SCOPE_SELF_CALL). */
|
|
287
290
|
function lowerCondition(cond, config) {
|
|
288
291
|
switch (cond.op) {
|
|
292
|
+
// `out >= in * num/den`. The contract refuses den == 0 or a non-positive
|
|
293
|
+
// ratio at install, so a malformed floor fails loudly rather than
|
|
294
|
+
// silently inverting the comparison.
|
|
295
|
+
case 'slippage_floor':
|
|
296
|
+
return {
|
|
297
|
+
op: 'gte',
|
|
298
|
+
left: { kind: 'call_arg', index: cond.outArgIndex },
|
|
299
|
+
right: {
|
|
300
|
+
kind: 'call_arg_scaled',
|
|
301
|
+
index: cond.inArgIndex,
|
|
302
|
+
num: cond.num,
|
|
303
|
+
den: cond.den,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
289
306
|
case 'and':
|
|
290
307
|
return { op: 'and', children: cond.children.map((c) => lowerCondition(c, config)) };
|
|
291
308
|
case 'or':
|
|
@@ -463,6 +480,8 @@ function assertNoOracleDescendants(node) {
|
|
|
463
480
|
}
|
|
464
481
|
function containsOracle(node) {
|
|
465
482
|
switch (node.op) {
|
|
483
|
+
case 'slippage_floor':
|
|
484
|
+
return false;
|
|
466
485
|
case 'compare':
|
|
467
486
|
return node.compare.selector.kind === 'oracle_price';
|
|
468
487
|
case 'in':
|
|
@@ -501,6 +520,8 @@ function assertOracleParamsTighten(params) {
|
|
|
501
520
|
* express. Mirrors the OZ adapter's `describeCondition` for parity. */
|
|
502
521
|
function describeCondition(cond) {
|
|
503
522
|
switch (cond.op) {
|
|
523
|
+
case 'slippage_floor':
|
|
524
|
+
return `slippage floor: arg[${cond.outArgIndex}] >= arg[${cond.inArgIndex}] * ${cond.num}/${cond.den}`;
|
|
504
525
|
case 'in':
|
|
505
526
|
return `value allowlist on ${describeSelector(cond.selector)} (predicate DSL)`;
|
|
506
527
|
case 'eq_seq':
|
|
@@ -212,6 +212,10 @@ function matchSpendingLimit(c) {
|
|
|
212
212
|
* cannot express, used to populate `uncovered`. */
|
|
213
213
|
function describeCondition(cond) {
|
|
214
214
|
switch (cond.op) {
|
|
215
|
+
case 'slippage_floor':
|
|
216
|
+
// The OZ primitives bound a value against a constant; this bounds one
|
|
217
|
+
// call argument against another, which none of them can express.
|
|
218
|
+
return `slippage floor on arg[${cond.outArgIndex}] (OZ built-ins cannot bound one argument against another)`;
|
|
215
219
|
case 'in':
|
|
216
220
|
return `value allowlist on ${describeSelector(cond.selector)} (arg allowlist)`;
|
|
217
221
|
case 'eq_seq':
|
package/dist-cjs/ir/types.d.ts
CHANGED
|
@@ -85,6 +85,25 @@ export type IRCondition = {
|
|
|
85
85
|
op: 'eq_seq';
|
|
86
86
|
selector: IRSelector;
|
|
87
87
|
values: string[];
|
|
88
|
+
}
|
|
89
|
+
/** A swap's output floor, expressed against its own input: the output arg
|
|
90
|
+
* must be at least `inArgIndex * num / den`.
|
|
91
|
+
*
|
|
92
|
+
* It is its own construct because `compare` bounds a selector against a
|
|
93
|
+
* CONSTANT, and a slippage floor has no constant to bound against - the
|
|
94
|
+
* acceptable output depends on the input of the same call. Encoding the
|
|
95
|
+
* recorded output as a constant would pin a policy to one trade size.
|
|
96
|
+
*
|
|
97
|
+
* `num/den` is the caller's minimum acceptable output per unit of input.
|
|
98
|
+
* It is never derived from the recording: the recorded rate is a price at
|
|
99
|
+
* one moment, and freezing it as policy would deny normal trades later.
|
|
100
|
+
* Adapters that cannot express it (the OZ built-ins) flag it `uncovered`. */
|
|
101
|
+
| {
|
|
102
|
+
op: 'slippage_floor';
|
|
103
|
+
outArgIndex: number;
|
|
104
|
+
inArgIndex: number;
|
|
105
|
+
num: string;
|
|
106
|
+
den: string;
|
|
88
107
|
};
|
|
89
108
|
export interface IRPolicyRule {
|
|
90
109
|
/** NEAR-V2 roles whitelist (empty = any; owner exempt). */
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
// untrusted-JSON parser that feeds it. A consumer accepting a hand-written
|
|
4
4
|
// policy needs both halves.
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.jsonToAst = exports.
|
|
7
|
-
var encode_ts_1 = require("./encode.js");
|
|
8
|
-
Object.defineProperty(exports, "encodePredicate", { enumerable: true, get: function () { return encode_ts_1.encodePredicate; } });
|
|
6
|
+
exports.jsonToAst = exports.encodePredicate = exports.decodePredicate = exports.decodeNode = exports.decodeLeaf = void 0;
|
|
9
7
|
var decode_ts_1 = require("./decode.js");
|
|
10
8
|
Object.defineProperty(exports, "decodeLeaf", { enumerable: true, get: function () { return decode_ts_1.decodeLeaf; } });
|
|
11
9
|
Object.defineProperty(exports, "decodeNode", { enumerable: true, get: function () { return decode_ts_1.decodeNode; } });
|
|
12
10
|
Object.defineProperty(exports, "decodePredicate", { enumerable: true, get: function () { return decode_ts_1.decodePredicate; } });
|
|
11
|
+
var encode_ts_1 = require("./encode.js");
|
|
12
|
+
Object.defineProperty(exports, "encodePredicate", { enumerable: true, get: function () { return encode_ts_1.encodePredicate; } });
|
|
13
13
|
var from_json_ts_1 = require("./from-json.js");
|
|
14
14
|
Object.defineProperty(exports, "jsonToAst", { enumerable: true, get: function () { return from_json_ts_1.jsonToAst; } });
|
|
@@ -196,6 +196,38 @@ function renderComparison(node) {
|
|
|
196
196
|
const allowed = node.op === 'lt' ? right.value : right.value + 1;
|
|
197
197
|
return `At most ${allowed} calls per ${left.windowSecs} seconds`;
|
|
198
198
|
}
|
|
199
|
+
// OP(call_arg[i], <scalar literal>) -> arg[i] OP <value>
|
|
200
|
+
//
|
|
201
|
+
// The per-call cap. It has to be here because the `amount` template below
|
|
202
|
+
// is unreachable for an interpreter policy: `amount` is deliberately not in
|
|
203
|
+
// the contract's grammar (dsl.rs - the interpreter sees one authorized
|
|
204
|
+
// call, not the transaction's token movements), so a predicate using it is
|
|
205
|
+
// refused at install. A bound on the call's own amount ARGUMENT is how a
|
|
206
|
+
// cap is actually written, and it was rendering nothing at all - the card
|
|
207
|
+
// silently understated the policy.
|
|
208
|
+
//
|
|
209
|
+
// Placed after the literal_vec case above so an exact-sequence `eq` still
|
|
210
|
+
// reads as a path rather than as a comparison.
|
|
211
|
+
if (left.kind === 'call_arg') {
|
|
212
|
+
const head = `arg[${left.index}]`;
|
|
213
|
+
const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op);
|
|
214
|
+
if (right.kind === 'literal_i128')
|
|
215
|
+
return `${head} ${sep} ${right.value}`;
|
|
216
|
+
if (right.kind === 'literal_u64')
|
|
217
|
+
return `${head} ${sep} ${right.value}`;
|
|
218
|
+
if (right.kind === 'literal_u32')
|
|
219
|
+
return `${head} ${sep} ${right.value}`;
|
|
220
|
+
if (right.kind === 'literal_address')
|
|
221
|
+
return `${head} ${sep} ${right.value}`;
|
|
222
|
+
if (right.kind === 'literal_symbol')
|
|
223
|
+
return `${head} ${sep} ${right.value}`;
|
|
224
|
+
if (right.kind === 'literal_bytes')
|
|
225
|
+
return `${head} ${sep} ${right.value}`;
|
|
226
|
+
// call_arg_scaled is the slippage floor and reads as itself.
|
|
227
|
+
if (right.kind === 'call_arg_scaled') {
|
|
228
|
+
return `${head} >= arg[${right.index}] * ${right.num}/${right.den}`;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
199
231
|
// amount <= v -> Amount <= v
|
|
200
232
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
201
233
|
return `Amount <= ${right.value}`;
|
|
@@ -115,6 +115,26 @@ function pushComparison(left, right, op, out) {
|
|
|
115
115
|
out.push(`At most ${allowed} calls per ${left.windowSecs} seconds`);
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
|
+
// Mirrors the builder's per-call cap line. Both sides must render the same
|
|
119
|
+
// string: cross-check compares the summary against the predicate, so a
|
|
120
|
+
// shape only ONE of them renders would read as a dropped constraint.
|
|
121
|
+
if (left.kind === 'call_arg') {
|
|
122
|
+
const head = `arg[${left.index}]`;
|
|
123
|
+
const sep = op === 'eq' ? '=' : comparisonOpText(op);
|
|
124
|
+
if (right.kind === 'literal_i128' ||
|
|
125
|
+
right.kind === 'literal_u64' ||
|
|
126
|
+
right.kind === 'literal_u32' ||
|
|
127
|
+
right.kind === 'literal_address' ||
|
|
128
|
+
right.kind === 'literal_symbol' ||
|
|
129
|
+
right.kind === 'literal_bytes') {
|
|
130
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (right.kind === 'call_arg_scaled') {
|
|
134
|
+
out.push(`${head} >= arg[${right.index}] * ${right.num}/${right.den}`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
118
138
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
119
139
|
out.push(`Amount <= ${right.value}`);
|
|
120
140
|
return;
|
|
@@ -31,6 +31,20 @@ export interface ComposeUserResponses {
|
|
|
31
31
|
* `oracle_price(asset) OP value` compare in the interpreter IR. Multiple
|
|
32
32
|
* entries on the same asset emit multiple leaves. */
|
|
33
33
|
oraclePriceBound?: OraclePriceBound[];
|
|
34
|
+
/** Minimum acceptable swap output per unit of input, as `num/den` (e.g.
|
|
35
|
+
* `{num:'95',den:'100'}` = accept losing at most 5%). Emitted as a
|
|
36
|
+
* slippage floor bounding the output arg against the input arg of the same
|
|
37
|
+
* call.
|
|
38
|
+
*
|
|
39
|
+
* REQUIRED to be supplied by the caller: it is never derived from the
|
|
40
|
+
* recording. The recorded in/out pair is a price at one moment, and
|
|
41
|
+
* freezing it as policy would deny ordinary trades as soon as the rate
|
|
42
|
+
* moves. Absent, no floor is emitted and the existing unbounded-output
|
|
43
|
+
* warning stands. */
|
|
44
|
+
swapMinOutRatio?: {
|
|
45
|
+
num: string;
|
|
46
|
+
den: string;
|
|
47
|
+
};
|
|
34
48
|
/** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
|
|
35
49
|
* swap_exact_tokens_for_tokens). When supplied, it REPLACES the default
|
|
36
50
|
* pin. Absent -> the recipient is pinned to the recorded value (mirroring
|
|
@@ -200,7 +200,7 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
200
200
|
// the window_spent path above already consumed the limit, so the per-call
|
|
201
201
|
// arg cap is skipped to avoid binding one limit to two different semantics.
|
|
202
202
|
const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined;
|
|
203
|
-
appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, interpreterEnabled);
|
|
203
|
+
appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, opts.userResponses?.swapMinOutRatio, interpreterEnabled);
|
|
204
204
|
}
|
|
205
205
|
// `scope.method` is carried on BOTH IRs so each adapter produces a
|
|
206
206
|
// self-consistent rule. The interpreter adapter lowers scope.method into a
|
|
@@ -242,7 +242,7 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
|
|
|
242
242
|
* SoroSwap's slippage / oracle / exact-path needs come from `userResponses`
|
|
243
243
|
* (oraclePriceBound + limitAmount) + the recorded path (eq_seq on
|
|
244
244
|
* call_arg[2]). */
|
|
245
|
-
function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, interpreterEnabled) {
|
|
245
|
+
function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, swapMinOutRatio, interpreterEnabled) {
|
|
246
246
|
// SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit it as
|
|
247
247
|
// a single-element allowlist; the interpreter adapter lowers it to `in`.
|
|
248
248
|
// When interpreter is not enabled, route to OZ so the caller sees today's
|
|
@@ -450,6 +450,30 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
450
450
|
ozConstraints.push(cond);
|
|
451
451
|
}
|
|
452
452
|
}
|
|
453
|
+
// Slippage floor: `out >= in * num/den`. Only when the caller supplied the
|
|
454
|
+
// ratio - see `swapMinOutRatio`. Without it the output arg stays free,
|
|
455
|
+
// which is the case the unbounded-swap warning above describes.
|
|
456
|
+
const outMinArgIndex = soroswapMinOutArgIndex(protocol.fn);
|
|
457
|
+
const minOutRatio = swapMinOutRatio;
|
|
458
|
+
if (minOutRatio !== undefined &&
|
|
459
|
+
inputArgIndex !== undefined &&
|
|
460
|
+
outMinArgIndex !== undefined &&
|
|
461
|
+
inputAmountArg &&
|
|
462
|
+
inputAmountArg.type === 'i128') {
|
|
463
|
+
const floor = {
|
|
464
|
+
op: 'slippage_floor',
|
|
465
|
+
outArgIndex: outMinArgIndex,
|
|
466
|
+
inArgIndex: inputArgIndex,
|
|
467
|
+
num: minOutRatio.num,
|
|
468
|
+
den: minOutRatio.den,
|
|
469
|
+
};
|
|
470
|
+
if (interpreterEnabled) {
|
|
471
|
+
interpreterConstraints.push(floor);
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
ozConstraints.push(floor);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
453
477
|
// Swap recipient (call_arg[3]): when the caller supplies
|
|
454
478
|
// swapRecipientAllowlist, emit it as an `in` constraint on the recipient
|
|
455
479
|
// arg. When absent, PIN the recipient to the recorded value - mirroring the
|
|
@@ -494,6 +518,21 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
|
|
|
494
518
|
* the exact input as arg[0]; `swap_tokens_for_exact_tokens` takes the maximum
|
|
495
519
|
* input (`amount_in_max`) as arg[1] - its arg[0] is the exact OUTPUT. Any other
|
|
496
520
|
* function has no positional input-amount argument -> undefined (no cap bound). */
|
|
521
|
+
/** The argument carrying the swap's MINIMUM ACCEPTABLE OUTPUT.
|
|
522
|
+
*
|
|
523
|
+
* Only the exact-input entrypoints have one: `swap_tokens_for_exact_tokens`
|
|
524
|
+
* fixes the output and varies the input, so its output needs no floor (its
|
|
525
|
+
* arg[1] is `amount_in_max`, already bounded by the input cap). Returning
|
|
526
|
+
* undefined there keeps a floor from being pinned to the wrong argument. */
|
|
527
|
+
function soroswapMinOutArgIndex(fn) {
|
|
528
|
+
switch (fn) {
|
|
529
|
+
case 'swap_exact_tokens_for_tokens':
|
|
530
|
+
case 'swap_exact_in_for_tokens':
|
|
531
|
+
return 1;
|
|
532
|
+
default:
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
497
536
|
function soroswapInputAmountArgIndex(fn) {
|
|
498
537
|
switch (fn) {
|
|
499
538
|
case 'swap_exact_tokens_for_tokens':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crediolabs/policy-synth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
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",
|
|
@@ -298,6 +298,9 @@ function lowerRule(rule: IRPolicyRule, config: InterpreterAdapterConfig): Lowere
|
|
|
298
298
|
* by `lowerRule` to populate `uncovered` before lowering. */
|
|
299
299
|
function unsupportedConstruct(cond: IRCondition): string | null {
|
|
300
300
|
switch (cond.op) {
|
|
301
|
+
// Expressible: `call_arg_scaled` is exactly this construct.
|
|
302
|
+
case 'slippage_floor':
|
|
303
|
+
return null
|
|
301
304
|
case 'in':
|
|
302
305
|
// The oracle position rule is a hard error and must be raised before a
|
|
303
306
|
// construct can be written off as uncovered - otherwise a subtree that
|
|
@@ -365,6 +368,20 @@ function unsourceableSelector(s: IRSelector): string | null {
|
|
|
365
368
|
* address -> SCOPE_SELF_CALL). */
|
|
366
369
|
function lowerCondition(cond: IRCondition, config: InterpreterAdapterConfig): PredicateNode {
|
|
367
370
|
switch (cond.op) {
|
|
371
|
+
// `out >= in * num/den`. The contract refuses den == 0 or a non-positive
|
|
372
|
+
// ratio at install, so a malformed floor fails loudly rather than
|
|
373
|
+
// silently inverting the comparison.
|
|
374
|
+
case 'slippage_floor':
|
|
375
|
+
return {
|
|
376
|
+
op: 'gte',
|
|
377
|
+
left: { kind: 'call_arg', index: cond.outArgIndex },
|
|
378
|
+
right: {
|
|
379
|
+
kind: 'call_arg_scaled',
|
|
380
|
+
index: cond.inArgIndex,
|
|
381
|
+
num: cond.num,
|
|
382
|
+
den: cond.den,
|
|
383
|
+
},
|
|
384
|
+
}
|
|
368
385
|
case 'and':
|
|
369
386
|
return { op: 'and', children: cond.children.map((c) => lowerCondition(c, config)) }
|
|
370
387
|
case 'or':
|
|
@@ -563,6 +580,8 @@ function assertNoOracleDescendants(node: IRCondition): void {
|
|
|
563
580
|
|
|
564
581
|
function containsOracle(node: IRCondition): boolean {
|
|
565
582
|
switch (node.op) {
|
|
583
|
+
case 'slippage_floor':
|
|
584
|
+
return false
|
|
566
585
|
case 'compare':
|
|
567
586
|
return node.compare.selector.kind === 'oracle_price'
|
|
568
587
|
case 'in':
|
|
@@ -616,6 +635,8 @@ function assertOracleParamsTighten(params: InterpreterAdapterConfig['oracleParam
|
|
|
616
635
|
* express. Mirrors the OZ adapter's `describeCondition` for parity. */
|
|
617
636
|
function describeCondition(cond: IRCondition): string {
|
|
618
637
|
switch (cond.op) {
|
|
638
|
+
case 'slippage_floor':
|
|
639
|
+
return `slippage floor: arg[${cond.outArgIndex}] >= arg[${cond.inArgIndex}] * ${cond.num}/${cond.den}`
|
|
619
640
|
case 'in':
|
|
620
641
|
return `value allowlist on ${describeSelector(cond.selector)} (predicate DSL)`
|
|
621
642
|
case 'eq_seq':
|
|
@@ -285,6 +285,10 @@ function matchSpendingLimit(c: IRCondition): SpendingLimitMatch | null {
|
|
|
285
285
|
* cannot express, used to populate `uncovered`. */
|
|
286
286
|
function describeCondition(cond: IRCondition): string {
|
|
287
287
|
switch (cond.op) {
|
|
288
|
+
case 'slippage_floor':
|
|
289
|
+
// The OZ primitives bound a value against a constant; this bounds one
|
|
290
|
+
// call argument against another, which none of them can express.
|
|
291
|
+
return `slippage floor on arg[${cond.outArgIndex}] (OZ built-ins cannot bound one argument against another)`
|
|
288
292
|
case 'in':
|
|
289
293
|
return `value allowlist on ${describeSelector(cond.selector)} (arg allowlist)`
|
|
290
294
|
case 'eq_seq':
|
package/src/ir/types.ts
CHANGED
|
@@ -86,6 +86,25 @@ export type IRCondition =
|
|
|
86
86
|
* does. Adapters that cannot express an exact ordered vector (OZ built-ins)
|
|
87
87
|
* flag this as `uncovered` rather than silently dropping it. */
|
|
88
88
|
| { op: 'eq_seq'; selector: IRSelector; values: string[] }
|
|
89
|
+
/** A swap's output floor, expressed against its own input: the output arg
|
|
90
|
+
* must be at least `inArgIndex * num / den`.
|
|
91
|
+
*
|
|
92
|
+
* It is its own construct because `compare` bounds a selector against a
|
|
93
|
+
* CONSTANT, and a slippage floor has no constant to bound against - the
|
|
94
|
+
* acceptable output depends on the input of the same call. Encoding the
|
|
95
|
+
* recorded output as a constant would pin a policy to one trade size.
|
|
96
|
+
*
|
|
97
|
+
* `num/den` is the caller's minimum acceptable output per unit of input.
|
|
98
|
+
* It is never derived from the recording: the recorded rate is a price at
|
|
99
|
+
* one moment, and freezing it as policy would deny normal trades later.
|
|
100
|
+
* Adapters that cannot express it (the OZ built-ins) flag it `uncovered`. */
|
|
101
|
+
| {
|
|
102
|
+
op: 'slippage_floor'
|
|
103
|
+
outArgIndex: number
|
|
104
|
+
inArgIndex: number
|
|
105
|
+
num: string
|
|
106
|
+
den: string
|
|
107
|
+
}
|
|
89
108
|
|
|
90
109
|
export interface IRPolicyRule {
|
|
91
110
|
/** NEAR-V2 roles whitelist (empty = any; owner exempt). */
|
package/src/predicate/index.ts
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
// untrusted-JSON parser that feeds it. A consumer accepting a hand-written
|
|
3
3
|
// policy needs both halves.
|
|
4
4
|
|
|
5
|
-
export { type EncodedPredicate, encodePredicate } from './encode.ts'
|
|
6
5
|
export { decodeLeaf, decodeNode, decodePredicate } from './decode.ts'
|
|
6
|
+
export { type EncodedPredicate, encodePredicate } from './encode.ts'
|
|
7
7
|
export { jsonToAst } from './from-json.ts'
|
|
@@ -222,6 +222,33 @@ function renderComparison(
|
|
|
222
222
|
return `At most ${allowed} calls per ${left.windowSecs} seconds`
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
// OP(call_arg[i], <scalar literal>) -> arg[i] OP <value>
|
|
226
|
+
//
|
|
227
|
+
// The per-call cap. It has to be here because the `amount` template below
|
|
228
|
+
// is unreachable for an interpreter policy: `amount` is deliberately not in
|
|
229
|
+
// the contract's grammar (dsl.rs - the interpreter sees one authorized
|
|
230
|
+
// call, not the transaction's token movements), so a predicate using it is
|
|
231
|
+
// refused at install. A bound on the call's own amount ARGUMENT is how a
|
|
232
|
+
// cap is actually written, and it was rendering nothing at all - the card
|
|
233
|
+
// silently understated the policy.
|
|
234
|
+
//
|
|
235
|
+
// Placed after the literal_vec case above so an exact-sequence `eq` still
|
|
236
|
+
// reads as a path rather than as a comparison.
|
|
237
|
+
if (left.kind === 'call_arg') {
|
|
238
|
+
const head = `arg[${left.index}]`
|
|
239
|
+
const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op)
|
|
240
|
+
if (right.kind === 'literal_i128') return `${head} ${sep} ${right.value}`
|
|
241
|
+
if (right.kind === 'literal_u64') return `${head} ${sep} ${right.value}`
|
|
242
|
+
if (right.kind === 'literal_u32') return `${head} ${sep} ${right.value}`
|
|
243
|
+
if (right.kind === 'literal_address') return `${head} ${sep} ${right.value}`
|
|
244
|
+
if (right.kind === 'literal_symbol') return `${head} ${sep} ${right.value}`
|
|
245
|
+
if (right.kind === 'literal_bytes') return `${head} ${sep} ${right.value}`
|
|
246
|
+
// call_arg_scaled is the slippage floor and reads as itself.
|
|
247
|
+
if (right.kind === 'call_arg_scaled') {
|
|
248
|
+
return `${head} >= arg[${right.index}] * ${right.num}/${right.den}`
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
225
252
|
// amount <= v -> Amount <= v
|
|
226
253
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
227
254
|
return `Amount <= ${right.value}`
|
|
@@ -125,12 +125,36 @@ function pushComparison(
|
|
|
125
125
|
out.push(`At most ${allowed} calls per ${left.windowSecs} seconds`)
|
|
126
126
|
return
|
|
127
127
|
}
|
|
128
|
+
// Mirrors the builder's per-call cap line. Both sides must render the same
|
|
129
|
+
// string: cross-check compares the summary against the predicate, so a
|
|
130
|
+
// shape only ONE of them renders would read as a dropped constraint.
|
|
131
|
+
if (left.kind === 'call_arg') {
|
|
132
|
+
const head = `arg[${left.index}]`
|
|
133
|
+
const sep = op === 'eq' ? '=' : comparisonOpText(op)
|
|
134
|
+
if (
|
|
135
|
+
right.kind === 'literal_i128' ||
|
|
136
|
+
right.kind === 'literal_u64' ||
|
|
137
|
+
right.kind === 'literal_u32' ||
|
|
138
|
+
right.kind === 'literal_address' ||
|
|
139
|
+
right.kind === 'literal_symbol' ||
|
|
140
|
+
right.kind === 'literal_bytes'
|
|
141
|
+
) {
|
|
142
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (right.kind === 'call_arg_scaled') {
|
|
146
|
+
out.push(`${head} >= arg[${right.index}] * ${right.num}/${right.den}`)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
}
|
|
128
150
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
129
151
|
out.push(`Amount <= ${right.value}`)
|
|
130
152
|
return
|
|
131
153
|
}
|
|
132
154
|
if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
|
|
133
|
-
out.push(
|
|
155
|
+
out.push(
|
|
156
|
+
`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value} (${right.decimals} dp)`
|
|
157
|
+
)
|
|
134
158
|
return
|
|
135
159
|
}
|
|
136
160
|
}
|
|
@@ -84,6 +84,17 @@ export interface ComposeUserResponses {
|
|
|
84
84
|
* `oracle_price(asset) OP value` compare in the interpreter IR. Multiple
|
|
85
85
|
* entries on the same asset emit multiple leaves. */
|
|
86
86
|
oraclePriceBound?: OraclePriceBound[]
|
|
87
|
+
/** Minimum acceptable swap output per unit of input, as `num/den` (e.g.
|
|
88
|
+
* `{num:'95',den:'100'}` = accept losing at most 5%). Emitted as a
|
|
89
|
+
* slippage floor bounding the output arg against the input arg of the same
|
|
90
|
+
* call.
|
|
91
|
+
*
|
|
92
|
+
* REQUIRED to be supplied by the caller: it is never derived from the
|
|
93
|
+
* recording. The recorded in/out pair is a price at one moment, and
|
|
94
|
+
* freezing it as policy would deny ordinary trades as soon as the rate
|
|
95
|
+
* moves. Absent, no floor is emitted and the existing unbounded-output
|
|
96
|
+
* warning stands. */
|
|
97
|
+
swapMinOutRatio?: { num: string; den: string }
|
|
87
98
|
/** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
|
|
88
99
|
* swap_exact_tokens_for_tokens). When supplied, it REPLACES the default
|
|
89
100
|
* pin. Absent -> the recipient is pinned to the recorded value (mirroring
|
|
@@ -294,6 +305,7 @@ export function composeFromRecording(
|
|
|
294
305
|
protocol,
|
|
295
306
|
opts.userResponses?.swapRecipientAllowlist,
|
|
296
307
|
swapInputAmountCap,
|
|
308
|
+
opts.userResponses?.swapMinOutRatio,
|
|
297
309
|
interpreterEnabled
|
|
298
310
|
)
|
|
299
311
|
}
|
|
@@ -350,6 +362,7 @@ function appendProtocolSpecificConstraints(
|
|
|
350
362
|
protocol: IdentifiedProtocol,
|
|
351
363
|
swapRecipientAllowlist: string[] | undefined,
|
|
352
364
|
swapInputAmountCap: string | undefined,
|
|
365
|
+
swapMinOutRatio: { num: string; den: string } | undefined,
|
|
353
366
|
interpreterEnabled: boolean
|
|
354
367
|
): void {
|
|
355
368
|
// SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit it as
|
|
@@ -567,6 +580,32 @@ function appendProtocolSpecificConstraints(
|
|
|
567
580
|
}
|
|
568
581
|
}
|
|
569
582
|
|
|
583
|
+
// Slippage floor: `out >= in * num/den`. Only when the caller supplied the
|
|
584
|
+
// ratio - see `swapMinOutRatio`. Without it the output arg stays free,
|
|
585
|
+
// which is the case the unbounded-swap warning above describes.
|
|
586
|
+
const outMinArgIndex = soroswapMinOutArgIndex(protocol.fn)
|
|
587
|
+
const minOutRatio = swapMinOutRatio
|
|
588
|
+
if (
|
|
589
|
+
minOutRatio !== undefined &&
|
|
590
|
+
inputArgIndex !== undefined &&
|
|
591
|
+
outMinArgIndex !== undefined &&
|
|
592
|
+
inputAmountArg &&
|
|
593
|
+
inputAmountArg.type === 'i128'
|
|
594
|
+
) {
|
|
595
|
+
const floor: IRCondition = {
|
|
596
|
+
op: 'slippage_floor',
|
|
597
|
+
outArgIndex: outMinArgIndex,
|
|
598
|
+
inArgIndex: inputArgIndex,
|
|
599
|
+
num: minOutRatio.num,
|
|
600
|
+
den: minOutRatio.den,
|
|
601
|
+
}
|
|
602
|
+
if (interpreterEnabled) {
|
|
603
|
+
interpreterConstraints.push(floor)
|
|
604
|
+
} else {
|
|
605
|
+
ozConstraints.push(floor)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
570
609
|
// Swap recipient (call_arg[3]): when the caller supplies
|
|
571
610
|
// swapRecipientAllowlist, emit it as an `in` constraint on the recipient
|
|
572
611
|
// arg. When absent, PIN the recipient to the recorded value - mirroring the
|
|
@@ -610,6 +649,22 @@ function appendProtocolSpecificConstraints(
|
|
|
610
649
|
* the exact input as arg[0]; `swap_tokens_for_exact_tokens` takes the maximum
|
|
611
650
|
* input (`amount_in_max`) as arg[1] - its arg[0] is the exact OUTPUT. Any other
|
|
612
651
|
* function has no positional input-amount argument -> undefined (no cap bound). */
|
|
652
|
+
/** The argument carrying the swap's MINIMUM ACCEPTABLE OUTPUT.
|
|
653
|
+
*
|
|
654
|
+
* Only the exact-input entrypoints have one: `swap_tokens_for_exact_tokens`
|
|
655
|
+
* fixes the output and varies the input, so its output needs no floor (its
|
|
656
|
+
* arg[1] is `amount_in_max`, already bounded by the input cap). Returning
|
|
657
|
+
* undefined there keeps a floor from being pinned to the wrong argument. */
|
|
658
|
+
function soroswapMinOutArgIndex(fn: string): number | undefined {
|
|
659
|
+
switch (fn) {
|
|
660
|
+
case 'swap_exact_tokens_for_tokens':
|
|
661
|
+
case 'swap_exact_in_for_tokens':
|
|
662
|
+
return 1
|
|
663
|
+
default:
|
|
664
|
+
return undefined
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
613
668
|
function soroswapInputAmountArgIndex(fn: string): number | undefined {
|
|
614
669
|
switch (fn) {
|
|
615
670
|
case 'swap_exact_tokens_for_tokens':
|
package/src/synth/evaluate.ts
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
import type { PredicateLeaf, PredicateNode, RecordedTransaction } from '../types.ts'
|
|
11
11
|
import { cloneScVal } from './deny-cases.ts'
|
|
12
12
|
import type { EvalContext } from './evaluate.ts'
|
|
13
|
-
import { literalNumericBigInt } from './predicate-literals.ts'
|
|
14
13
|
|
|
15
14
|
export interface PermitContextResponses {
|
|
16
15
|
windowSeconds: number
|