@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.
@@ -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':
@@ -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). */
@@ -1,3 +1,3 @@
1
- export { type EncodedPredicate, encodePredicate } from './encode.ts';
2
1
  export { decodeLeaf, decodeNode, decodePredicate } from './decode.ts';
2
+ export { type EncodedPredicate, encodePredicate } from './encode.ts';
3
3
  export { jsonToAst } from './from-json.ts';
@@ -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':
@@ -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). */
@@ -1,3 +1,3 @@
1
- export { type EncodedPredicate, encodePredicate } from './encode.ts';
2
1
  export { decodeLeaf, decodeNode, decodePredicate } from './decode.ts';
2
+ export { type EncodedPredicate, encodePredicate } from './encode.ts';
3
3
  export { jsonToAst } from './from-json.ts';
@@ -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.decodePredicate = exports.decodeNode = exports.decodeLeaf = exports.encodePredicate = void 0;
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.11",
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). */
@@ -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(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value} (${right.decimals} dp)`)
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':
@@ -38,7 +38,6 @@ const NORMALISED_DECIMALS = 9
38
38
  /** Mirrors MAX_ORACLE_THRESHOLD_DECIMALS in dsl.rs. */
39
39
  const MAX_ORACLE_THRESHOLD_DECIMALS = 18
40
40
 
41
-
42
41
  export interface EvalContext {
43
42
  /** Contract the interpreter is asked to enforce against. */
44
43
  contract: string
@@ -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